crates/storage-git-gix/parked-gix-2/gunnar-wire-gix/tests/real_git_interop.rs
//! **Interoperability against the real `git` binary, as a client.** Spec §15.
//!
//! gunnar never runs git. This test does, because the only honest proof that a
//! git server works is that stock `git` talks to it. A test where gunnar's
//! client agrees with gunnar's server proves nothing: both halves can be wrong
//! in the same way, and a hand-written fixture of "what git sends" is a
//! statement about what its author believed, not about what git does.
//!
//! The pipe, the fixtures and the `git` invocation builder live in
//! [`git_harness`]; this file is the case table and the cases. A second target,
//! `real_git_surface`, drives the same harness over the command surface this
//! one does not reach.
//!
//! # Red first
//!
//! Two cases at the end deliberately break the server and assert that `git`
//! **refuses**: one lies about `object-format`, the other strips the sideband
//! band byte off every packfile line. Without them, the passing cases only show
//! that something happened.
#[path = "git_harness/mod.rs"]
mod git_harness;
use std::path::Path;
use git_harness::*;
use gix_hash::Kind;
use gunnar_wire::ProtocolVersion;
fn main() {
serve_if_asked();
let cases: &[(&str, fn())] = &[
("v2_clone_sha1", v2_clone_sha1),
("v0_clone_sha1_unforced_fallback", v0_clone_sha1),
("v2_clone_sha256", v2_clone_sha256),
("v0_clone_sha256", v0_clone_sha256),
("v2_incremental_fetch", v2_incremental_fetch),
("v2_fetch_divergent_history_negotiates", v2_fetch_divergent),
("v0_fetch_divergent_history_negotiates", v0_fetch_divergent),
("fetch_already_up_to_date_is_a_no_op", fetch_up_to_date),
("v2_clone_reuses_deltas_P001", v2_clone_reuses_deltas),
("v2_shallow_clone_depth_1", v2_shallow_clone),
// S-031: the three deepening variants, each compared against stock
// `git-upload-pack` over the same fixture rather than against gunnar's
// own understanding of them.
("v2_deepen_since_matches_stock_git", v2_deepen_since),
("v2_deepen_not_matches_stock_git", v2_deepen_not),
("v2_deepen_relative_matches_stock_git", v2_deepen_relative),
("v2_partial_clone_blob_none", v2_partial_clone),
// Reachability bitmaps. Every one of these compares the bitmap answer
// against the walk's, because "the clone worked" is true of a server
// that ships the whole repository every time.
("v2_clone_with_a_bitmap", v2_clone_with_a_bitmap),
("v2_fetch_with_a_bitmap", v2_fetch_with_a_bitmap),
(
"v2_fetch_after_a_gc_with_a_bitmap",
v2_fetch_after_a_gc_with_a_bitmap,
),
(
"v2_clone_with_a_bitmap_sha256",
v2_clone_with_a_bitmap_sha256,
),
(
"v2_bitmap_pack_passes_index_pack_strict",
v2_bitmap_pack_passes_index_pack_strict,
),
("v2_ls_remote_lists_symref_and_peeled_tag", v2_ls_remote),
("push_create_update_delete_sha1", push_lifecycle_sha1),
("push_sha256", push_sha256),
(
"push_rejected_by_a_gate_is_reported_to_git",
push_rejected_by_gate,
),
("push_atomic_rejects_the_whole_batch", push_atomic),
(
"push_of_a_conflicting_refname_is_rejected_like_stock_git",
push_refname_conflict,
),
(
"RED_wrong_object_format_breaks_the_clone",
red_wrong_object_format,
),
(
"RED_dropped_sideband_marker_breaks_the_clone",
red_dropped_sideband,
),
];
// The empty-run trap, closed: "0 passed, 0 failed, out of 0" exits 0 and is
// shaped exactly like a clean sweep. `S-005`/`S-006` cite the *count*, so
// the count is asserted rather than reported.
const EXPECTED_CASES: usize = 27;
assert_eq!(
cases.len(),
EXPECTED_CASES,
"the §15 case table changed size — update EXPECTED_CASES deliberately, \
so a table that lost its entries cannot report a clean sweep"
);
run_cases("real_git_interop", cases, 27);
}
// ── cases: fetch ────────────────────────────────────────────────────────────
fn v2_clone_sha1() {
let scene = Scene::new(Kind::Sha1);
clone_and_verify(&scene, ProtocolVersion::V2, "clone-v2");
}
fn v0_clone_sha1() {
let scene = Scene::new(Kind::Sha1);
clone_and_verify(&scene, ProtocolVersion::V0, "clone-v0");
}
fn v2_clone_sha256() {
let scene = Scene::new(Kind::Sha256);
let target = clone_and_verify(&scene, ProtocolVersion::V2, "clone-v2-256");
assert_eq!(
git_in(&target).args(["rev-parse", "HEAD"]).ok().len(),
64,
"a sha256 repository must produce 64-hex object ids"
);
}
fn v0_clone_sha256() {
let scene = Scene::new(Kind::Sha256);
clone_and_verify(&scene, ProtocolVersion::V0, "clone-v0-256");
}
fn v2_incremental_fetch() {
let scene = Scene::new(Kind::Sha1);
let target = clone_and_verify(&scene, ProtocolVersion::V2, "work");
// Move the origin on, through real git, so the fetch has something to do.
let seed = scene.root.join("seed");
commit(&seed, "e.txt", "five", "after the clone");
git_in(&seed)
.args(["push", "-q", scene.origin.to_str().unwrap(), "main"])
.ok();
scene.clear_log();
scene
.client(ProtocolVersion::V2)
.cwd(&target)
.args(["fetch", "-q", "origin"])
.ok();
git_in(&target)
.args(["merge", "-q", "--ff-only", "origin/main"])
.ok();
assert_eq!(
git_in(&target).args(["rev-parse", "HEAD"]).ok(),
scene.origin_ref("refs/heads/main")
);
git_in(&target).args(["fsck", "--no-progress"]).ok();
}
/// The case spec §6 singles out: a client whose branch has genuinely diverged.
///
/// The assertion is not "the fetch succeeded": a server that ships the whole
/// repository every time also succeeds. It is that the pack is **small**, which
/// is only true if the `have`s were understood.
fn divergent(protocol: ProtocolVersion) {
let scene = Scene::new(Kind::Sha1);
let target = clone_and_verify(&scene, protocol, "work");
let seed = scene.root.join("seed");
// Forty commits on each side, so negotiation cannot finish in one round
// with git's default `have` batch of 32.
git_in(&target).args(["checkout", "-q", "-b", "mine"]).ok();
for index in 0..40 {
commit(&target, &format!("mine{index}.txt"), "mine", "local work");
commit(
&seed,
&format!("theirs{index}.txt"),
"theirs",
"remote work",
);
}
git_in(&seed)
.args(["push", "-q", scene.origin.to_str().unwrap(), "main"])
.ok();
// What a fresh clone of the grown origin would cost.
scene.clear_log();
let probe = scene.root.join("probe");
scene
.client(protocol)
.args(["clone", "-q", "--bare"])
.arg(scene.url())
.arg(probe.to_str().unwrap())
.ok();
let whole_repository = objects_sent(&scene);
std::fs::remove_dir_all(&probe).ok();
scene.clear_log();
scene
.client(protocol)
.cwd(&target)
.args(["fetch", "-q", "origin"])
.ok();
git_in(&target).args(["fsck", "--no-progress"]).ok();
assert_eq!(
git_in(&target).args(["rev-parse", "origin/main"]).ok(),
scene.origin_ref("refs/heads/main")
);
let sent = objects_sent(&scene);
assert!(
sent < whole_repository,
"the divergent fetch sent {sent} objects where a whole clone is \
{whole_repository}: the `have`s were not used"
);
let rounds = fetch_rounds(&scene);
assert!(
rounds >= 2,
"a 40-commit divergence negotiated in {rounds} round(s); the \
have/want loop is not being exercised"
);
}
fn v2_fetch_divergent() {
divergent(ProtocolVersion::V2);
}
fn v0_fetch_divergent() {
divergent(ProtocolVersion::V0);
}
/// An empty fetch has to be a clean no-op, not an error and not a full pack.
fn fetch_up_to_date() {
let scene = Scene::new(Kind::Sha1);
let target = clone_and_verify(&scene, ProtocolVersion::V2, "work");
scene.clear_log();
let (ok, _, stderr) = scene
.client(ProtocolVersion::V2)
.cwd(&target)
.args(["fetch", "origin"])
.run();
assert!(ok, "an up-to-date fetch failed:\n{stderr}");
assert_eq!(
objects_sent(&scene),
0,
"a fetch with nothing to do still packed objects; log was {:?}",
scene.server_log()
);
git_in(&target).args(["fsck", "--no-progress"]).ok();
}
/// **`P-001`, end to end and judged by the client.**
///
/// Every other clone case here runs against `Scene`'s six-object fixture, where
/// git's delta encoder has nothing to find, so they prove the pack-copy path is
/// *correct* and say nothing about whether it copied anything. This one runs
/// against a repository stock git delta-compressed itself and asserts three
/// things no clone-succeeded check can see:
///
/// 1. the server reports the objects **copied** rather than recompressed,
/// 2. `git` accepts the delta-carrying pack: `index-pack` resolved every
/// `ofs-delta` base distance gunnar re-pointed, and `fsck --strict` walked
/// every object,
/// 3. the clone's own pack is a fraction of the content it represents, which
/// is only possible if the deltas came across.
///
/// Point 2 is the reason this case exists at all: a wrong base distance
/// produces a smaller pack that no git can read, and *"smaller"* is exactly
/// what `P-001` is judged on.
fn v2_clone_reuses_deltas() {
let scene = Scene::churned(Kind::Sha1);
let target = scene.root.join("delta-clone");
scene.clear_log();
scene
.client(ProtocolVersion::V2)
.args(["clone", "-q"])
.arg(scene.url())
.arg(target.to_str().unwrap())
.ok();
// git's own strictest read of what gunnar sent.
git_in(&target)
.args(["fsck", "--strict", "--no-progress"])
.ok();
assert_eq!(
git_in(&target).args(["rev-parse", "HEAD"]).ok(),
scene.origin_ref("refs/heads/main")
);
let log = scene.server_log();
let copied = log_field(&log, "copied=");
let recompressed = log_field(&log, "recompressed=");
let sent = objects_sent(&scene);
assert!(
copied > 0 && recompressed == 0,
"the server served {sent} objects of a fully packed repository with \
copied={copied} recompressed={recompressed} — it inflated and \
re-deflated bytes it already had (P-001). Log: {log:?}"
);
assert_eq!(
copied, sent as usize,
"only {copied} of {sent} objects were copied. Log: {log:?}"
);
// The clone's pack against the raw content it stands for. Churned text
// compresses well on its own, so this is a loose bound deliberately; the
// tight one is the head-to-head bench, not a correctness suite. What it
// catches is a pack that came across as whole objects.
let pack_bytes: u64 = std::fs::read_dir(target.join(".git/objects/pack"))
.expect("the clone has a pack directory")
.flatten()
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("pack"))
.filter_map(|e| e.metadata().ok().map(|m| m.len()))
.sum();
assert!(
pack_bytes > 0,
"the clone has no packfile at all, so nothing above was measured"
);
let content_bytes = (CHURN_COMMITS * CHURN_LINES * 45) as u64;
assert!(
pack_bytes * 10 < content_bytes,
"the clone's pack is {pack_bytes} B for ~{content_bytes} B of revisions \
— under 10x, which is what whole-object deflate gets and delta reuse \
beats by a wide margin"
);
}
fn v2_shallow_clone() {
let scene = Scene::new(Kind::Sha1);
let target = scene.root.join("shallow");
scene
.client(ProtocolVersion::V2)
.args(["clone", "-q", "--depth=1"])
.arg(scene.url())
.arg(target.to_str().unwrap())
.ok();
git_in(&target).args(["fsck", "--no-progress"]).ok();
assert!(
target.join(".git").join("shallow").exists(),
"a --depth=1 clone produced no shallow file, so no boundary was sent"
);
let commits: usize = git_in(&target)
.args(["rev-list", "--count", "HEAD"])
.ok()
.parse()
.unwrap();
assert_eq!(commits, 1, "--depth=1 produced {commits} commits");
assert_eq!(
git_in(&target).args(["rev-parse", "HEAD"]).ok(),
scene.origin_ref("refs/heads/main")
);
}
/// Everything a clone ended up holding: object ids, and its shallow boundary.
///
/// Ids rather than `rev-list` output verbatim, because the path column differs
/// between a tree walked from one root and the same tree walked from another.
fn clone_contents(dir: &Path) -> (Vec<String>, Vec<String>) {
let listing = git_in(dir).args(["rev-list", "--objects", "--all"]).ok();
let mut ids: Vec<String> = listing
.lines()
.filter_map(|line| line.split_whitespace().next())
.map(str::to_owned)
.collect();
ids.sort();
ids.dedup();
let mut shallow: Vec<String> = std::fs::read_to_string(dir.join(".git").join("shallow"))
.unwrap_or_default()
.lines()
.map(str::to_owned)
.collect();
shallow.sort();
(ids, shallow)
}
/// Clone `scene`'s repository twice with the same `flags`, once from gunnar
/// over `ext::` and once from stock `git-upload-pack` over `file://`, then
/// assert the two clones are indistinguishable.
///
/// This is the assertion `S-031` exists for. gunnar's own unit tests for
/// deepening were written from the same understanding as `shallow.rs`, so they
/// can only confirm it; another implementation of the same protocol cannot.
///
/// Returns what arrived, so the caller can assert the request did something
/// *specific*. Two implementations that both ignore an argument agree
/// perfectly.
fn deepening_matches_stock_git(scene: &Scene, name: &str, flags: &[&str]) -> Deepened {
let ours = scene.root.join(format!("{name}-gunnar"));
let theirs = scene.root.join(format!("{name}-git"));
let base = ["clone", "-q", "--single-branch", "--branch", "main"];
scene
.client(ProtocolVersion::V2)
.args(base)
.args(flags.iter().copied())
.arg(scene.url())
.arg(ours.to_str().unwrap())
.ok();
Git::new()
.cwd(&scene.root)
.args(base)
.args(flags.iter().copied())
.arg(scene.local_url())
.arg(theirs.to_str().unwrap())
.ok();
// A clone gunnar produced that git cannot check is not a clone.
git_in(&ours).args(["fsck", "--no-progress"]).ok();
let (our_ids, our_shallow) = clone_contents(&ours);
let (their_ids, their_shallow) = clone_contents(&theirs);
assert_eq!(
our_shallow, their_shallow,
"{name}: gunnar cut the history at a different boundary than stock git.\n\
gunnar: {our_shallow:?}\nstock: {their_shallow:?}"
);
assert_eq!(
our_ids.len(),
their_ids.len(),
"{name}: gunnar sent {} objects and stock git sent {}",
our_ids.len(),
their_ids.len()
);
assert_eq!(
our_ids, their_ids,
"{name}: the two clones hold different objects"
);
let commits: usize = git_in(&ours)
.args(["rev-list", "--count", "HEAD"])
.ok()
.parse()
.unwrap();
assert_eq!(
commits,
git_in(&theirs)
.args(["rev-list", "--count", "HEAD"])
.ok()
.parse::<usize>()
.unwrap(),
"{name}: the two clones disagree on how much history arrived"
);
Deepened {
commits,
boundary: our_shallow,
}
}
/// What a deepening request delivered, once gunnar and stock git have been
/// proven to have delivered the same thing.
struct Deepened {
/// Commits reachable from `HEAD` in the resulting clone.
commits: usize,
/// The clone's `.git/shallow`, sorted. This is the field that tells
/// `--depth`, `--shallow-since` and `--shallow-exclude` apart; the commit
/// count does not (see [`Scene::dated`]).
boundary: Vec<String>,
}
/// `deepen-since`, i.e. `git clone --shallow-since`, agrees with stock git and
/// says something `--depth` does not (`S-031`).
///
/// The cut sits between `C`(+3000) and `B`(+2000). Both requests land the same
/// two commits — `D` and `M` — and stop at a **different boundary**: `--depth=2`
/// grafts `M` alone, the date cut grafts `C` as well, because `C` is inside the
/// window while `M`'s other parent `B` is not. The boundary is asserted
/// commit-by-commit, because "gunnar agrees with git" is also true of two
/// servers that both ignore the argument.
fn v2_deepen_since() {
let scene = Scene::dated(Kind::Sha1);
let depth2 = deepening_matches_stock_git(&scene, "depth2", &["--depth=2"]);
let since = deepening_matches_stock_git(&scene, "since", &[SHALLOW_SINCE]);
let merge = scene.origin_ref("refs/heads/main^");
let older = scene.origin_ref("refs/heads/side");
assert_eq!(
(depth2.commits, since.commits),
(2, 2),
"--depth=2 gave {} commits and --shallow-since gave {}; both land D and M",
depth2.commits,
since.commits
);
assert_eq!(
depth2.boundary,
vec![merge.clone()],
"--depth=2 must graft the merge and nothing else"
);
assert_eq!(
since.boundary,
sorted(vec![merge, older]),
"--shallow-since must graft the merge *and* name C as a cut point; a \
boundary equal to --depth=2's means the date was ignored"
);
assert_ne!(
depth2.boundary, since.boundary,
"--depth=2 and --shallow-since cut at the same place, so this fixture \
cannot tell the two arguments apart"
);
}
/// `deepen-not` (`git clone --shallow-exclude`) agrees with stock git on the
/// merge case `graph.rs` calls "a bug worth naming".
///
/// Excluding `refs/heads/side` cuts `M` off from the parent that leads to `C`
/// while keeping the parent that leads to `B` — which is why `B`, and not `C`,
/// is the cut point named alongside the merge. A server that treated the
/// exclusion as a *boundary* rather than a parent restriction never reaches `B`
/// at all and cannot produce that boundary.
fn v2_deepen_not() {
let scene = Scene::dated(Kind::Sha1);
let excluded =
deepening_matches_stock_git(&scene, "exclude", &["--shallow-exclude=refs/heads/side"]);
let merge = scene.origin_ref("refs/heads/main^");
let kept_parent = scene.origin_ref("refs/heads/main^^");
assert_eq!(excluded.commits, 2, "--shallow-exclude lands D and M");
assert_eq!(
excluded.boundary,
sorted(vec![merge, kept_parent]),
"--shallow-exclude=refs/heads/side must cut at B — the parent the \
exclusion *kept* — and not at C, which the exclusion removed"
);
// ...and it is genuinely a different answer from the other two variants,
// which is what makes all three cases worth running. Same fixture, same
// client, three arguments.
let since_dir = scene.root.join("since-cmp");
scene
.client(ProtocolVersion::V2)
.args(["clone", "-q", "--single-branch", "--branch", "main"])
.arg(SHALLOW_SINCE)
.arg(scene.url())
.arg(since_dir.to_str().unwrap())
.ok();
let (_, since_shallow) = clone_contents(&since_dir);
assert_ne!(
excluded.boundary, since_shallow,
"--shallow-exclude and --shallow-since cut at the same place, so the \
fixture cannot tell the two arguments apart and neither case proves \
anything"
);
}
/// `values`, sorted — the order [`clone_contents`] returns a boundary in.
fn sorted(mut values: Vec<String>) -> Vec<String> {
values.sort();
values
}
/// `deepen-relative` (`git fetch --deepen=<n>` on an already-shallow clone)
/// agrees with stock git.
///
/// The only way to exercise `deepen-relative` at all: it counts from the
/// *client's* existing boundary rather than from the wants, so it needs a
/// client that already has one. Both clones start at `--depth=1` and are
/// deepened by one, and the fetch is asserted to have actually moved the
/// boundary, since a `--deepen` that did nothing would leave the two clones equal
/// and pass a comparison on its own.
fn v2_deepen_relative() {
let scene = Scene::dated(Kind::Sha1);
let before = deepening_matches_stock_git(&scene, "rel", &["--depth=1"]);
assert_eq!(
before.commits, 1,
"--depth=1 gave {} commits",
before.commits
);
let ours = scene.root.join("rel-gunnar");
let theirs = scene.root.join("rel-git");
scene
.client(ProtocolVersion::V2)
.cwd(&ours)
.args(["fetch", "-q", "--deepen=1", "origin"])
.ok();
Git::new()
.cwd(&theirs)
.args(["fetch", "-q", "--deepen=1", "origin"])
.ok();
git_in(&ours).args(["fsck", "--no-progress"]).ok();
let (our_ids, our_shallow) = clone_contents(&ours);
let (their_ids, their_shallow) = clone_contents(&theirs);
assert_eq!(
our_shallow, their_shallow,
"after --deepen=1 gunnar's boundary is {our_shallow:?} and stock git's \
is {their_shallow:?}"
);
assert_eq!(
our_ids, their_ids,
"after --deepen=1 the two clones hold different objects"
);
let commits: usize = git_in(&ours)
.args(["rev-list", "--count", "HEAD"])
.ok()
.parse()
.unwrap();
assert_eq!(
commits, 2,
"--deepen=1 from a depth-1 clone left {commits} commits; the boundary \
did not move, so this case never exercised deepen-relative"
);
}
fn v2_partial_clone() {
let scene = Scene::new(Kind::Sha1);
let target = scene.root.join("partial");
scene
.client(ProtocolVersion::V2)
.args(["clone", "-q", "--no-checkout", "--filter=blob:none"])
.arg(scene.url())
.arg(target.to_str().unwrap())
.ok();
// The point of the filter is that blobs did NOT arrive. A server that
// parsed `filter` and ignored it produces a clone that works perfectly.
let objects = git_in(&target)
.args(["cat-file", "--batch-check", "--batch-all-objects"])
.ok();
let blobs = objects
.lines()
.filter(|line| line.contains(" blob "))
.count();
assert_eq!(
blobs, 0,
"a blob:none clone received {blobs} blobs, so the filter did nothing"
);
assert!(
objects.lines().any(|line| line.contains(" commit ")),
"a blob:none clone received no commits either"
);
// And it must still be a usable partial clone: checking out fetches the
// missing blobs back through the same server.
scene
.client(ProtocolVersion::V2)
.cwd(&target)
.args(["checkout", "-q", "main"])
.ok();
assert_eq!(
std::fs::read_to_string(target.join("a.txt"))
.unwrap()
.trim(),
"one"
);
}
fn v2_ls_remote() {
let scene = Scene::new(Kind::Sha1);
scene.clear_log();
let output = scene
.client(ProtocolVersion::V2)
.args(["ls-remote", "--symref"])
.arg(scene.url())
.ok();
assert!(
output.contains("ref: refs/heads/main\tHEAD"),
"ls-remote --symref did not report the symref:\n{output}"
);
assert!(
output.contains("refs/tags/v1^{}"),
"ls-remote did not report the peeled tag:\n{output}"
);
assert!(
output.contains("refs/heads/side"),
"ls-remote lost a branch:\n{output}"
);
}
// ── cases: push ─────────────────────────────────────────────────────────────
fn push_lifecycle(hash_kind: Kind) {
let scene = Scene::new(hash_kind);
let target = clone_and_verify(&scene, ProtocolVersion::V0, "work");
// create
commit(&target, "pushed.txt", "pushed", "a pushed commit");
scene.clear_log();
scene
.client(ProtocolVersion::V0)
.cwd(&target)
.args(["push", "-q", "origin", "HEAD:refs/heads/pushed"])
.ok();
let local = git_in(&target).args(["rev-parse", "HEAD"]).ok();
assert_eq!(scene.origin_ref("refs/heads/pushed"), local);
scene.origin_git().args(["fsck", "--no-progress"]).ok();
// update. A second commit onto the same branch, which is the thin-pack
// case: its deltas resolve against objects only the server has.
commit(&target, "pushed2.txt", "again", "a second pushed commit");
scene
.client(ProtocolVersion::V0)
.cwd(&target)
.args(["push", "-q", "origin", "HEAD:refs/heads/pushed"])
.ok();
assert_eq!(
scene.origin_ref("refs/heads/pushed"),
git_in(&target).args(["rev-parse", "HEAD"]).ok()
);
scene.origin_git().args(["fsck", "--no-progress"]).ok();
// and the pushed history is servable back out again
let readback = scene.root.join("readback");
scene
.client(ProtocolVersion::V2)
.args(["clone", "-q", "--branch", "pushed"])
.arg(scene.url())
.arg(readback.to_str().unwrap())
.ok();
assert_eq!(git_in(&readback).args(["rev-parse", "HEAD"]).ok(), {
let mut expected = scene.origin_ref("refs/heads/pushed");
expected.truncate(if hash_kind == Kind::Sha256 { 64 } else { 40 });
expected
});
// delete
scene
.client(ProtocolVersion::V0)
.cwd(&target)
.args(["push", "-q", "origin", ":refs/heads/pushed"])
.ok();
let (exists, _, _) = scene
.origin_git()
.args(["rev-parse", "--verify", "refs/heads/pushed"])
.run();
assert!(!exists, "the branch survived a delete push");
}
fn push_lifecycle_sha1() {
push_lifecycle(Kind::Sha1);
}
fn push_sha256() {
push_lifecycle(Kind::Sha256);
}
/// **`refs/heads/x` and `refs/heads/x/y` cannot both exist**, and the client
/// has to be told so rather than handed a repository no loose-ref backend can
/// hold.
///
/// Stock git 2.53.0 into a bare repository already holding `refs/heads/x`,
/// measured:
///
/// ```text
/// ! [remote rejected] HEAD -> x/y (refname conflict)
/// error: failed to push some refs
/// ```
///
/// gunnar's `fs` backend inherited the rule from gix and its three in-process
/// backends did not — MEASURED 2026-08-05, `mem`, the Arrow log and the
/// in-memory Arrow log all accepted the pair. This case is the client-facing
/// half: the refusal has to arrive as an `ng`, and the reference must not be
/// there afterwards. The per-backend half is `gunnar-store`'s conformance
/// check `ref_directory_file_conflict`, which runs on all four.
fn push_refname_conflict() {
let scene = Scene::new(Kind::Sha1);
let target = clone_and_verify(&scene, ProtocolVersion::V0, "work-df");
// The file first. This one must land, or the conflict below would be
// green on a server that refuses every push.
scene
.client(ProtocolVersion::V0)
.cwd(&target)
.args(["push", "origin", "HEAD:refs/heads/dfx"])
.ok();
let (ok, stdout, stderr) = scene
.client(ProtocolVersion::V0)
.cwd(&target)
.args(["push", "origin", "HEAD:refs/heads/dfx/y"])
.run();
assert!(
!ok,
"gunnar accepted refs/heads/dfx/y beside refs/heads/dfx; stock git \
refuses the identical push:\n{stdout}\n{stderr}"
);
assert!(
stderr.contains("[remote rejected]") || stdout.contains("[remote rejected]"),
"the refusal did not reach git as an `ng`:\n{stdout}\n{stderr}"
);
// Applied output: what the server actually holds. A refusal that still
// moved the reference, or one that dropped `dfx` on the way, would both
// satisfy the exit-status assertion above.
let refs = scene
.origin_git()
.args(["for-each-ref", "--format=%(refname)"])
.ok();
assert!(
refs.lines().any(|r| r == "refs/heads/dfx"),
"the refused push took refs/heads/dfx with it:\n{refs}"
);
assert!(
!refs.lines().any(|r| r == "refs/heads/dfx/y"),
"the refused reference is on the server anyway:\n{refs}"
);
}
/// A refusal that comes from the **server** has to reach the client as a
/// refusal, not as a hang-up.
///
/// This is driven through the policy gate rather than through a stale old
/// value, because stock git checks the old value on its own side and never
/// sends a push it expects to be refused. The server-side compare-and-swap
/// itself is pinned by `tests/protocol.rs`, where the bytes can be built by
/// hand; what only a real client can prove is that `ng <ref> <reason>` is
/// understood.
fn push_rejected_by_gate() {
let scene = Scene::new(Kind::Sha1);
let target = clone_and_verify(&scene, ProtocolVersion::V0, "work");
commit(
&target,
"gated.txt",
"gated",
"a commit the gate will refuse",
);
scene.clear_log();
let (ok, stdout, stderr) = scene
.client(ProtocolVersion::V0)
.env("GUNNAR_WIRE_BREAK", "reject-push")
.cwd(&target)
.args(["push", "origin", "HEAD:refs/heads/gated"])
.run();
assert!(
!ok,
"a gate-refused push reported success:\n{stdout}\n{stderr}"
);
assert!(
stderr.contains("[remote rejected]") || stdout.contains("[remote rejected]"),
"git did not parse gunnar's refusal:\n{stdout}\n{stderr}"
);
assert!(
stderr.contains("the gunnar test gate says no")
|| stdout.contains("the gunnar test gate says no"),
"the reason gunnar sent did not reach the user:\n{stdout}\n{stderr}"
);
let log = scene.server_log();
assert!(
log.iter().any(|line| line.contains("ng:")),
"the server did not record a refusal; it logged {log:?}"
);
let (created, _, _) = scene
.origin_git()
.args(["rev-parse", "--verify", "refs/heads/gated"])
.run();
assert!(!created, "a refused push created the reference anyway");
// ...and the identical push lands once the gate is out of the way, so the
// failure above is the gate and not the fixture.
scene
.client(ProtocolVersion::V0)
.cwd(&target)
.args(["push", "-q", "origin", "HEAD:refs/heads/gated"])
.ok();
assert_eq!(
scene.origin_ref("refs/heads/gated"),
git_in(&target).args(["rev-parse", "HEAD"]).ok()
);
}
/// `--atomic`: one refused reference fails the batch, and the good one must
/// not land.
///
/// The refusal is made to happen **on the server**, so the batch really is
/// sent and really is applied-or-not by `RefStore::transaction`. A per-
/// reference loop that stopped at the first refusal would leave the earlier
/// one applied, and only the second assertion here would notice.
fn push_atomic() {
let scene = Scene::new(Kind::Sha1);
let target = clone_and_verify(&scene, ProtocolVersion::V0, "work");
commit(&target, "atomic.txt", "atomic", "an atomic push");
scene.clear_log();
let (ok, stdout, stderr) = scene
.client(ProtocolVersion::V0)
.env("GUNNAR_WIRE_BREAK", "reject-side")
.cwd(&target)
.args([
"push",
"--atomic",
"origin",
"HEAD:refs/heads/atomic-new",
"HEAD:refs/heads/side",
])
.run();
assert!(
!ok,
"an atomic push with a refused reference reported success:\n{stdout}\n{stderr}"
);
let log = scene.server_log();
let statuses = log
.iter()
.find_map(|line| line.split("statuses=").nth(1))
.unwrap_or("");
assert_eq!(
statuses.matches("ng:").count(),
2,
"an atomic batch reported a partial result: {statuses:?}"
);
let (created, _, _) = scene
.origin_git()
.args(["rev-parse", "--verify", "refs/heads/atomic-new"])
.run();
assert!(
!created,
"the good half of an atomic push landed anyway; it is not atomic"
);
// Without --atomic the same batch must apply what it can, or `atomic` is
// not a capability but the only behaviour.
scene.clear_log();
let (_, _, _) = scene
.client(ProtocolVersion::V0)
.env("GUNNAR_WIRE_BREAK", "reject-side")
.cwd(&target)
.args([
"push",
"origin",
"HEAD:refs/heads/loose-new",
"HEAD:refs/heads/side",
])
.run();
let (created, _, _) = scene
.origin_git()
.args(["rev-parse", "--verify", "refs/heads/loose-new"])
.run();
assert!(
created,
"a non-atomic batch refused everything because one reference was bad"
);
}
// ── cases: red first ────────────────────────────────────────────────────────
/// Spec §6's documented, session-costing bug: answer the wrong
/// `object-format`. If this case passes, every green above is meaningless.
fn red_wrong_object_format() {
let scene = Scene::new(Kind::Sha256);
let target = scene.root.join("should-not-exist");
let (ok, stdout, stderr) = scene
.client(ProtocolVersion::V2)
.env("GUNNAR_WIRE_BREAK", "hash-lie")
.args(["clone", "-q"])
.arg(scene.url())
.arg(target.to_str().unwrap())
.run();
assert!(
!ok,
"git accepted a server that advertised sha1 for a sha256 \
repository:\n{stdout}\n{stderr}"
);
// ...and the same clone works once the lie is removed, so the failure is
// the lie and not the harness.
let honest = scene.root.join("honest");
scene
.client(ProtocolVersion::V2)
.args(["clone", "-q"])
.arg(scene.url())
.arg(honest.to_str().unwrap())
.ok();
}
/// Drop the sideband band byte from every packfile line. The bytes stay
/// well-formed pkt-lines, so only a client that really demultiplexes band 1
/// notices, which is the point.
fn red_dropped_sideband() {
let scene = Scene::new(Kind::Sha1);
let target = scene.root.join("should-not-exist");
let (ok, stdout, stderr) = scene
.client(ProtocolVersion::V2)
.env("GUNNAR_WIRE_BREAK", "no-sideband")
.args(["clone", "-q"])
.arg(scene.url())
.arg(target.to_str().unwrap())
.run();
assert!(
!ok,
"git accepted a v2 packfile section with no band byte:\n{stdout}\n{stderr}"
);
let honest = scene.root.join("honest");
scene
.client(ProtocolVersion::V2)
.args(["clone", "-q"])
.arg(scene.url())
.arg(honest.to_str().unwrap())
.ok();
}
// ───────────────────────────────────────────────────────────────────────────
// Reachability bitmaps, end to end, with stock git as the judge
// ───────────────────────────────────────────────────────────────────────────
/// Repack `scene.origin` so it carries a `.bitmap`, and prove it got one.
///
/// The `assert` is not decoration. Every case below would pass with the whole
/// reader deleted if the repository quietly had no bitmap to read — which is
/// precisely how the previous lane's writer shipped broken on 18 of 19 real
/// repositories with every fixture green.
///
/// Note which branch of `repack` this takes: `Scene` builds its origin with one
/// `git clone --bare`, so the origin holds **one** pack and the repack is the
/// `already_consolidated` no-op that writes only the sidecar. That is the shape
/// a real served repository is in, and the shape the fixtures that missed the
/// bug were not.
fn bitmap_the_origin(scene: &Scene) -> std::path::PathBuf {
let hash_kind = detect_hash_kind(&scene.origin);
let store = gunnar_store::fs::FsStore::open_or_init(&scene.origin, hash_kind).expect("open");
let outcome = store
.repack(&gunnar_store::RepackOptions::new())
.expect("repack");
let bitmap = outcome
.bitmap
.expect("the fixture got no .bitmap, so nothing below tests the bitmap path");
assert!(
bitmap.exists(),
"repack reported a .bitmap that is not on disk"
);
// The store holds the repository's exclusive lock; the server process that
// is about to serve it needs it.
drop(store);
bitmap
}
/// Every object id in a repository, sorted. The comparison unit for the cases
/// below — a **set**, because two packs of the same size can hold different
/// objects and both index cleanly.
fn objects_of(git_dir: &Path) -> Vec<String> {
let mut ids: Vec<String> = git_in(git_dir)
.args(["rev-list", "--objects", "--all"])
.ok()
.lines()
.filter_map(|line| line.split_whitespace().next().map(str::to_owned))
.collect();
ids.sort();
ids
}
/// A clone off a bitmapped repository lands the same objects as one off the
/// walk, and stock git accepts both.
///
/// Three judges, and each can pass while another fails:
///
/// * `fsck --full --strict` guards the **pack** — and the previous lane
/// recorded it staying green through a bitmap that was flatly wrong, because
/// a wrong object *set* is still a well-formed pack;
/// * the object-set comparison is what catches that, and it is the one this
/// case exists for;
/// * `rev-parse HEAD` catches a clone that landed a tree nobody asked for.
fn v2_clone_with_a_bitmap() {
let scene = Scene::new(Kind::Sha1);
bitmap_the_origin(&scene);
let with = clone_and_verify(&scene, ProtocolVersion::V2, "with");
git_in(&with).args(["fsck", "--full", "--strict"]).ok();
// The same clone with the reader switched off, so the comparison is
// between the two paths rather than between gunnar and its own memory.
let without = scene.root.join("without");
scene
.client(ProtocolVersion::V2)
.env("GUNNAR_BITMAP", "off")
.args(["clone", "-q"])
.arg(scene.url())
.arg(without.to_str().unwrap())
.ok();
git_in(&without).args(["fsck", "--full", "--strict"]).ok();
assert_eq!(
objects_of(&with),
objects_of(&without),
"the bitmap clone and the walk clone landed different objects"
);
assert_eq!(
git_in(&with).args(["rev-parse", "HEAD"]).ok(),
git_in(&without).args(["rev-parse", "HEAD"]).ok(),
);
}
/// A negotiated fetch off a bitmapped repository, which is the exclusion side.
///
/// A clone has no `have`s at all, so it exercises the OR and nothing else. This
/// exercises the `AND NOT` — the half the README's "exclusion is
/// `O(client history)`" was about — and the bounded walk, because the commits
/// pushed after the repack are in a second pack with no bitmap of their own.
fn v2_fetch_with_a_bitmap() {
let scene = Scene::new(Kind::Sha1);
bitmap_the_origin(&scene);
let with = clone_and_verify(&scene, ProtocolVersion::V2, "with");
let without = scene.root.join("without");
scene
.client(ProtocolVersion::V2)
.env("GUNNAR_BITMAP", "off")
.args(["clone", "-q"])
.arg(scene.url())
.arg(without.to_str().unwrap())
.ok();
// Move the origin on **after** the repack, so HEAD is a commit with no
// stored bitmap and the fetch has to walk down to one that has.
let seed = scene.root.join("seed");
for index in 0..3 {
commit(
&seed,
&format!("late{index}.txt"),
"late",
"after the repack",
);
}
git_in(&seed)
.args(["push", "-q", scene.origin.to_str().unwrap(), "main"])
.ok();
let mut sent = Vec::new();
for (target, bitmaps) in [(&with, "on"), (&without, "off")] {
scene.clear_log();
let mut client = scene.client(ProtocolVersion::V2).cwd(target);
if bitmaps == "off" {
client = client.env("GUNNAR_BITMAP", "off");
}
client.args(["fetch", "-q", "origin"]).ok();
git_in(target)
.args(["merge", "-q", "--ff-only", "origin/main"])
.ok();
git_in(target).args(["fsck", "--full", "--strict"]).ok();
sent.push(objects_sent(&scene));
}
// The object **count**, because nothing above can see the exclusion at all.
// MEASURED 2026-08-04 with the `AND NOT` deliberately removed: this whole
// file stayed green while the server shipped the client's own history back
// to it. The pack was bigger, valid, `fsck`ed clean and landed identical
// objects — because the client already had the surplus. Only this number
// moves.
// No count assertion here, deliberately, and the reason is worth recording
// rather than leaving as a gap somebody re-adds. On this fixture the want
// is a commit with **no** stored bitmap, so the bounded walk answers it —
// and that walk already turns away anything in the `have` set as it goes
// (git's `include_data.seen`). The final `AND NOT` therefore has nothing
// left to subtract, and MEASURED 2026-08-04 with it deliberately removed
// this case reported 7 objects either way. A guard that cannot move is not
// a guard; `v2_fetch_after_a_gc_with_a_bitmap` is where the subtraction is
// load-bearing, and that is where the count is asserted.
let _ = sent;
assert_eq!(
objects_of(&with),
objects_of(&without),
"the bitmap fetch and the walk fetch landed different objects"
);
assert_eq!(
git_in(&with).args(["rev-parse", "HEAD"]).ok(),
scene.origin_ref("refs/heads/main"),
"the bitmap fetch did not land the origin's main"
);
}
/// A fetch where **both sides are bitmapped**, which is the steady state and
/// the only shape in which the `AND NOT` is load-bearing.
///
/// `v2_fetch_with_a_bitmap` fetches commits pushed *after* the repack, so its
/// want has no stored bitmap and the bounded walk answers it — turning away the
/// client's objects as it goes, leaving the subtraction nothing to do. Repack a
/// second time and the want has a bitmap: the whole closure is OR'd in at once,
/// and the only thing standing between the client and its own history coming
/// back to it is the `AND NOT`.
///
/// MEASURED 2026-08-04, with the subtraction deliberately removed: this case
/// goes from 7 objects to the whole repository. Without it the removal is
/// invisible to all 25 cases in this file.
fn v2_fetch_after_a_gc_with_a_bitmap() {
let scene = Scene::new(Kind::Sha1);
bitmap_the_origin(&scene);
let with = clone_and_verify(&scene, ProtocolVersion::V2, "with");
let without = scene.root.join("without");
scene
.client(ProtocolVersion::V2)
.env("GUNNAR_BITMAP", "off")
.args(["clone", "-q"])
.arg(scene.url())
.arg(without.to_str().unwrap())
.ok();
let seed = scene.root.join("seed");
for index in 0..3 {
commit(
&seed,
&format!("late{index}.txt"),
"late",
"after the first repack",
);
}
git_in(&seed)
.args(["push", "-q", scene.origin.to_str().unwrap(), "main"])
.ok();
// The second repack is the point: now the new tip has a bitmap too.
bitmap_the_origin(&scene);
let mut sent = Vec::new();
for (target, bitmaps) in [(&with, "on"), (&without, "off")] {
scene.clear_log();
let mut client = scene.client(ProtocolVersion::V2).cwd(target);
if bitmaps == "off" {
client = client.env("GUNNAR_BITMAP", "off");
}
client.args(["fetch", "-q", "origin"]).ok();
git_in(target)
.args(["merge", "-q", "--ff-only", "origin/main"])
.ok();
git_in(target).args(["fsck", "--full", "--strict"]).ok();
sent.push(objects_sent(&scene));
}
assert_eq!(
objects_of(&with),
objects_of(&without),
"the bitmap fetch and the walk fetch landed different objects"
);
assert!(
sent[0] > 0 && sent[1] > 0,
"a fetch sent nothing, so the comparison below is vacuous: {sent:?}"
);
assert!(
sent[0] <= sent[1],
"the bitmap fetch sent {} objects where the walk sent {}; \
the client is being sent history it already has",
sent[0],
sent[1]
);
}
/// The same, on **sha256**, because the `.bitmap` header's checksum width
/// follows the repository hash and a hardcoded 20 parses a sha256 file into
/// garbage that still looks like an index.
fn v2_clone_with_a_bitmap_sha256() {
let scene = Scene::new(Kind::Sha256);
bitmap_the_origin(&scene);
let target = clone_and_verify(&scene, ProtocolVersion::V2, "work");
git_in(&target).args(["fsck", "--full", "--strict"]).ok();
}
/// The pack a bitmapped clone produces is one **stock `index-pack --strict`**
/// accepts on its own, away from the clone that would fix it up.
///
/// `git clone` runs `index-pack` itself, but with `--fix-thin`, which repairs a
/// pack whose deltas point at bases it does not carry. This asks the harsher
/// question, on the raw bytes: is the pack self-contained and internally
/// consistent as written? A wrong object *order* — a delta emitted before its
/// base — shows up here and nowhere above.
fn v2_bitmap_pack_passes_index_pack_strict() {
let scene = Scene::new(Kind::Sha1);
bitmap_the_origin(&scene);
let bundle = scene.root.join("bundle");
std::fs::create_dir_all(&bundle).expect("mkdir");
// A clone into a scratch directory, then re-index the pack it received.
let target = clone_and_verify(&scene, ProtocolVersion::V2, "work");
let packs: Vec<std::path::PathBuf> = target
.join(".git/objects/pack")
.read_dir()
.expect("pack dir")
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "pack"))
.collect();
assert_eq!(
packs.len(),
1,
"expected one received pack, found {packs:?}"
);
let (ok, _out, err) = git_in(&bundle)
.args(["index-pack", "--strict", "-o"])
.arg(bundle.join("copy.idx").to_str().unwrap())
.arg(packs[0].to_str().unwrap())
.run();
assert!(
ok,
"index-pack --strict refused the bitmap clone's pack: {err}"
);
}