Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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"
    );
}