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/client_against_real_git.rs

//! The **client** half, pointed at the real `git` binary.
//!
//! Everything in the tree so far pointed *stock git at gunnar*. These point
//! gunnar at stock git, which is the direction a person meets first, and every
//! arm here is a defect that was live on 2026-08-05 and is now fixed.
//!
//! Hermetic: pipes to a real `git upload-pack` / `git receive-pack`, temporary
//! directories, no network.
//!
//! # Each guard carries its own red
//!
//! Measured, not asserted. A fetch test that only asserted "the fetch returned
//! `Ok`" was green for the whole of the destination-less-refspec bug, and a
//! tag test comparing an oid to itself cannot tell a peeled commit from a tag
//! object. So each test below either drives the broken shape as a **negative
//! control** in the same run, or asserts that the two values it is
//! distinguishing are different before comparing them.

mod common;

use common::*;
use gix::hash::Kind;
use gunnar_client::fetch::{fetch_with_io, open_or_init_bare};

const TRACKING_REFSPECS: &[&str] = &[
    "+refs/heads/*:refs/remotes/origin/*",
    "+refs/tags/*:refs/tags/*",
];

/// A fetch must leave a **reference** behind, not only a pack.
///
/// `gunnar fetch` passed `refs/heads/*` — a refspec with no destination — so
/// against a real `git upload-pack` it downloaded the objects, reported them,
/// exited 0, and wrote no ref and no `FETCH_HEAD`. The objects were reachable
/// from nothing and the next `gc` would have taken them.
///
/// The negative control is the second half: a fetch whose refspec names no
/// destination must still write nothing. Without it this test would pass on a
/// client that wrote refs for every refspec ever handed to it, and would go green
/// again the moment `updated_refs` was wired to report intent instead of edits.
///
/// The control **was** `refs/heads/*`, and cannot be any more: that spec is now
/// refused outright, the way git refuses it
/// (`gunnar_client::fetch::check_fetch_refspec`,
/// `tests/fetch_refspec_refusal.rs`). `refs/heads/main` replaces it and keeps the
/// control's whole meaning — git accepts it, gunnar accepts it, it transfers a
/// pack, and it writes `FETCH_HEAD` and no reference. The refusal of the old
/// control is asserted here too, so the two facts cannot rot apart.
#[test]
fn a_fetch_writes_local_refs_and_a_destinationless_refspec_writes_none() {
    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");
        let tip = commit_file(&src, "b.txt", "two\n", "second");
        let url = src.to_string_lossy().to_string();

        // ── the fixed shape ──────────────────────────────────────────────
        let dest = dir.path().join("good.git");
        let repo = open_or_init_bare(&dest, kind).unwrap();
        let (o, i, mut c) = GitChild::upload_pack(&src).into_halves();
        let good = fetch_with_io(&repo, o, i, &url, &url, TRACKING_REFSPECS).unwrap();
        let _ = c.wait();

        assert!(good.received_pack, "{kind:?}: no pack arrived at all");
        assert!(
            !good.updated_refs.is_empty(),
            "{kind:?}: a pack arrived and not one local ref was written"
        );
        // Applied output, read back by stock git rather than from our own
        // return value.
        assert_eq!(
            git(&dest, &["rev-parse", "refs/remotes/origin/main"]),
            tip,
            "{kind:?}: the tracking ref did not reach the fetched tip"
        );
        assert!(
            good.updated_refs
                .iter()
                .any(|u| u.name == "refs/remotes/origin/main" && u.new.is_some()),
            "{kind:?}: the reported updates do not name the ref that moved: {:?}",
            good.updated_refs
        );

        // ── the negative control: legal, and writes no reference ─────────
        let dest = dir.path().join("bad.git");
        let repo = open_or_init_bare(&dest, kind).unwrap();
        let (o, i, mut c) = GitChild::upload_pack(&src).into_halves();
        let bad = fetch_with_io(&repo, o, i, &url, &url, &["refs/heads/main"]).unwrap();
        let _ = c.wait();

        assert!(
            bad.received_pack,
            "{kind:?}: the control must still transfer a pack, or it proves nothing"
        );
        assert!(
            bad.updated_refs.is_empty(),
            "{kind:?}: a destination-less refspec wrote {:?}; if git changed, so must the \
             default in `gunnar-cli`",
            bad.updated_refs
        );
        assert!(
            git_try(&dest, &["rev-parse", "refs/remotes/origin/main"]).is_err(),
            "{kind:?}: the control wrote a tracking ref, so it is not a control"
        );

        // ── and the control that USED to be here is now refused ──────────
        let dest = dir.path().join("refused.git");
        let repo = open_or_init_bare(&dest, kind).unwrap();
        let err = fetch_with_io(
            &repo,
            std::io::empty(),
            std::io::sink(),
            &url,
            &url,
            &["refs/heads/*"],
        )
        .expect_err("{kind:?}: `refs/heads/*` was accepted again");
        assert_eq!(
            err.to_string(),
            "invalid refspec 'refs/heads/*'",
            "{kind:?}: refused, but not for being an invalid refspec"
        );
    }
}

/// `refs/tags/v1` on an annotated tag names the **tag object**.
///
/// The client read `peeled.or(target)` and so reported the commit the tag
/// resolves to, under the tag's own name — a different object, silently, in
/// every `gunnar fetch` listing.
#[test]
fn an_annotated_tag_is_advertised_as_the_tag_object_not_its_commit() {
    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");
        git(&src, &["tag", "-a", "v1", "-m", "release"]);

        let tag_object = git(&src, &["rev-parse", "refs/tags/v1"]);
        let peeled = git(&src, &["rev-parse", "refs/tags/v1^{commit}"]);
        // Without this the whole test is the identity-value trap: a lightweight
        // tag has tag_object == peeled and every assertion below passes on a
        // client that reports either one.
        assert_ne!(
            tag_object, peeled,
            "{kind:?}: the fixture's tag is not annotated, so this test cannot fail"
        );

        let dest = dir.path().join("d.git");
        let repo = open_or_init_bare(&dest, kind).unwrap();
        let url = src.to_string_lossy().to_string();
        let (o, i, mut c) = GitChild::upload_pack(&src).into_halves();
        let out = fetch_with_io(&repo, o, i, &url, &url, TRACKING_REFSPECS).unwrap();
        let _ = c.wait();

        let advertised = out
            .refs
            .iter()
            .find(|r| r.name == "refs/tags/v1")
            .unwrap_or_else(|| panic!("{kind:?}: refs/tags/v1 was not advertised: {:?}", out.refs));
        assert_eq!(
            advertised.oid.to_string(),
            tag_object,
            "{kind:?}: advertised the peeled commit under the tag's name"
        );
        // And the ref that landed locally is the tag object too, read back with
        // stock git.
        assert_eq!(git(&dest, &["rev-parse", "refs/tags/v1"]), tag_object);
        assert_eq!(
            git(&dest, &["cat-file", "-t", "refs/tags/v1"]),
            "tag",
            "{kind:?}: the annotated tag object did not come across"
        );
    }
}

/// When a `pre-receive` hook says no, its own sentence must reach the caller.
///
/// `receive-pack` muxes hook stderr onto **band 2**; every reader in the tree
/// looked only at band 3, which is empty for a hook rejection. So gunnar
/// reported `pre-receive hook declined` and threw away the reason, where stock
/// git prints `remote: <the hook's words>`.
#[test]
fn a_rejecting_hooks_own_words_reach_the_caller() {
    const MESSAGE: &str = "policy: protected branch, ask an owner";
    let fx = Fixture::new(Kind::Sha1);
    commit_file(&fx.src(), "a.txt", "one\n", "first");
    write_hook(&fx.bare(), "pre-receive", MESSAGE);

    let repo = open(&fx.src());
    let specs = gunnar_client::RefSpec::parse_all(&["refs/heads/main:refs/heads/main"]).unwrap();
    let mut t = GitChild::receive_pack(&fx.bare());
    let outcome =
        gunnar_client::push_over(&repo, &mut t, &specs, &gunnar_client::PushOptions::new())
            .unwrap();
    t.abandon();

    let report = outcome.report.expect("the remote sent a report");
    assert!(
        !report.is_ok(),
        "the hook exited 1, so the push cannot have been accepted"
    );
    // The red this guard is built on: band 3 alone is empty here, which is why
    // reading it alone lost the message. If a future git starts sending hook
    // output on band 3 this line fires and the guard is re-derived rather than
    // silently made vacuous.
    assert!(
        report.remote_errors.is_empty(),
        "hook output arrived on band 3, not band 2: {:?}",
        report.remote_errors
    );
    let lines: Vec<&str> = report.remote_lines().collect();
    assert!(
        lines.iter().any(|l| l.contains(MESSAGE)),
        "the hook's own message is not in remote_lines(): {lines:?}"
    );
    assert!(
        report
            .failure_summary()
            .is_some_and(|s| s.contains(MESSAGE)),
        "failure_summary() dropped the only sentence that says why"
    );
}

/// Write an executable hook that refuses everything with `message` on stderr.
fn write_hook(bare: &std::path::Path, name: &str, message: &str) {
    let hooks = bare.join("hooks");
    std::fs::create_dir_all(&hooks).unwrap();
    let path = hooks.join(name);
    std::fs::write(&path, format!("#!/bin/sh\necho '{message}' >&2\nexit 1\n")).unwrap();
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
    }
}

/// A remote with **no commits yet** must be readable, and read correctly.
///
/// `gunnar clone` failed outright on one — *"advertised no HEAD"*, exit 1, no
/// directory — where `git clone` produces a working checkout. That is the first
/// move after creating a repository, so the client could not bootstrap one at
/// all. MEASURED against a real `sshd` on 2026-08-05.
///
/// Everything the fix depends on is here: the object format has to be **read**
/// (guessing it wedges the clone exactly as it wedged the mirror), the unborn
/// `HEAD` names the branch the first commit must land on, and `has_refs` must
/// not count the `unborn` line as a reference — which the first cut of this did,
/// making every empty repository look non-empty.
#[test]
fn an_empty_remote_is_read_for_its_hash_and_its_unborn_head() {
    for kind in BOTH_HASH_KINDS {
        for v2 in [true, false] {
            let dir = tempfile::tempdir().unwrap();
            let empty = dir.path().join("empty.git");
            std::fs::create_dir_all(&empty).unwrap();
            git(
                &empty,
                &[
                    "init",
                    "-q",
                    "--bare",
                    "-b",
                    "trunk",
                    format_flag(kind),
                    ".",
                ],
            );

            let (o, i, mut c) = GitChild::spawn("upload-pack", &empty, v2).into_halves();
            let path = empty.to_string_lossy().to_string();
            let id = gunnar_client::fetch::remote_identity_with_io(o, i, &path)
                .unwrap_or_else(|e| panic!("{kind:?} v{}: {e}", u8::from(v2) * 2));
            let _ = c.wait();

            assert_eq!(
                id.hash_kind,
                kind,
                "{kind:?} v{}: the object format was not read off the wire",
                u8::from(v2) * 2
            );
            assert!(
                !id.has_refs,
                "{kind:?} v{}: an empty repository reported references: {id:?}",
                u8::from(v2) * 2
            );
            // v0 has no `unborn` extension at all, so only v2 can name the
            // branch. Stated rather than asserted away, so the v0 arm cannot
            // pass by accident on a v2 answer.
            if v2 {
                assert_eq!(
                    id.unborn_head.as_deref(),
                    Some("refs/heads/trunk"),
                    "{kind:?}: the unborn HEAD did not name the remote's branch"
                );
            }

            // ── the red: a repository WITH a commit must not look empty ────
            let work = dir.path().join("work");
            init_worktree(&work, kind);
            commit_file(&work, "a.txt", "one\n", "first");
            git(
                &work,
                &["push", "-q", &path, "refs/heads/main:refs/heads/trunk"],
            );

            let (o, i, mut c) = GitChild::spawn("upload-pack", &empty, v2).into_halves();
            let id = gunnar_client::fetch::remote_identity_with_io(o, i, &path).unwrap();
            let _ = c.wait();
            assert!(
                id.has_refs,
                "{kind:?} v{}: a repository with a commit reported no references, so the \
                 emptiness test is not a test",
                u8::from(v2) * 2
            );
            assert_eq!(id.hash_kind, kind);
        }
    }
}