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