crates/gunnar-client/tests/real_git_fetch.rs
//! Fetch, against the real `git upload-pack` binary.
//!
//! The fetch itself is `gix`'s and `nornir-git`'s — reused, not written; what is new
//! is [`gunnar_client::fetch_with_io`], which runs it over an arbitrary
//! reader/writer pair instead of only over a russh channel. These tests are what
//! that seam is for: they point it at a real `git upload-pack` child and assert,
//! with stock git, that the objects arrived.
//!
//! Hermetic: pipes, temporary directories, no network.
mod common;
use common::*;
use gix::hash::Kind;
use gunnar_client::fetch::{fetch_with_io, open_or_init_bare};
const ALL_REFS: &[&str] = &["+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"];
#[test]
fn fetch_from_real_upload_pack_sha1_and_sha256() {
for kind in BOTH_HASH_KINDS {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
init_worktree(&src, kind);
commit_file(&src, "a.txt", "one\n", "first");
commit_file(&src, "b.txt", "two\n", "second");
git(&src, &["tag", "-a", "v1", "-m", "release"]);
let dest = dir.path().join("dest.git");
let repo = open_or_init_bare(&dest, kind).unwrap();
let (stdout, stdin, mut child) = GitChild::upload_pack(&src).into_halves();
let url = src.to_string_lossy().to_string();
let outcome = fetch_with_io(&repo, stdout, stdin, &url, &url, ALL_REFS).unwrap();
let _ = child.wait();
assert!(outcome.received_pack, "{kind:?}: no pack was transferred");
assert!(
outcome.objects_received >= 6,
"{kind:?}: only {} objects",
outcome.objects_received
);
// Read the result back with stock git, not with gunnar.
assert_eq!(
git(&dest, &["rev-parse", "refs/heads/main"]),
git(&src, &["rev-parse", "refs/heads/main"]),
"{kind:?}"
);
assert_eq!(
objects_via_git(&dest, "refs/heads/main"),
objects_via_git(&src, "refs/heads/main"),
"{kind:?}: the fetched closure is incomplete"
);
assert_eq!(
git(&dest, &["cat-file", "-t", "refs/tags/v1"]),
"tag",
"{kind:?}: the annotated tag object did not come across"
);
assert!(
git_try(&dest, &["fsck", "--strict", "--no-progress"]).is_ok(),
"{kind:?}: fsck rejected the fetched repository"
);
}
}
/// A second fetch with nothing new must transfer no pack. Without this, a fetch
/// that silently re-downloaded everything would look identical to a working one.
#[test]
fn a_second_fetch_with_no_changes_receives_no_pack() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
init_worktree(&src, Kind::Sha1);
commit_file(&src, "a.txt", "one\n", "first");
let dest = dir.path().join("dest.git");
let repo = open_or_init_bare(&dest, Kind::Sha1).unwrap();
let url = src.to_string_lossy().to_string();
let (o, i, mut c) = GitChild::upload_pack(&src).into_halves();
let first = fetch_with_io(&repo, o, i, &url, &url, ALL_REFS).unwrap();
let _ = c.wait();
assert!(first.received_pack);
let (o, i, mut c) = GitChild::upload_pack(&src).into_halves();
let second = fetch_with_io(&repo, o, i, &url, &url, ALL_REFS).unwrap();
let _ = c.wait();
assert!(
!second.received_pack,
"nothing changed upstream, so nothing should have been transferred"
);
assert_eq!(second.objects_received, 0);
}
/// A fetch that picks up new commits must bring only those objects across, and
/// the ref must move.
#[test]
fn an_incremental_fetch_picks_up_new_commits() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
init_worktree(&src, Kind::Sha1);
commit_file(&src, "a.txt", "one\n", "first");
let dest = dir.path().join("dest.git");
let repo = open_or_init_bare(&dest, Kind::Sha1).unwrap();
let url = src.to_string_lossy().to_string();
let (o, i, mut c) = GitChild::upload_pack(&src).into_halves();
fetch_with_io(&repo, o, i, &url, &url, ALL_REFS).unwrap();
let _ = c.wait();
let second_sha = commit_file(&src, "b.txt", "two\n", "second");
let (o, i, mut c) = GitChild::upload_pack(&src).into_halves();
let outcome = fetch_with_io(&repo, o, i, &url, &url, ALL_REFS).unwrap();
let _ = c.wait();
assert!(outcome.received_pack);
assert_eq!(
git(&dest, &["rev-parse", "refs/heads/main"]),
second_sha,
"the fetched ref did not move"
);
assert_eq!(
objects_via_git(&dest, "refs/heads/main"),
objects_via_git(&src, "refs/heads/main")
);
}
/// A clone and a push, back to back, over the same pair of real git binaries:
/// fetch a repository out of one, then push it into a third that has never seen
/// it. The end-to-end shape `gunnar mirror` is made of.
#[test]
fn fetch_then_push_round_trips_through_real_git() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
init_worktree(&src, Kind::Sha256);
commit_file(&src, "a.txt", "one\n", "first");
commit_file(&src, "b.txt", "two\n", "second");
let middle = dir.path().join("middle.git");
let repo = open_or_init_bare(&middle, Kind::Sha256).unwrap();
let url = src.to_string_lossy().to_string();
let (o, i, mut c) = GitChild::upload_pack(&src).into_halves();
fetch_with_io(&repo, o, i, &url, &url, ALL_REFS).unwrap();
let _ = c.wait();
let far = dir.path().join("far.git");
init_bare(&far, Kind::Sha256);
let repo = open(&middle);
let specs = gunnar_client::RefSpec::parse_all(&["refs/heads/main:refs/heads/main"]).unwrap();
let mut t = GitChild::receive_pack(&far);
let outcome =
gunnar_client::push_over(&repo, &mut t, &specs, &gunnar_client::PushOptions::new())
.unwrap();
t.finish();
assert!(outcome.is_ok(), "{:?}", outcome.report);
assert_eq!(
git(&far, &["rev-parse", "refs/heads/main"]),
git(&src, &["rev-parse", "refs/heads/main"]),
"the commit did not survive the round trip"
);
assert_eq!(
objects_via_git(&far, "refs/heads/main"),
objects_via_git(&src, "refs/heads/main")
);
assert!(git_try(&far, &["fsck", "--strict", "--no-progress"]).is_ok());
}
/// The wire must say who we are, and a `fetch` must say the same thing a `push`
/// does.
///
/// This asserts the BYTES the client wrote, not a field on an options struct:
/// the agent a fetch sends is not gunnar's to set directly — gix builds the
/// request and reads `gitoxide.userAgent` off the repository — so the only
/// honest place to check it is the wire.
///
/// Seen red before the fix: the capture carried `agent=git/oxide-0.86.0` and no
/// `gunnar/` anywhere, while `push` on the same client said `gunnar/<version>`.
/// One client, two identities, and no way for an operator to attribute a fetch.
///
/// The two strings are still not byte-identical — gix prepends `git/` to
/// `gitoxide.userAgent`, so a fetch reads `git/gunnar/<version>`. That prefix is
/// gix's and follows git's own `agent=git/2.53.0`; the defect was that neither
/// half named gunnar, not that the two differed in shape.
#[test]
fn a_fetch_names_gunnar_on_the_wire_the_way_a_push_does() {
// `Tee` lives in `common` because `tests/real_git_shallow.rs` needs the same
// "what did the client actually write" proof for `deepen` (one implementation).
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
init_worktree(&src, Kind::Sha1);
commit_file(&src, "a.txt", "one\n", "first");
let dest = dir.path().join("dest.git");
let repo = open_or_init_bare(&dest, Kind::Sha1).unwrap();
let (stdout, stdin, mut child) = GitChild::upload_pack(&src).into_halves();
let (tee, seen) = Tee::new(stdin);
let url = src.to_string_lossy().to_string();
let outcome = fetch_with_io(&repo, stdout, tee, &url, &url, ALL_REFS).unwrap();
let _ = child.wait();
assert!(outcome.received_pack, "no pack, so nothing was negotiated");
let wire = String::from_utf8_lossy(&seen.lock().unwrap()).into_owned();
// gix prepends `git/` to whatever `gitoxide.userAgent` holds, so the fetch
// says `agent=git/gunnar/<version>` where the push says `agent=gunnar/<version>`.
// That prefix is gix's convention and not ours to remove; what the defect
// was about is whether the string names gunnar AT ALL, and it now does on
// both paths.
let expected = gunnar_client::agent();
assert!(
wire.contains(&expected),
"the client never named {expected:?} on the wire. What it did send:\n{}",
wire.lines()
.filter(|l| l.contains("agent="))
.collect::<Vec<_>>()
.join("\n")
);
// The anti-vacuity half: a capture that saw no agent at all would satisfy a
// `!contains("oxide")` check while proving nothing.
assert!(
wire.contains("agent="),
"the capture holds no `agent=` line at all, so this guard is blind"
);
}
/// `FETCH_HEAD` must exist, and say what git says.
///
/// `git merge FETCH_HEAD`, `git pull`'s second half, `git log FETCH_HEAD` and
/// every script doing `git rev-parse FETCH_HEAD` read this file. gix's `receive`
/// applies ref edits and never writes it, so until 2026-08-05 a `gunnar fetch`
/// left none at all and all of those failed with *unknown revision*.
///
/// The comparison is against **stock git fetching the same refspecs from the
/// same source**, on the fields that carry meaning: the object, whether the line
/// is for merge, and what kind of ref it names. The description's URL text is
/// not compared — it is whatever string the caller passed, and git and gunnar
/// are handed different ones by this harness.
///
/// Seen red before the fix: no `FETCH_HEAD` existed, so the read failed outright.
#[test]
fn fetch_head_holds_what_git_puts_there() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
init_worktree(&src, Kind::Sha1);
commit_file(&src, "a.txt", "one\n", "first");
git(&src, &["tag", "-a", "v1", "-m", "release"]);
// gunnar's side.
let ours = dir.path().join("ours.git");
let repo = open_or_init_bare(&ours, Kind::Sha1).unwrap();
let (stdout, stdin, mut child) = GitChild::upload_pack(&src).into_halves();
let url = src.to_string_lossy().to_string();
fetch_with_io(&repo, stdout, stdin, &url, &url, ALL_REFS).unwrap();
let _ = child.wait();
let ours_text = std::fs::read_to_string(ours.join("FETCH_HEAD"))
.expect("gunnar wrote no FETCH_HEAD at all");
// git's side, same source, same refspecs.
let theirs = dir.path().join("theirs.git");
init_bare(&theirs, Kind::Sha1);
git(&theirs, &["fetch", &url, ALL_REFS[0], ALL_REFS[1]]);
let theirs_text = std::fs::read_to_string(theirs.join("FETCH_HEAD"))
.expect("git wrote no FETCH_HEAD, so this comparison is meaningless");
/// `(oid, for-merge, kind, short name)` — the fields that carry meaning.
fn fields(text: &str) -> Vec<(String, bool, String, String)> {
let mut out: Vec<_> = text
.lines()
.filter(|l| !l.trim().is_empty())
.map(|line| {
let mut parts = line.splitn(3, '\t');
let oid = parts.next().unwrap_or_default().to_string();
let merge = parts.next().unwrap_or_default();
let desc = parts.next().unwrap_or_default();
let mut words = desc.split_whitespace();
let kind = words.next().unwrap_or_default().to_string();
let short = words
.next()
.unwrap_or_default()
.trim_matches('\'')
.to_string();
(oid, merge.is_empty(), kind, short)
})
.collect();
out.sort();
out
}
assert_eq!(
fields(&ours_text),
fields(&theirs_text),
"gunnar's FETCH_HEAD differs from git's.\n--- gunnar ---\n{ours_text}--- git ---\n{theirs_text}"
);
// The tag line must name the TAG OBJECT, not the commit it peels to — the
// same distinction `AdvertisedRef::oid` carries, and the reason `git
// rev-parse FETCH_HEAD` after fetching a tag must name the tag.
let tag_oid = git(&src, &["rev-parse", "refs/tags/v1"]);
assert!(
ours_text.contains(tag_oid.trim()),
"FETCH_HEAD does not carry the tag object {tag_oid:?}:\n{ours_text}"
);
// Anti-vacuity: an empty file would make the comparison above trivially true
// if git also wrote nothing.
assert!(
!fields(&ours_text).is_empty(),
"FETCH_HEAD parsed to zero lines, so this guard proves nothing"
);
}