crates/gunnar-wire/tests/protocol.rs
//! Byte-level tests of both state machines, driven over `Vec<u8>` with no
//! transport at all.
//!
//! These do not replace `real_git_interop.rs` (only stock `git` can prove
//! interoperability), but they pin the things that are hard to *observe* from
//! outside: exact object counts, which parent of a merge got walked, and what a
//! filter actually removed. A clone that succeeds tells you none of that.
//!
//! Packs are checked by handing them to **gix's own indexer**, never by gunnar
//! re-reading its own bytes.
mod common;
use gix_hash::{Kind, ObjectId};
use gunnar_pktline::Packet;
use gunnar_store::git_storage_trait::{GitOps, GitServe, ObjType};
use gunnar_store::mem::MemGitServe;
use gunnar_wire::receive_pack::{AllowAll, PushGate, RefCommand};
use gunnar_wire::upload_pack::{Config, FetchRequest, LsRefsRequest, WantPolicy};
use gunnar_wire::{ProtocolVersion, ReceivePack, RefStatus, UploadPack};
use common::{
empty_pack, index_and_count, name, push_bytes, v2_request, validating_ingest, Fixture, Response,
};
/// The fixture's five commits carry one blob and one tree each.
const OBJECTS_PER_COMMIT: u32 = 3;
fn fetch(fixture: &Fixture, request: &FetchRequest) -> (Response, u32) {
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut out = Vec::new();
let outcome = upload.fetch(request, &mut out).expect("fetch");
(Response::parse(&out), outcome.objects_sent)
}
fn clone_request(wants: &[ObjectId]) -> FetchRequest {
FetchRequest {
wants: wants.to_vec(),
done: true,
thin_pack: true,
ofs_delta: true,
..Default::default()
}
}
// ── ls-refs ─────────────────────────────────────────────────────────────────
#[test]
fn ls_refs_reports_head_first_with_its_symref_and_the_peeled_tag() {
let fixture = Fixture::build(Kind::Sha1);
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut out = Vec::new();
upload
.ls_refs(
&LsRefsRequest {
symrefs: true,
peel: true,
unborn: true,
ref_prefixes: Vec::new(),
},
&mut out,
)
.expect("ls-refs");
let response = Response::parse(&out);
let lines = response.lines();
assert_eq!(
lines[0],
format!("{} HEAD symref-target:refs/heads/main", fixture.four),
"HEAD must come first, with its symref target: {lines:?}"
);
assert!(
lines.contains(&format!("{} refs/heads/main", fixture.four)),
"{lines:?}"
);
assert!(
lines.contains(&format!("{} refs/heads/side", fixture.three)),
"{lines:?}"
);
assert!(
lines.contains(&format!(
"{} refs/tags/v1 peeled:{}",
fixture.tag, fixture.merge
)),
"the annotated tag was not peeled: {lines:?}"
);
assert_eq!(response.packets.last(), Some(&Packet::Flush));
}
#[test]
fn ls_refs_without_peel_or_symrefs_reports_neither() {
let fixture = Fixture::build(Kind::Sha1);
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut out = Vec::new();
upload
.ls_refs(&LsRefsRequest::default(), &mut out)
.expect("ls-refs");
let lines = Response::parse(&out).lines();
assert!(
lines.iter().all(|line| !line.contains("symref-target:")),
"symrefs were not asked for: {lines:?}"
);
assert!(
lines.iter().all(|line| !line.contains("peeled:")),
"peeling was not asked for: {lines:?}"
);
}
#[test]
fn ref_prefix_narrows_the_answer() {
let fixture = Fixture::build(Kind::Sha1);
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut out = Vec::new();
upload
.ls_refs(
&LsRefsRequest {
ref_prefixes: vec!["refs/tags/".to_owned()],
..Default::default()
},
&mut out,
)
.expect("ls-refs");
let lines = Response::parse(&out).lines();
assert_eq!(lines.len(), 1, "a tags-only prefix returned {lines:?}");
assert!(lines[0].ends_with("refs/tags/v1"));
}
// ── fetch ───────────────────────────────────────────────────────────────────
#[test]
fn a_clone_sized_fetch_packs_the_whole_history_on_band_one() {
let fixture = Fixture::build(Kind::Sha1);
let (response, sent) = fetch(&fixture, &clone_request(&[fixture.four]));
assert!(
response.has_line("packfile"),
"no packfile section: {:?}",
response.lines()
);
// `band(1)` is the only place the band byte is stripped, so a pack that
// indexes out of it has proven the byte was there.
let pack = response.band(1);
assert!(
!pack.is_empty(),
"the packfile section carried no band-1 data"
);
assert_eq!(&pack[..4], b"PACK");
let counted = index_and_count(&pack, Kind::Sha1);
assert_eq!(
counted,
5 * OBJECTS_PER_COMMIT,
"5 commits, 5 trees, 5 blobs"
);
assert_eq!(counted, sent, "the reported count disagrees with the pack");
assert_eq!(response.packets.last(), Some(&Packet::Flush));
}
#[test]
fn wanting_the_tag_packs_the_tag_object_too() {
let fixture = Fixture::build(Kind::Sha1);
let (response, _) = fetch(&fixture, &clone_request(&[fixture.four, fixture.tag]));
// The tag peels to `merge`, which is already reachable from `four`, so the
// only new object is the tag itself.
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
5 * OBJECTS_PER_COMMIT + 1
);
}
/// A merge has two parents. A traversal that follows only the first still
/// produces a clone that looks right at the tip, so the assertion has to be an
/// object count and not "it worked".
#[test]
fn both_parents_of_a_merge_are_walked() {
let fixture = Fixture::build(Kind::Sha1);
let request = FetchRequest {
haves: vec![fixture.two],
..clone_request(&[fixture.merge])
};
let (response, _) = fetch(&fixture, &request);
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
2 * OBJECTS_PER_COMMIT,
"with `two` excluded the pack is `merge` and `three`; half that means \
the second parent was never followed"
);
}
#[test]
fn a_fetch_whose_haves_cover_the_wants_sends_a_legal_empty_pack() {
let fixture = Fixture::build(Kind::Sha1);
let request = FetchRequest {
haves: vec![fixture.four],
..clone_request(&[fixture.four])
};
let (response, sent) = fetch(&fixture, &request);
assert_eq!(sent, 0);
let pack = response.band(1);
assert_eq!(
index_and_count(&pack, Kind::Sha1),
0,
"an empty pack must still be a pack gix will index"
);
}
#[test]
fn sha256_is_served_end_to_end_and_not_inferred_from_the_sha1_green() {
let fixture = Fixture::build(Kind::Sha256);
assert_eq!(fixture.four.to_hex().to_string().len(), 64);
let (response, sent) = fetch(&fixture, &clone_request(&[fixture.four]));
let pack = response.band(1);
assert_eq!(index_and_count(&pack, Kind::Sha256), 5 * OBJECTS_PER_COMMIT);
assert_eq!(sent, 5 * OBJECTS_PER_COMMIT);
}
#[test]
fn a_want_whose_hash_kind_is_wrong_is_refused() {
let fixture = Fixture::build(Kind::Sha256);
let sha1_want = format!("want {}", "a".repeat(40));
let err = FetchRequest::parse(&[sha1_want.into_bytes()], Kind::Sha256, Default::default())
.expect_err("a sha1 want on a sha256 repository must be refused");
assert!(
matches!(err, gunnar_wire::Error::ObjectFormatMismatch { .. }),
"unexpected error: {err}"
);
let _ = fixture;
}
// ── filters ─────────────────────────────────────────────────────────────────
#[test]
fn blob_none_really_omits_the_blobs() {
let fixture = Fixture::build(Kind::Sha1);
let request = FetchRequest {
filter: Some(gunnar_wire::Filter::BlobNone),
..clone_request(&[fixture.four])
};
let (response, _) = fetch(&fixture, &request);
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
10,
"5 commits and 5 trees, no blobs"
);
// The unfiltered number is what makes that assertion mean something.
let (plain, _) = fetch(&fixture, &clone_request(&[fixture.four]));
assert_eq!(index_and_count(&plain.band(1), Kind::Sha1), 15);
}
#[test]
fn tree_zero_sends_commits_only() {
let fixture = Fixture::build(Kind::Sha1);
let request = FetchRequest {
filter: Some(gunnar_wire::Filter::TreeDepth(0)),
..clone_request(&[fixture.four])
};
let (response, _) = fetch(&fixture, &request);
assert_eq!(index_and_count(&response.band(1), Kind::Sha1), 5);
}
#[test]
fn blob_limit_keeps_the_small_ones() {
let fixture = Fixture::build(Kind::Sha1);
// Every fixture blob is under 16 bytes, so this filter keeps them all...
let generous = FetchRequest {
filter: Some(gunnar_wire::Filter::BlobLimit(1024)),
..clone_request(&[fixture.four])
};
let (response, _) = fetch(&fixture, &generous);
assert_eq!(index_and_count(&response.band(1), Kind::Sha1), 15);
// ...and this one keeps none, which is what shows the size is being read.
let mean = FetchRequest {
filter: Some(gunnar_wire::Filter::BlobLimit(1)),
..clone_request(&[fixture.four])
};
let (response, _) = fetch(&fixture, &mean);
assert_eq!(index_and_count(&response.band(1), Kind::Sha1), 10);
}
#[test]
fn an_unimplemented_filter_is_refused_by_name() {
let err = gunnar_wire::Filter::parse("sparse:oid=deadbeef")
.expect_err("gunnar does not implement sparse filters");
assert!(
matches!(err, gunnar_wire::Error::UnsupportedFilter { .. }),
"unexpected error: {err}"
);
assert_eq!(
gunnar_wire::Filter::parse("blob:limit=2k").unwrap(),
gunnar_wire::Filter::BlobLimit(2048)
);
}
// ── shallow ─────────────────────────────────────────────────────────────────
#[test]
fn depth_one_reports_a_boundary_and_packs_one_commit() {
let fixture = Fixture::build(Kind::Sha1);
let mut request = clone_request(&[fixture.four]);
request.shallow.depth = Some(1);
let (response, _) = fetch(&fixture, &request);
assert!(
response.has_line("shallow-info"),
"no shallow-info section: {:?}",
response.lines()
);
assert!(
response.has_line(&format!("shallow {}", fixture.four)),
"the boundary was not reported: {:?}",
response.lines()
);
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
OBJECTS_PER_COMMIT
);
}
#[test]
fn depth_two_reaches_both_parents_of_the_merge() {
let fixture = Fixture::build(Kind::Sha1);
let mut request = clone_request(&[fixture.four]);
request.shallow.depth = Some(3);
let (response, _) = fetch(&fixture, &request);
// four, merge, then both of merge's parents at depth 3.
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
4 * OBJECTS_PER_COMMIT
);
}
#[test]
fn a_root_commit_is_never_reported_as_shallow() {
let fixture = Fixture::build(Kind::Sha1);
let mut request = clone_request(&[fixture.four]);
request.shallow.depth = Some(99);
let (response, _) = fetch(&fixture, &request);
assert!(
!response.has_line(&format!("shallow {}", fixture.one)),
"the root commit was reported as grafted, which makes every later \
fetch ask for a parent that does not exist: {:?}",
response.lines()
);
}
/// `ref-in-want` is advertised, so it has to work. An advertised feature with
/// no implementation behind it is the same bug as an implementation with no
/// advertisement, seen from the other side.
#[test]
fn want_ref_resolves_the_name_and_echoes_a_wanted_refs_section() {
let fixture = Fixture::build(Kind::Sha1);
let request = FetchRequest {
want_refs: vec!["refs/heads/side".to_owned()],
done: true,
..Default::default()
};
let (response, _) = fetch(&fixture, &request);
assert!(
response.has_line("wanted-refs"),
"no wanted-refs section: {:?}",
response.lines()
);
assert!(
response.has_line(&format!("{} refs/heads/side", fixture.three)),
"the resolved name was not echoed: {:?}",
response.lines()
);
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
2 * OBJECTS_PER_COMMIT,
"`side` is `one` then `three`"
);
}
/// Deepening a client that is **already shallow** must send the commits past
/// its own cut, even though it offered its tip as a `have`.
///
/// This is `git fetch --deepen=1` against a `--depth=1` clone, and it is the
/// one request where "the client has `four`" and "the client has `four`'s
/// ancestors" come apart. Walking that `have` without honouring the `shallow
/// four` line the client sent in the same request marks `merge` as
/// already-present, so the server promises `unshallow four` and then ships an
/// empty pack. Real git calls that state `remote did not send all necessary
/// objects` (`real_git_interop::v2_deepen_relative`); from inside, it is
/// visible as an object count.
#[test]
fn deepening_an_already_shallow_client_sends_the_commits_past_its_cut() {
let fixture = Fixture::build(Kind::Sha1);
let mut request = clone_request(&[fixture.four]);
// Exactly what git 2.53.0 puts on the wire for `fetch --deepen=1` from a
// depth-1 clone, captured 2026-08-03: the tip as both a want and a have,
// its own boundary declared, and a *relative* depth of one.
request.haves = vec![fixture.four];
request.shallow.client_shallow = vec![fixture.four];
request.shallow.depth = Some(1);
request.shallow.relative = true;
let (response, sent) = fetch(&fixture, &request);
assert!(
response.has_line(&format!("unshallow {}", fixture.four)),
"the client's old boundary was not lifted: {:?}",
response.lines()
);
assert!(
response.has_line(&format!("shallow {}", fixture.merge)),
"the new boundary was not reported: {:?}",
response.lines()
);
// `merge` and its tree and its blob, and nothing else: `four` is genuinely
// at the client, and `merge`'s own parents are past the new boundary. The
// count is the assertion, because a server that answers this request with
// an empty pack still writes both lines above.
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
OBJECTS_PER_COMMIT,
"the pack must carry `merge`; the client cannot reach it and was just \
told its own tip is no longer grafted"
);
assert_eq!(sent, OBJECTS_PER_COMMIT);
}
#[test]
fn deepen_since_cuts_on_the_committer_date() {
let fixture = Fixture::build(Kind::Sha1);
let mut request = clone_request(&[fixture.four]);
// `merge` is at this second; its parents are older.
request.shallow.since = Some(1_700_000_004);
let (response, _) = fetch(&fixture, &request);
assert!(
response.has_line(&format!("shallow {}", fixture.merge)),
"the date cut was not reported: {:?}",
response.lines()
);
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
2 * OBJECTS_PER_COMMIT,
"only `four` and `merge` are new enough"
);
}
#[test]
fn deepen_not_cuts_at_a_named_reference() {
let fixture = Fixture::build(Kind::Sha1);
let mut request = clone_request(&[fixture.four]);
request.shallow.not = vec!["refs/heads/side".to_owned()];
let (response, _) = fetch(&fixture, &request);
// `side` is `three`, whose ancestry is `one`, so the walk keeps `four`,
// `merge` and `two` and stops there.
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
3 * OBJECTS_PER_COMMIT
);
assert!(
response.has_line(&format!("shallow {}", fixture.two)),
"{:?}",
response.lines()
);
}
// ── negotiation ─────────────────────────────────────────────────────────────
/// The classic negotiation failure: a `have`/`want` loop that terminates on
/// the happy path and never on a divergent one.
///
/// The client here has forty commits the server has never seen, and sends them
/// in batches of thirty-two the way git does. The assertions are that the loop
/// **ends**, and that it ends by finding the real cut point rather than by
/// running out of patience.
#[test]
fn negotiation_terminates_on_a_divergent_history() {
let fixture = Fixture::build(Kind::Sha1);
let server_tip = fixture.extend(fixture.four, 40, "theirs");
fixture.force_ref("refs/heads/main", server_tip);
// The client's own divergent commits, which the server does not have.
let mine = Fixture::build(Kind::Sha1);
let mut client_haves: Vec<ObjectId> = Vec::new();
let mut head = mine.four;
for index in 0..40 {
head = mine.extend(head, 1, &format!("mine{index}"));
client_haves.push(head);
}
// ...and then, last, the commit both sides share.
client_haves.push(fixture.four);
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut offered = 0usize;
let mut rounds = 0usize;
let mut ready = false;
while offered < client_haves.len() && rounds < 20 {
rounds += 1;
offered = (offered + 32).min(client_haves.len());
let request = FetchRequest {
wants: vec![server_tip],
haves: client_haves[..offered].to_vec(),
done: false,
..Default::default()
};
let mut out = Vec::new();
let outcome = upload.fetch(&request, &mut out).expect("a fetch round");
let response = Response::parse(&out);
assert!(
response.has_line("acknowledgments"),
"every non-final round owes the client an acknowledgments \
section, or the client waits forever: {:?}",
response.lines()
);
if outcome.ready {
ready = true;
assert!(response.has_line("ready"));
assert!(outcome.sent_pack, "a ready round must carry the pack");
let sent = index_and_count(&response.band(1), Kind::Sha1);
assert_eq!(
sent,
40 * OBJECTS_PER_COMMIT,
"the cut point was found but the pack still carries history \
the client already has"
);
break;
}
assert!(
!outcome.sent_pack,
"a round with no cut point must not send a pack"
);
}
assert!(
ready,
"negotiation ran {rounds} rounds without finding the cut point that \
was in the client's very last batch"
);
assert!(rounds >= 2, "the divergence was not actually exercised");
}
#[test]
fn a_round_with_no_common_ground_answers_nak_rather_than_silence() {
let fixture = Fixture::build(Kind::Sha1);
let stranger = Fixture::build(Kind::Sha1).extend(Fixture::build(Kind::Sha1).one, 1, "alien");
let request = FetchRequest {
wants: vec![fixture.four],
haves: vec![stranger],
done: false,
..Default::default()
};
let (response, _) = fetch(&fixture, &request);
assert!(response.has_line("NAK"), "{:?}", response.lines());
assert!(!response.has_line("ready"));
assert!(!response.has_line("packfile"));
}
#[test]
fn wait_for_done_suppresses_the_early_cut_point() {
let fixture = Fixture::build(Kind::Sha1);
let request = FetchRequest {
wants: vec![fixture.four],
haves: vec![fixture.merge],
done: false,
wait_for_done: true,
..Default::default()
};
let (response, _) = fetch(&fixture, &request);
assert!(
response.has_line(&format!("ACK {}", fixture.merge)),
"the common commit was not acknowledged: {:?}",
response.lines()
);
assert!(
!response.has_line("ready"),
"wait-for-done was accepted and then ignored"
);
}
#[test]
fn a_done_request_skips_the_acknowledgments_section_entirely() {
let fixture = Fixture::build(Kind::Sha1);
let request = FetchRequest {
haves: vec![fixture.merge],
..clone_request(&[fixture.four])
};
let (response, _) = fetch(&fixture, &request);
// Measured against `git-upload-pack` 2.53 on 2026-08-03: a request
// carrying `done` gets `packfile` as its first line.
assert!(
!response.has_line("acknowledgments"),
"{:?}",
response.lines()
);
assert_eq!(
response.lines().first().map(String::as_str),
Some("packfile")
);
}
// ── want policy ─────────────────────────────────────────────────────────────
#[test]
fn an_unreachable_want_is_refused_and_a_reachable_one_is_not() {
let fixture = Fixture::build(Kind::Sha1);
// Detach a commit by pointing nothing at it.
let orphan = fixture.extend(fixture.four, 1, "orphan");
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut out = Vec::new();
let err = upload
.fetch(&clone_request(&[orphan]), &mut out)
.expect_err("an unreachable object must not be served");
assert!(
matches!(err, gunnar_wire::Error::WantNotAdvertised { .. }),
"unexpected error: {err}"
);
// A non-tip object that *is* reachable is fine. This is what a partial
// clone's lazy fetch asks for, and refusing it breaks every checkout in a
// `blob:none` clone.
let mut out = Vec::new();
upload
.fetch(&clone_request(&[fixture.one]), &mut out)
.expect("a reachable non-tip want must be served");
assert!(Response::parse(&out).has_line("packfile"));
}
/// **"There is no such object" and "there is, and you may not have it" are one
/// answer** (GUN-042).
///
/// `check_wants` used to run an existence check first and only then measure the
/// want against the advertisement, so the two cases came back as two different
/// errors. The difference is an oracle over exactly the thing the policy is
/// there to hide: `WantNotAdvertised`'s own doc says serving arbitrary ids
/// "leaks objects that are not reachable from any reference, which is how
/// 'deleted' branches stay fetchable" — and answering "that one IS here" one
/// `want` at a time leaks the same fact one bit at a time. An oid from a
/// force-pushed branch, a deleted branch or somebody else's paste can be tested
/// against a public repository for free.
///
/// Same merge `gunnar-ssh` makes between "no such repository" and "you have no
/// grant": one string on the wire.
///
/// The absent oid is a real sha1 of the right width that this repository
/// cannot hold — all-`f` — so the two requests differ in exactly the fact
/// under test and in nothing else.
#[test]
fn an_object_that_is_not_there_is_refused_exactly_like_one_that_is() {
let fixture = Fixture::build(Kind::Sha1);
let orphan = fixture.extend(fixture.four, 1, "orphan");
let absent = gix_hash::ObjectId::from_hex(b"ffffffffffffffffffffffffffffffffffffffff")
.expect("a legal sha1 that nothing hashes to");
let upload = UploadPack::new(&fixture.git, fixture.kind);
let present_but_hidden = upload
.fetch(&clone_request(&[orphan]), &mut Vec::new())
.expect_err("an unreachable object must not be served");
let not_there = upload
.fetch(&clone_request(&[absent]), &mut Vec::new())
.expect_err("an object that does not exist must not be served either");
// The variant, because that is what a caller matches on…
assert!(
matches!(
(&present_but_hidden, ¬_there),
(
gunnar_wire::Error::WantNotAdvertised { .. },
gunnar_wire::Error::WantNotAdvertised { .. }
)
),
"the two cases answer differently, which tells a caller which oids this repository holds:
hidden: {present_but_hidden}
absent: {not_there}"
);
// …and the words, because the variant is not what reaches the wire. Each
// names its own oid, so they are compared with the oid taken out.
let shape = |err: &gunnar_wire::Error, oid: &gix_hash::ObjectId| {
err.client_message().replace(&oid.to_string(), "<oid>")
};
assert_eq!(
shape(&present_but_hidden, &orphan),
shape(¬_there, &absent),
"the client-visible sentences differ, so the oracle is still open"
);
// The identity-value guard: a server that refused every want would satisfy
// all of the above. A reachable non-tip — what a partial clone's lazy fetch
// asks for — must still be served.
let mut out = Vec::new();
upload
.fetch(&clone_request(&[fixture.one]), &mut out)
.expect("a reachable non-tip want must still be served");
assert!(Response::parse(&out).has_line("packfile"));
}
#[test]
fn the_strict_policy_refuses_even_a_reachable_non_tip() {
let fixture = Fixture::build(Kind::Sha1);
let upload = UploadPack::new(&fixture.git, fixture.kind).with_config(Config {
want_policy: WantPolicy::AdvertisedTips,
..Default::default()
});
let mut out = Vec::new();
assert!(upload
.fetch(&clone_request(&[fixture.one]), &mut out)
.is_err());
let mut out = Vec::new();
assert!(upload
.fetch(&clone_request(&[fixture.four]), &mut out)
.is_ok());
}
// ── protocol v0 ─────────────────────────────────────────────────────────────
#[test]
fn the_v0_advertisement_carries_capabilities_after_a_nul_on_the_first_line() {
let fixture = Fixture::build(Kind::Sha1);
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut out = Vec::new();
upload.write_ref_advertisement(&mut out).expect("advertise");
let response = Response::parse(&out);
let Packet::Data(first) = &response.packets[0] else {
panic!("the first packet is not a line");
};
let nul = first.iter().position(|&b| b == 0).expect("a NUL separator");
let (head, caps) = first.split_at(nul);
let caps = String::from_utf8_lossy(&caps[1..]);
assert_eq!(
String::from_utf8_lossy(head),
format!("{} HEAD", fixture.four)
);
assert!(
caps.contains("symref=HEAD:refs/heads/main"),
"no symref capability: {caps}"
);
assert!(caps.contains("side-band-64k"), "{caps}");
assert!(caps.contains("object-format=sha1"), "{caps}");
let lines = response.lines();
assert!(
lines.contains(&format!("{} refs/tags/v1^{{}}", fixture.merge)),
"the peeled tag line is missing: {lines:?}"
);
assert_eq!(response.packets.last(), Some(&Packet::Flush));
}
#[test]
fn a_v0_clone_negotiates_and_ends_its_sideband_with_a_flush() {
let fixture = Fixture::build(Kind::Sha1);
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut request = Vec::new();
gunnar_pktline::write_text(
&mut request,
format!(
"want {} side-band-64k thin-pack ofs-delta agent=test/1",
fixture.four
)
.as_bytes(),
)
.unwrap();
gunnar_pktline::write_flush(&mut request).unwrap();
gunnar_pktline::write_text(&mut request, b"done").unwrap();
let mut input = std::io::Cursor::new(request);
let mut out = Vec::new();
let summary = upload.serve_v0(&mut input, &mut out).expect("serve v0");
assert_eq!(summary.protocol, ProtocolVersion::V0);
let response = Response::parse(&out);
assert!(response.has_line("NAK"), "{:?}", response.lines());
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
5 * OBJECTS_PER_COMMIT
);
// Without this terminator git reports "unexpected disconnect while
// reading sideband packet"; found by the real-git suite, 2026-08-03.
assert_eq!(
response.packets.last(),
Some(&Packet::Flush),
"a sidebanded v0 response must end with an unframed flush-pkt"
);
}
/// **GHUNT-1 (lane OX-GUNHUNT, 2026-10-28)**: on protocol v0 `deepen-relative` is a
/// CAPABILITY on the first `want` line, not a request line, so `git fetch --deepen=N`
/// over v0 (stock git `-c protocol.version=0`, or any v0-only client) asks for N MORE
/// commits past its cut. Read as a plain `deepen 1` the server counted from the tip:
/// the client's own boundary came back as the boundary and nothing was sent (stock git
/// 2.53: `--deepen 5` from a depth-1 clone left 5 commits, `file://` leaves 6).
#[test]
fn a_v0_deepen_relative_capability_counts_past_the_clients_cut() {
let fixture = Fixture::build(Kind::Sha1);
let upload = UploadPack::new(&fixture.git, fixture.kind);
// What stock git 2.53 sends for `fetch --deepen=1` from a depth-1 clone over v0.
let mut request = Vec::new();
gunnar_pktline::write_text(
&mut request,
format!(
"want {} side-band-64k thin-pack ofs-delta shallow deepen-relative agent=test/1",
fixture.four
)
.as_bytes(),
)
.unwrap();
gunnar_pktline::write_text(&mut request, format!("shallow {}", fixture.four).as_bytes())
.unwrap();
gunnar_pktline::write_text(&mut request, b"deepen 1").unwrap();
gunnar_pktline::write_flush(&mut request).unwrap();
gunnar_pktline::write_text(&mut request, format!("have {}", fixture.four).as_bytes()).unwrap();
gunnar_pktline::write_text(&mut request, b"done").unwrap();
let mut input = std::io::Cursor::new(request);
let mut out = Vec::new();
upload.serve_v0(&mut input, &mut out).expect("serve v0");
let response = Response::parse(&out);
assert!(
response.has_line(&format!("unshallow {}", fixture.four)),
"a relative deepen must lift the client's old boundary: {:?}",
response.lines()
);
assert!(
response.has_line(&format!("shallow {}", fixture.merge)),
"the new boundary is one commit past the cut: {:?}",
response.lines()
);
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
OBJECTS_PER_COMMIT,
"the pack must carry `merge`, the one commit past the client's cut"
);
}
/// The v0 negotiation of a fetch whose `have`s and `done` share one block (no flush
/// between): what stock git sends for any v0 fetch with fewer than 16 `have`s.
fn v0_haves_then_done(fixture: &Fixture, caps: &str, have: ObjectId) -> Response {
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut request = Vec::new();
gunnar_pktline::write_text(
&mut request,
format!("want {} {caps} agent=test/1", fixture.four).as_bytes(),
)
.unwrap();
gunnar_pktline::write_flush(&mut request).unwrap();
gunnar_pktline::write_text(&mut request, format!("have {have}").as_bytes()).unwrap();
gunnar_pktline::write_text(&mut request, b"done").unwrap();
let mut input = std::io::Cursor::new(request);
let mut out = Vec::new();
upload.serve_v0(&mut input, &mut out).expect("serve v0");
Response::parse(&out)
}
/// **GHUNT-2 (lane OX-GUNHUNT, 2026-10-28)**: a `have` the server holds, sent in the
/// block `done` closes, must be acknowledged: git's `upload-pack.c:get_common_commits`
/// counts every recognised `have` as it arrives (`have_obj.nr`), flush or no flush, and
/// at `done` answers `ACK <last_hex>` (multi-ack) or has already said `ACK <oid>` inline
/// (single-ACK). gunnar counted haves only at a flush, so it answered `NAK` and stock git
/// printed `warning: no common commits` on every small v0 fetch (seen on `--deepen`).
#[test]
fn a_v0_have_in_the_block_done_closes_is_acknowledged_in_both_ack_modes() {
let fixture = Fixture::build(Kind::Sha1);
for caps in [
"multi_ack_detailed side-band-64k ofs-delta",
"side-band-64k ofs-delta",
] {
let response = v0_haves_then_done(&fixture, caps, fixture.three);
assert!(
response.has_line(&format!("ACK {}", fixture.three)),
"[{caps}] the common `have` was not acknowledged: {:?}",
response.lines()
);
assert!(
!response.has_line("NAK"),
"[{caps}] a common `have` answered NAK: {:?}",
response.lines()
);
}
// And a `have` the server does not hold still gets its NAK.
let unknown = ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").unwrap();
let response = v0_haves_then_done(&fixture, "multi_ack_detailed side-band-64k", unknown);
assert!(response.has_line("NAK"), "{:?}", response.lines());
}
#[test]
fn a_v0_client_that_only_wants_the_advertisement_gets_a_clean_end() {
let fixture = Fixture::build(Kind::Sha1);
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut input = std::io::Cursor::new(Vec::new());
let mut out = Vec::new();
upload
.serve_v0(&mut input, &mut out)
.expect("`git ls-remote` hanging up is not an error");
assert!(Response::parse(&out).lines().len() >= 3);
}
// ── receive-pack ────────────────────────────────────────────────────────────
#[test]
fn a_push_moves_the_reference_and_reports_ok() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
let input = push_bytes(
&[(&zero, &fixture.merge.to_string(), "refs/heads/from-a-push")],
"report-status object-format=sha1 agent=test/1",
&empty_pack(Kind::Sha1),
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.expect("serve the push")
.expect("a push was made");
assert_eq!(outcome.statuses, vec![RefStatus::Ok]);
let response = Response::parse(&out);
assert!(response.has_line("unpack ok"), "{:?}", response.lines());
assert!(
response.has_line("ok refs/heads/from-a-push"),
"{:?}",
response.lines()
);
assert_eq!(
fixture.read_ref("refs/heads/from-a-push").unwrap(),
fixture.merge
);
}
#[test]
fn a_push_whose_stated_old_value_is_wrong_is_refused() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let input = push_bytes(
&[(
// `refs/heads/main` is at `four`, not at `one`.
&fixture.one.to_string(),
&fixture.merge.to_string(),
"refs/heads/main",
)],
"report-status object-format=sha1",
&empty_pack(Kind::Sha1),
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.unwrap()
.unwrap();
assert!(matches!(outcome.statuses[0], RefStatus::Rejected(_)));
assert!(
Response::parse(&out)
.lines()
.iter()
.any(|line| line.starts_with("ng refs/heads/main")),
"the client was not told: {:?}",
Response::parse(&out).lines()
);
assert_eq!(
fixture.read_ref("refs/heads/main").unwrap(),
fixture.four,
"the reference moved despite the refusal"
);
}
/// Connectivity, not indexing. An empty pack indexes perfectly; the tip it is
/// supposed to complete still is not there.
#[test]
fn a_tip_whose_objects_are_missing_is_refused() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
let absent = "1".repeat(40);
let input = push_bytes(
&[(&zero, &absent, "refs/heads/incomplete")],
"report-status object-format=sha1",
&empty_pack(Kind::Sha1),
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.unwrap()
.unwrap();
assert!(
outcome.unpack.is_ok(),
"the pack itself was fine; only its closure was not"
);
match &outcome.statuses[0] {
RefStatus::Rejected(reason) => assert!(
reason.contains("missing"),
"unhelpful rejection reason: {reason}"
),
RefStatus::Ok => panic!("a reference was pointed at an object that is not here"),
}
assert!(fixture.read_ref("refs/heads/incomplete").is_none());
}
/// One reference closes, the next does not, and the report must say **which**.
///
/// This is the guard on the one-walk fix. Connectivity is now checked with **one** walk
/// over every non-delete tip in the push, because running it per command cost
/// O(references x history) and is why a 2 872-reference kernel import never
/// finished. But a batched walk can only report that *something* does not
/// close, and the event stream reports per reference — so a failed batch falls back to
/// walking one reference at a time purely to attribute the failure.
///
/// Nothing else can see that fallback. Every single-reference test passes with
/// it removed, and so does every test where the whole push is good or the whole
/// push is bad; only a **mixed** push distinguishes "reject the incomplete
/// reference" from "reject the push". Without this, dropping the fallback would
/// turn one bad tag in a mirror import into a total refusal, silently.
///
/// Not `atomic`: under `atomic` one refusal is supposed to take the batch down
/// with it, which would mask exactly the difference being asserted.
#[test]
fn a_mixed_push_refuses_only_the_reference_that_does_not_close() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
let absent = "1".repeat(40);
let input = push_bytes(
&[
(&zero, &fixture.merge.to_string(), "refs/heads/good"),
(&zero, &absent, "refs/heads/incomplete"),
],
"report-status object-format=sha1",
&empty_pack(Kind::Sha1),
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.unwrap()
.unwrap();
assert_eq!(
outcome.statuses[0],
RefStatus::Ok,
"refs/heads/good closes on its own and was refused anyway, so the batched \
connectivity walk took a whole mirror push down over one bad tip"
);
match &outcome.statuses[1] {
RefStatus::Rejected(reason) => assert!(
reason.contains("missing"),
"unhelpful rejection reason: {reason}"
),
RefStatus::Ok => panic!("refs/heads/incomplete points at an object that is not here"),
}
// Applied output, not the report: the good reference really moved and the
// bad one really did not.
assert_eq!(
fixture.read_ref("refs/heads/good"),
Some(fixture.merge),
"refs/heads/good was reported ok and did not move"
);
assert!(fixture.read_ref("refs/heads/incomplete").is_none());
// And the client is told the same two things on the wire.
let lines = Response::parse(&out).lines();
assert!(
lines.iter().any(|line| line == "ok refs/heads/good"),
"the client was not told refs/heads/good landed: {lines:?}"
);
assert!(
lines
.iter()
.any(|line| line.starts_with("ng refs/heads/incomplete")),
"the client was not told refs/heads/incomplete was refused: {lines:?}"
);
}
#[test]
fn atomic_refuses_the_whole_batch_and_leaves_nothing_applied() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
let input = push_bytes(
&[
(&zero, &fixture.merge.to_string(), "refs/heads/good"),
// A create for a reference that already exists: it cannot land.
(&zero, &fixture.merge.to_string(), "refs/heads/side"),
],
"report-status atomic object-format=sha1",
&empty_pack(Kind::Sha1),
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.unwrap()
.unwrap();
assert!(outcome.atomic);
assert!(
outcome.statuses.iter().all(|status| !status.is_ok()),
"an atomic batch reported a partial success: {:?}",
outcome.statuses
);
assert!(
fixture.read_ref("refs/heads/good").is_none(),
"the good half of an atomic push landed; it is not atomic"
);
assert_eq!(fixture.read_ref("refs/heads/side").unwrap(), fixture.three);
}
/// Without `atomic` the same batch must apply what it can; otherwise `atomic`
/// is not a capability, it is the only behaviour.
#[test]
fn without_atomic_the_good_half_of_a_batch_lands() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
let input = push_bytes(
&[
(&zero, &fixture.merge.to_string(), "refs/heads/good"),
(&zero, &fixture.merge.to_string(), "refs/heads/side"),
],
"report-status object-format=sha1",
&empty_pack(Kind::Sha1),
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.unwrap()
.unwrap();
assert!(outcome.statuses[0].is_ok(), "{:?}", outcome.statuses);
assert!(!outcome.statuses[1].is_ok());
assert_eq!(fixture.read_ref("refs/heads/good").unwrap(), fixture.merge);
}
#[test]
fn a_delete_only_push_reads_no_packfile() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
// Deliberately no pack after the flush: a server that waits for one here
// blocks until the connection times out.
let input = push_bytes(
&[(&fixture.three.to_string(), &zero, "refs/heads/side")],
"report-status delete-refs object-format=sha1",
&[],
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&(|_: &mut dyn std::io::BufRead| panic!("no pack should have been read")),
&AllowAll,
)
.unwrap()
.unwrap();
assert_eq!(outcome.statuses, vec![RefStatus::Ok]);
assert!(outcome.pack.is_none());
assert!(fixture.read_ref("refs/heads/side").is_none());
}
#[test]
fn push_options_are_read_only_when_the_capability_was_negotiated() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
let mut input = push_bytes(
&[(&zero, &fixture.merge.to_string(), "refs/heads/opts")],
"report-status push-options object-format=sha1",
&[],
);
// The options section sits between the command list and the pack.
let mut options = Vec::new();
gunnar_pktline::write_text(&mut options, b"reviewer=nobody").unwrap();
gunnar_pktline::write_flush(&mut options).unwrap();
let flush_at = input.len();
input.splice(flush_at.., options);
input.extend_from_slice(&empty_pack(Kind::Sha1));
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.unwrap()
.unwrap();
assert_eq!(outcome.push_options, vec!["reviewer=nobody".to_owned()]);
}
/// **The push options reach the GATE**, not only the outcome.
///
/// Reaching the outcome is what they already did, and it is what made the
/// defect invisible: `outcome.push_options` was populated, the assertion above
/// was green, and the only consumer in the whole tree was `.len()` in a log
/// line. `PushFacts` had no options field at all, so the thing that decides
/// whether a push lands could not see them. A client that sent `-o` was told on
/// the wire that the server speaks push options and the option went nowhere.
///
/// So this asserts the *applied* fact: a gate refuses on the strength of an
/// option it read out of `PushFacts`, and the reference does not move.
#[test]
fn a_gate_refuses_on_a_push_option_it_read_out_of_the_facts() {
/// Refuses unless the client sent `ci.ok`, and names what it saw.
struct DemandsAnOption;
impl PushGate for DemandsAnOption {
fn check(
&self,
_command: &RefCommand,
facts: &gunnar_wire::PushFacts<'_>,
) -> Result<(), String> {
if facts.push_options.iter().any(|o| o == "ci.ok") {
Ok(())
} else {
Err(format!("the gate saw {:?}", facts.push_options))
}
}
}
/// One push, with `options` in the push-options section. Returns the
/// outcome and whether the reference exists afterwards.
fn push_with(
refname: &str,
options: &[&str],
) -> (gunnar_wire::receive_pack::PushOutcome, bool) {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
let mut input = push_bytes(
&[(&zero, &fixture.merge.to_string(), refname)],
"report-status push-options object-format=sha1",
&[],
);
let mut section = Vec::new();
for option in options {
gunnar_pktline::write_text(&mut section, option.as_bytes()).unwrap();
}
gunnar_pktline::write_flush(&mut section).unwrap();
let at = input.len();
input.splice(at.., section);
input.extend_from_slice(&empty_pack(Kind::Sha1));
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&DemandsAnOption,
)
.unwrap()
.unwrap();
let exists = fixture.read_ref(refname).is_some();
(outcome, exists)
}
// ── red: the option is missing, and the REFERENCE DOES NOT MOVE ────────
let (refused, moved) = push_with("refs/heads/needs-opt", &["ci.nope"]);
match &refused.statuses[0] {
RefStatus::Rejected(reason) => assert!(
reason.contains("ci.nope"),
"the gate did not see the option the client sent: {reason}"
),
other => panic!("the gate did not refuse: {other:?}"),
}
assert!(
!moved,
"the reference was applied despite the gate refusing on a push option"
);
// ── the same push with no options at all, also refused ─────────────────
let (bare, moved) = push_with("refs/heads/needs-opt", &[]);
assert!(matches!(bare.statuses[0], RefStatus::Rejected(_)));
assert!(!moved);
// ── green, and the reference DOES move ─────────────────────────────────
let (allowed, moved) = push_with("refs/heads/has-opt", &["ci.ok"]);
assert_eq!(
allowed.statuses,
vec![RefStatus::Ok],
"a gate that refuses everything would satisfy both reds above"
);
assert!(moved, "the allowed push did not apply its reference");
}
/// **Measured, applied to the gate seam.** A gate that always allows is
/// indistinguishable from a working gate in any test that only pushes valid
/// commits, so the seam is proven by a gate that refuses.
#[test]
fn a_gate_that_refuses_is_obeyed() {
struct RefuseEverything;
impl PushGate for RefuseEverything {
fn check(
&self,
command: &RefCommand,
_facts: &gunnar_wire::PushFacts<'_>,
) -> Result<(), String> {
Err(format!("{} is not allowed here", command.name))
}
}
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
let input = push_bytes(
&[(&zero, &fixture.merge.to_string(), "refs/heads/gated")],
"report-status object-format=sha1",
&empty_pack(Kind::Sha1),
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&RefuseEverything,
)
.unwrap()
.unwrap();
assert!(matches!(outcome.statuses[0], RefStatus::Rejected(_)));
assert!(fixture.read_ref("refs/heads/gated").is_none());
// ...and the very same push lands once the gate allows it, so the failure
// above is the gate and not the fixture.
let input = push_bytes(
&[(&zero, &fixture.merge.to_string(), "refs/heads/gated")],
"report-status object-format=sha1",
&empty_pack(Kind::Sha1),
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.unwrap()
.unwrap();
assert_eq!(outcome.statuses, vec![RefStatus::Ok]);
}
/// The report rides band 1 and the connection is ended by an **unframed**
/// flush, the shape `git-receive-pack` 2.53 puts on the wire (captured
/// 2026-08-03).
#[test]
fn the_sidebanded_report_is_wrapped_and_then_the_stream_is_flushed() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
let input = push_bytes(
&[(&zero, &fixture.merge.to_string(), "refs/heads/banded")],
"report-status side-band-64k object-format=sha1",
&empty_pack(Kind::Sha1),
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.unwrap();
let response = Response::parse(&out);
let inner = Response::parse(&response.band(1));
assert!(inner.has_line("unpack ok"), "{:?}", inner.lines());
assert!(
inner.has_line("ok refs/heads/banded"),
"{:?}",
inner.lines()
);
assert_eq!(
inner.packets.last(),
Some(&Packet::Flush),
"the report inside the band is not flush-terminated"
);
assert_eq!(
response.packets.last(),
Some(&Packet::Flush),
"the outer stream is not flush-terminated"
);
}
#[test]
fn a_client_that_only_wanted_the_advertisement_is_not_a_push() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let mut input = std::io::Cursor::new(Vec::new());
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&(|_: &mut dyn std::io::BufRead| panic!("no pack")),
&AllowAll,
)
.expect("hanging up after the advertisement is normal");
assert!(outcome.is_none());
assert!(!Response::parse(&out).lines().is_empty());
}
#[test]
fn an_empty_repository_still_advertises_its_hash_kind() {
let store = MemGitServe::new(Kind::Sha256);
let receive = ReceivePack::new(&store, Kind::Sha256);
let mut out = Vec::new();
receive.write_ref_advertisement(&mut out).unwrap();
let response = Response::parse(&out);
let Packet::Data(first) = &response.packets[0] else {
panic!("no advertisement line");
};
let text = String::from_utf8_lossy(first);
assert!(text.starts_with(&Kind::Sha256.null().to_string()), "{text}");
assert!(text.contains("capabilities^{}"), "{text}");
assert!(text.contains("object-format=sha256"), "{text}");
let upload = UploadPack::new(&store, Kind::Sha256);
let mut out = Vec::new();
upload.write_ref_advertisement(&mut out).unwrap();
let response = Response::parse(&out);
let Packet::Data(first) = &response.packets[0] else {
panic!("no advertisement line");
};
let text = String::from_utf8_lossy(first);
assert!(text.contains("capabilities^{}"), "{text}");
assert!(text.contains("object-format=sha256"), "{text}");
// `FullName` died with `RefStore`; the *rule* did not, and `common::name`
// routes through `gunnar_store::refname::validate`, which is the single
// authority now. Keeping the line keeps the check that this suite's
// reference names are legal ones.
let _: String = name("refs/heads/main");
}
/// **`ACK` names the head of a chain of `have`s and nothing behind it.**
///
/// A client walking its own history back from a tip sends `tip`, `tip^`,
/// `tip^^`, … Each of those tells the server about the whole ancestry behind
/// it, so every `have` after the first is something the server was just told.
/// git `ACK`s only the news (`upload-pack.c`'s `we_knew_they_have`); gunnar
/// `ACK`ed all of it.
///
/// MEASURED 2026-08-04 across the bench's 500-commit divergence: stock git
/// **1** `ACK`, gunnar **508**, gunnar's being a parent chain headed by git's
/// one — 25 400 bytes, a third of gunnar's whole wire-byte deficit on that
/// fetch.
///
/// **Seen red**: against the code this replaced the first assertion reports 40
/// `ACK`s for 40 `have`s. The second and third assertions are the ones that
/// make it a guard rather than a byte count — they say the smaller
/// acknowledgment did not change *what the client is sent*, which is the only
/// way this could have been a bug rather than a saving.
#[test]
fn ack_names_the_head_of_a_have_chain_and_nothing_behind_it() {
let fixture = Fixture::build(Kind::Sha1);
// A shared history the client holds every commit of...
let mut chain: Vec<ObjectId> = Vec::new();
let mut head = fixture.four;
for index in 0..40 {
head = fixture.extend(head, 1, &format!("shared{index}"));
chain.push(head);
}
// ...and three commits past it that only the server has.
let server_tip = fixture.extend(head, 3, "new");
fixture.force_ref("refs/heads/main", server_tip);
// Newest first, which is the order a client walking its own history sends.
let haves: Vec<ObjectId> = chain.iter().rev().copied().collect();
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut out = Vec::new();
let outcome = upload
.fetch(
&FetchRequest {
wants: vec![server_tip],
haves: haves.clone(),
done: false,
..Default::default()
},
&mut out,
)
.expect("a fetch round");
let response = Response::parse(&out);
let acks: Vec<String> = response
.lines()
.into_iter()
.filter(|l| l.starts_with("ACK "))
.collect();
assert_eq!(
acks,
vec![format!("ACK {}", chain[chain.len() - 1])],
"40 `have`s forming one parent chain are one piece of news, and only \
the head of the chain is it; git sends exactly this one line"
);
// The saving must be bytes and nothing else. `common` is untouched, so the
// cut point and the pack are what they were.
assert_eq!(
outcome.common.len(),
chain.len(),
"the pack is computed from every common `have`, not from the `ACK`ed \
ones; shrinking the wire list must not shrink this"
);
assert!(outcome.ready && outcome.sent_pack, "{:?}", response.lines());
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
3 * OBJECTS_PER_COMMIT,
"the client is sent the three commits it lacks and nothing else"
);
// A second round repeating the same `have`s is no news at all, and git
// answers that with `NAK` rather than with a silent section.
let mut again = Vec::new();
upload
.fetch(
&FetchRequest {
wants: vec![server_tip],
haves,
done: false,
..Default::default()
},
&mut again,
)
.expect("a second round");
let repeat = Response::parse(&again);
assert!(
repeat.has_line("NAK"),
"a round that repeats known `have`s owes the client a NAK, not an \
empty acknowledgments section: {:?}",
repeat.lines()
);
assert!(
!repeat.lines().iter().any(|l| l.starts_with("ACK ")),
"nothing in the second round was news: {:?}",
repeat.lines()
);
}
// ── the v0 acknowledgment count (2026-08-05) ────────────────────────────────
//
// git's client stops reading acknowledgments the moment it sees a plain `ACK`
// (`fetch-pack.c:find_common` zeroes `flushes` and clears `multi_ack`), so an
// extra line after it is not merely redundant — it stays in the pipe until the
// reader switches to sideband and is then demultiplexed as a band number.
// gunnar wrote two: one at the flush of the block that found the common commit,
// one more after `done`. Against stock git 2.53.0 over real SSH, EVERY v0
// incremental fetch that shared history with the server died on it:
//
// ```text
// fetch-pack: protocol error: bad band #65 (a second `ACK`)
// fetch-pack: protocol error: bad band #78 (a trailing `NAK`)
// fatal: protocol error: bad pack header
// ```
//
// MEASURED 2026-08-05, the production-shaped corpus over `gunnar serve`,
// 4 repositories x 4 cut depths: **13 of 13 v0 arms failed and 13 of 13 v2 arms
// passed**, in sha1 and in sha256 alike. A full clone was clean throughout,
// because a clone sends no `have` and so is never ACKed — which is exactly the
// shape the interop suite's v0 coverage had.
//
// These two tests are on the LINE COUNT rather than on "a pack came out",
// because a pack does come out either way; what the client cannot survive is
// how many lines precede it.
/// Build a v0 request: one `want` with capabilities, then `blocks` of `have`
/// lines each ended by a flush, then `done`.
fn v0_negotiation(want: &ObjectId, blocks: &[&[ObjectId]]) -> Vec<u8> {
let mut request = Vec::new();
gunnar_pktline::write_text(
&mut request,
format!("want {want} side-band-64k thin-pack ofs-delta agent=test/1").as_bytes(),
)
.unwrap();
gunnar_pktline::write_flush(&mut request).unwrap();
for block in blocks {
for id in *block {
gunnar_pktline::write_text(&mut request, format!("have {id}").as_bytes()).unwrap();
}
gunnar_pktline::write_flush(&mut request).unwrap();
}
gunnar_pktline::write_text(&mut request, b"done").unwrap();
request
}
/// Every acknowledgment line of a v0 response, in order.
///
/// The advertisement and the sideband are filtered out and nothing else is:
/// what this exists to count is `ACK`/`NAK`, and a **surplus** one of either is
/// the defect, so narrowing to those two prefixes cannot hide it.
fn v0_acknowledgments(response: &Response) -> Vec<String> {
response
.packets
.iter()
.filter_map(|packet| match packet {
Packet::Data(payload) => {
// Band 1/2/3 payloads are the pack and its progress.
if matches!(payload.first(), Some(1..=3)) {
return None;
}
let text = String::from_utf8_lossy(
payload.strip_suffix(b"\n").unwrap_or(payload.as_slice()),
)
.into_owned();
(text == "NAK" || text.starts_with("ACK ")).then_some(text)
}
_ => None,
})
.collect()
}
#[test]
fn a_v0_negotiation_that_finds_a_common_commit_acknowledges_it_once() {
let fixture = Fixture::build(Kind::Sha1);
let upload = UploadPack::new(&fixture.git, fixture.kind);
// ONE block, and it holds a commit the server has.
let request = v0_negotiation(&fixture.four, &[&[fixture.two]]);
let mut input = std::io::Cursor::new(request);
let mut out = Vec::new();
upload.serve_v0(&mut input, &mut out).expect("serve v0");
let response = Response::parse(&out);
let lines = v0_acknowledgments(&response);
let acks = lines.iter().filter(|l| l.starts_with("ACK ")).count();
assert_eq!(
acks, 1,
"single-ACK v0 owes the client exactly one `ACK`; a second one is read \
as a sideband band number: {lines:?}"
);
assert_eq!(
lines.iter().filter(|l| l.as_str() == "NAK").count(),
0,
"nothing may follow the `ACK`: {lines:?}"
);
}
#[test]
fn a_v0_negotiation_says_nothing_more_after_the_block_that_was_acknowledged() {
let fixture = Fixture::build(Kind::Sha1);
let upload = UploadPack::new(&fixture.git, fixture.kind);
// THREE blocks. The first holds nothing the server has, the second holds
// the common commit, the third is the one a real client has already
// pipelined by the time the ACK reaches it — and it is the one that used to
// draw a trailing `NAK`. `fx/knut` at 40 commits back is this shape;
// `fx/funnel-core` at five is the single-block one above, which is why the
// small fixture never showed the defect.
let stranger = ObjectId::from_hex(b"1111111111111111111111111111111111111111").unwrap();
let request = v0_negotiation(
&fixture.four,
&[&[stranger], &[fixture.two], &[fixture.three]],
);
let mut input = std::io::Cursor::new(request);
let mut out = Vec::new();
upload.serve_v0(&mut input, &mut out).expect("serve v0");
let response = Response::parse(&out);
let lines = v0_acknowledgments(&response);
// NAK for the first block, ACK for the second, and then silence.
assert_eq!(
lines,
vec!["NAK".to_owned(), format!("ACK {}", fixture.two)],
"after the acknowledgment the server owes the client nothing but the \
pack; git reads the next byte as a band number"
);
// And the pack is still there, so this is not a test that passed by the
// server having stopped talking altogether.
assert_eq!(
index_and_count(&response.band(1), Kind::Sha1),
2 * OBJECTS_PER_COMMIT,
"the pack must still carry what the negotiation did not cover"
);
}
// ── the `blob:limit=` boundary (2026-08-05) ─────────────────────────────────
/// A commit whose single blob is exactly `bytes` long.
///
/// The content is generated rather than written out because the assertion is
/// about a size, and a literal that drifts by one character would move the
/// boundary this test exists to sit on.
fn commit_with_blob_of(
git: &dyn GitServe,
file: &str,
bytes: usize,
parents: &[ObjectId],
at: i64,
) -> ObjectId {
let content = "x".repeat(bytes).into_bytes();
let blob = gix_object::compute_hash(Kind::Sha1, gix_object::Kind::Blob, &content)
.expect("hash the blob");
let mut tree = Vec::new();
tree.extend_from_slice(b"100644 ");
tree.extend_from_slice(file.as_bytes());
tree.push(0);
tree.extend_from_slice(blob.as_bytes());
let tree_id =
gix_object::compute_hash(Kind::Sha1, gix_object::Kind::Tree, &tree).expect("hash the tree");
let mut text = format!("tree {}\n", tree_id.to_hex());
for parent in parents {
text.push_str(&format!("parent {}\n", parent.to_hex()));
}
text.push_str(&format!(
"author Gunnar Test <gunnar@example.invalid> {at} +0000\n\
committer Gunnar Test <gunnar@example.invalid> {at} +0000\n\nsized\n"
));
let ids = common::write_pack_of(
git,
Kind::Sha1,
&[
(gix_object::Kind::Blob, content),
(gix_object::Kind::Tree, tree),
(gix_object::Kind::Commit, text.into_bytes()),
],
);
// The size this whole test is about, read back through the contract rather
// than trusted from the writer.
assert_eq!(
git.header(blob.as_bytes())
.expect("header")
.expect("the blob exists"),
(ObjType::Blob, bytes as u64),
"the fixture blob is not the size this test is about"
);
ids[2]
}
/// `blob:limit=n` omits a blob of exactly `n` bytes, because git does.
///
/// git's `list-objects-filter.c` keeps a blob when `object_length <
/// filter_data->max_bytes`; gunnar's `graph.rs` had `<=`, so the two servers
/// disagreed about the blob sitting exactly on the limit and a partial-clone
/// client sizes its lazy-fetch expectations from git's rule.
///
/// MEASURED 2026-08-05 — one repository holding blobs of 1023, 1024 and
/// 1025 bytes, `git clone --filter=blob:limit=1024`, stock git 2.53.0 as the
/// client on both arms:
///
/// | server | blobs delivered |
/// |---|---|
/// | stock `git-upload-pack` | 1023 |
/// | gunnar, before | 1023, **1024** |
/// | gunnar, after | 1023 |
///
/// Both neighbours are asserted, not just the boundary: a filter that had
/// simply stopped working would keep the 1023 and drop the 1024 too, and pass a
/// test that only looked at the one in the middle.
#[test]
fn a_blob_limit_omits_the_blob_that_is_exactly_the_limit() {
let store = MemGitServe::new(Kind::Sha1);
let under = commit_with_blob_of(&store, "under.txt", 1023, &[], 1_700_000_001);
let exact = commit_with_blob_of(&store, "exact.txt", 1024, &[under], 1_700_000_002);
let over = commit_with_blob_of(&store, "over.txt", 1025, &[exact], 1_700_000_003);
store
.put_refs(&[gunnar_store::git_storage_trait::RefUpdate::set(
"refs/heads/main",
over.to_string(),
)])
.expect("install the reference");
let upload = UploadPack::new(&store, Kind::Sha1);
let mut out = Vec::new();
upload
.fetch(
&FetchRequest {
wants: vec![over],
done: true,
thin_pack: false,
ofs_delta: true,
filter: Some(gunnar_wire::Filter::BlobLimit(1024)),
..Default::default()
},
&mut out,
)
.expect("a filtered fetch");
// What actually arrived, read back out of the pack with gix's own indexer
// rather than from anything gunnar said about it.
let response = Response::parse(&out);
let pack = response.band(1);
let delivered = blob_sizes_in_pack(&pack, Kind::Sha1);
assert!(
delivered.contains(&1023),
"the blob UNDER the limit must be delivered; got {delivered:?}"
);
assert!(
!delivered.contains(&1024),
"git omits a blob of exactly the limit and so must gunnar; got {delivered:?}"
);
assert!(
!delivered.contains(&1025),
"the blob OVER the limit must be omitted; got {delivered:?}"
);
}
/// Every blob size in `pack`, via gix's indexer and object database.
fn blob_sizes_in_pack(pack: &[u8], kind: Kind) -> Vec<u64> {
let dir = tempfile::tempdir().expect("tempdir");
let out = std::process::Command::new("git")
.args(["init", "-q", "--bare"])
.arg(format!(
"--object-format={}",
match kind {
Kind::Sha256 => "sha256",
_ => "sha1",
}
))
.arg(dir.path())
.output()
.expect("git init");
assert!(out.status.success(), "git init: {out:?}");
let mut child = std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
// No `--strict`: a filtered pack is INCOMPLETE by construction — that
// is what a filter is — so a connectivity check would refuse every arm
// of this test including the correct one. What is being judged here is
// which blobs arrived, and git's own indexer is what reads them out.
// The strict, both-receivers judgement of whole packs is the corpus
// sweep's job, not this one's.
.args(["index-pack", "--stdin"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("git index-pack");
use std::io::Write as _;
child
.stdin
.as_mut()
.expect("stdin")
.write_all(pack)
.expect("write the pack");
let done = child.wait_with_output().expect("index-pack");
assert!(
done.status.success(),
"stock git refused the pack: {}",
String::from_utf8_lossy(&done.stderr)
);
let listed = std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args([
"cat-file",
"--batch-all-objects",
"--batch-check=%(objecttype) %(objectsize)",
])
.output()
.expect("cat-file");
String::from_utf8_lossy(&listed.stdout)
.lines()
.filter_map(|line| line.strip_prefix("blob "))
.filter_map(|size| size.trim().parse::<u64>().ok())
.collect()
}
// ── the refusal reason reaches the CLIENT (2026-08-05) ──────────────────────
//
// `UploadPack::report_error` writes an `ERR` pkt-line or a band-3 message, and
// it had **zero** call sites in the whole tree, tests included. So every
// refusal this crate makes reached the transport as an `Err`, was logged, and
// the channel closed — `git` printed
//
// ```text
// fatal: the remote end hung up unexpectedly
// ```
//
// while the server log carried the reason in full. That is the 03:00 failure
// mode: the one person who can see the answer is the one who is not awake.
//
// MEASURED 2026-08-05, stock git 2.53.0 over real SSH, protocol v2 (git's
// default since 2.26):
//
// | request | client saw, before | client sees, after |
// |---|---|---|
// | `fetch <unknown oid>` | `the remote end hung up unexpectedly` | `remote error: object 0000…0001 is not in this repository (a want)` |
// | `clone --filter=sparse:oid=…` | `the remote end hung up unexpectedly` | `remote error: filter "sparse:oid=HEAD:x" is not supported: gunnar implements blob:none, blob:limit=<n> and tree:<depth>` |
//
// The assertion is on the BYTES the server wrote, not on the `Err` it returned:
// the `Err` was always there, and it is exactly what nobody could see.
/// Serve one v2 command and return whatever bytes came back, error or not.
fn v2_served_bytes(fixture: &Fixture, request: &[u8]) -> (Vec<u8>, Option<String>) {
let upload = UploadPack::new(&fixture.git, fixture.kind);
let mut input = std::io::Cursor::new(request.to_vec());
let mut out = Vec::new();
let outcome = upload.serve_session(
ProtocolVersion::V2,
gunnar_wire::Session::Connected,
&mut input,
&mut out,
);
(out, outcome.err().map(|err| err.to_string()))
}
#[test]
fn a_refused_want_tells_the_client_which_object_and_why() {
let fixture = Fixture::build(Kind::Sha1);
let stranger = ObjectId::from_hex(b"0000000000000000000000000000000000000001").unwrap();
let request = v2_request(
"fetch",
&["object-format=sha1"],
&[&format!("want {stranger}"), "done"],
);
let (bytes, err) = v2_served_bytes(&fixture, &request);
assert!(err.is_some(), "the fetch must still fail");
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("ERR "),
"a refusal owes the client an `ERR` line, or git prints only \"the \
remote end hung up unexpectedly\": {text:?}"
);
assert!(
text.contains(&stranger.to_string()),
"the `ERR` line must name the object that was refused: {text:?}"
);
}
#[test]
fn a_refused_filter_tells_the_client_what_is_supported() {
let fixture = Fixture::build(Kind::Sha1);
let request = v2_request(
"fetch",
&["object-format=sha1"],
&[
&format!("want {}", fixture.four),
"filter sparse:oid=deadbeef",
"done",
],
);
let (bytes, err) = v2_served_bytes(&fixture, &request);
assert!(err.is_some(), "an unsupported filter must still fail");
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("ERR ") && text.contains("sparse:oid"),
"the client must be told which filter was refused: {text:?}"
);
assert!(
text.contains("blob:none"),
"and what it could have asked for instead: {text:?}"
);
}
#[test]
fn a_client_message_never_carries_a_server_path() {
// The three `#[error(transparent)]` variants were passed through verbatim,
// and `gunnar_store::Error::StoreLocked` renders the absolute path of the
// lock file. `receive_pack` puts `client_message()` straight into the
// `unpack` line of a push report, so `git push` printed it to whoever
// pushed.
let leaky = gunnar_store::Error::StoreLocked {
path: std::path::PathBuf::from("/srv/gunnar/data/repos/acme/secret.git/gunnar-store.lock"),
};
let rendered = leaky.to_string();
assert!(
rendered.contains("/srv/gunnar/data/repos/acme/secret.git"),
"the fixture must actually carry a path, or this test proves nothing: \
{rendered}"
);
let wrapped: gunnar_wire::Error = leaky.into();
let message = wrapped.client_message();
assert!(
!message.contains('/'),
"a client message must not carry a server path: {message:?}"
);
assert!(
!message.is_empty(),
"and it must still say something, or the client learns nothing"
);
// The other direction, so this cannot pass by redacting everything: a
// protocol refusal is the client's own words and stays verbatim, because
// that is what makes it actionable without a server login.
let named = gunnar_wire::Error::UnsupportedFilter {
spec: "sparse:oid=deadbeef".to_owned(),
reason: "gunnar implements blob:none, blob:limit=<n> and tree:<depth>",
};
assert!(
named.client_message().contains("sparse:oid=deadbeef"),
"a refusal that names the client's own request must not be redacted"
);
}
// ── a shallow client's grafts are not the capability line (2026-08-05) ──────
/// A push command list preceded by `shallow <oid>` graft announcements, which
/// is exactly what stock `git push` sends from a `--depth` clone.
fn shallow_push_bytes(
grafts: &[ObjectId],
commands: &[(&str, &str, &str)],
capabilities: &str,
pack: &[u8],
) -> Vec<u8> {
let mut out = Vec::new();
for graft in grafts {
gunnar_pktline::write_text(&mut out, format!("shallow {graft}").as_bytes()).unwrap();
}
for (index, (old, new, name)) in commands.iter().enumerate() {
let mut line = format!("{old} {new} {name}").into_bytes();
if index == 0 {
line.push(0);
line.extend_from_slice(capabilities.as_bytes());
}
gunnar_pktline::write_text(&mut out, &line).unwrap();
}
gunnar_pktline::write_flush(&mut out).unwrap();
out.extend_from_slice(pack);
out
}
/// Every push from a shallow clone was refused, because the capability section
/// was taken off the wrong line.
///
/// `first` meant "the first packet", and a shallow client spends that on a
/// `shallow <oid>` graft. That line has no NUL, so nothing was parsed off it,
/// `first` went false, and the real first command reached `parse_command` with
/// its whole capability section still glued to the reference name:
///
/// ```text
/// invalid reference name "refs/heads/x\0 report-status-v2 side-band-64k quiet
/// object-format=sha1 agent=git/2.53.0-Linux": control character or space
/// ```
///
/// ISOLATED 2026-08-05, stock git 2.53.0 over real SSH against a live
/// `gunnar serve`, one client, one commit, one server:
///
/// | client | push |
/// |-------------------------------------------|-------------|
/// | `clone --depth 3` | **refused** |
/// | the same clone after `fetch --unshallow` | accepted |
///
/// and the client saw only `the remote end hung up unexpectedly`.
/// `actions/checkout` clones at `fetch-depth: 1` by default, so a CI job that
/// pushes back is the ordinary shape of this, not an exotic one.
///
/// Both halves are asserted, because the ref name failing is the loud half and
/// the capabilities being lost is the quiet one: with the name accepted anyway,
/// a shallow pusher would still have had `report-status`, `side-band-64k`,
/// `atomic`, `push-options` and the `object-format` check silently off.
#[test]
fn a_push_from_a_shallow_client_is_not_derailed_by_its_grafts() {
let fixture = Fixture::build(Kind::Sha1);
let receive = ReceivePack::new(&fixture.git, fixture.kind);
let zero = Kind::Sha1.null().to_string();
let input = shallow_push_bytes(
&[fixture.one, fixture.two],
&[(&zero, &fixture.merge.to_string(), "refs/heads/from-shallow")],
"report-status side-band-64k object-format=sha1 agent=git/2.53.0",
&empty_pack(Kind::Sha1),
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
let outcome = receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.expect("a shallow client's push is still a push")
.expect("it carried a command");
assert_eq!(
outcome.statuses,
vec![RefStatus::Ok],
"the graft lines are not commands and must not reach the reference name"
);
assert_eq!(
fixture.read_ref("refs/heads/from-shallow"),
Some(fixture.merge),
"and the reference has to have actually moved"
);
// The quiet half. `side-band-64k` was asked for, so the report must be
// sidebanded — which is only true if the capability line was read at all.
let response = Response::parse(&out);
assert!(
response
.packets
.iter()
.any(|packet| matches!(packet, Packet::Data(payload) if payload.first() == Some(&1))),
"the capabilities rode the first COMMAND, not the first packet; without \
them a shallow pusher silently loses report-status, side-band-64k, \
atomic, push-options and the object-format check: {:?}",
response.lines()
);
}
/// **The connectivity budget refuses the push, and the client is told.**
///
/// `receive_pack::Config::budget` bounds the walk that proves a push closes.
/// Nothing exercised it: `caps::SERVER_POLICIES` classifies it as
/// `Declared::Refuses` — not advertised, because the peer finds out from an
/// error — and the only test that citation could name was
/// `a_mixed_push_refuses_only_the_reference_that_does_not_close`, which proves
/// the *reporting mechanism* and never sets a budget. A citation to an adjacent
/// test is the claim without the evidence, which is the thing that census
/// exists to stop, so here is the evidence.
///
/// The applied output is the **status report the client reads** and the state
/// of the reference afterwards, not a returned error: a budget that refused
/// internally and still moved the ref, or that reported `ok` and did not, would
/// both satisfy an exit-code assertion.
///
/// Seen red: with `.with_config(…)` dropped so the default
/// `graph::CONNECTIVITY_BUDGET` applies, this fails with *"a budget of 1 served
/// a push over 6 new commits"* — which is also what says the budget, and not
/// some unrelated refusal, is what fired.
#[test]
fn the_connectivity_budget_refuses_a_push_rather_than_accepting_it_unchecked() {
let fixture = Fixture::build(Kind::Sha1);
// Six commits that no reference reaches, so the walk really has to cross
// them before it meets the frontier at `four`.
let tip = fixture.extend(fixture.four, 6, "budget");
let zero = Kind::Sha1.null().to_string();
let push = |budget: Option<usize>| {
let receive = ReceivePack::new(&fixture.git, fixture.kind).with_config(
gunnar_wire::receive_pack::Config {
budget,
..gunnar_wire::receive_pack::Config::default()
},
);
let input = push_bytes(
&[(&zero, &tip.to_string(), "refs/heads/budgeted")],
"report-status object-format=sha1",
&empty_pack(Kind::Sha1),
);
let mut input = std::io::Cursor::new(input);
let mut out = Vec::new();
receive
.serve(
&mut input,
&mut out,
&validating_ingest(Kind::Sha1),
&AllowAll,
)
.expect("the session itself completes")
.expect("a push outcome")
};
// ── the control, FIRST: this push is servable ──────────────────────────
//
// Without it, "budget 1 refuses" would pass against a fixture that could
// not be pushed at all.
let served = push(gunnar_wire::receive_pack::Config::default().budget);
assert_eq!(
served.statuses[0],
RefStatus::Ok,
"the same push under the default budget was refused, so the arm below is not about the \
budget"
);
// `read_ref` answers only for a DIRECT reference, so a symbolic one here
// would come back `None` and fail this — which is the same refusal the
// `Target::Symbolic(_) => panic!` arm used to spell out, one layer down.
assert_eq!(
fixture.read_ref("refs/heads/budgeted"),
Some(tip),
"the control reported ok and the reference did not move"
);
// Put it back, so the second arm starts where the first did.
fixture
.git
.update_ref("refs/heads/budgeted", Some(tip.as_bytes()), None)
.expect("remove the control's reference");
// ── a budget of 1 cannot prove this push closes ────────────────────────
let refused = push(Some(1));
match &refused.statuses[0] {
RefStatus::Rejected(reason) => assert!(
!reason.is_empty(),
"the reference was rejected with an EMPTY reason, so `git push` prints a refusal \
that says nothing"
),
RefStatus::Ok => panic!(
"a budget of 1 served a push over 6 new commits: the connectivity walk either never \
ran or ran unbounded, and either way the ceiling is decoration"
),
}
assert!(
fixture.read_ref("refs/heads/budgeted").is_none(),
"the push was reported as rejected and the reference moved anyway, which is the worst of \
both answers"
);
}