crates/storage-git-gix/parked-gix-2/gunnar-wire-gix/tests/pack_copy.rs
//! `P-001` on the serve path: the pack is copied, not re-deflated.
//!
//! # Why none of these assertions is "the clone succeeded"
//!
//! Both emitters produce a correct, complete, stock-git-readable pack of the
//! same objects. A test that cloned and checked the result would pass against
//! the re-deflating writer this replaces, which is exactly how gunnar came to
//! ship an 18.34× bandwidth gap against `git 2.53.0` with a green suite. So
//! what is asserted here is **applied output**:
//!
//! * `objects_copied_from_pack` against `objects_recompressed`, which the serve
//! path now reports for the same reason `RepackOutcome` does: a byte count
//! and a wall clock cannot tell a pack-copy from a re-deflate,
//! * the **bytes**, against the whole-object pack of the *same object set*,
//! produced by the same code path on the same fixture,
//! * `git verify-pack`'s own count of resolved delta chains, which is stock
//! git's opinion rather than gunnar's of whether a delta reached the wire,
//! * `git index-pack --strict`, which is what git itself runs on a pack it is
//! handed, so the delta chains are resolved and every object id re-derived by
//! the other implementation.
//!
//! # The identity-value trap, and how the fixture avoids it
//!
//! A fixture of unique random blobs deltas to nothing, so a working pack-copy
//! would look broken and a broken one would look fine (`P-001`, `S-003`, and
//! the head-to-head in `stinky.md` `S-044`, whose own note says the eleven
//! gunnar-against-gunnar arms flatter gunnar for exactly this reason). The
//! fixture here is **churn**: one file rewritten a couple of lines at a time
//! across many commits, which is what a real repository looks like and what
//! git's delta encoder is for.
//!
//! # And why stock `git` builds it
//!
//! gitoxide has **no delta encoder at all** (`P-009`), so gunnar cannot make
//! the deltas this reuses and neither can its own repack. They come from the
//! pushing client, and the pushing client is stock git. A fixture packed by
//! gunnar would contain no deltas to copy and every assertion below would be
//! vacuous.
use std::path::{Path, PathBuf};
use std::process::Command;
use gix_hash::{Kind, ObjectId};
use gunnar_store::fs::FsStore;
use gunnar_store::{NoPackCopy, ObjectStore, MULTI_PACK_INDEX_THRESHOLD};
use gunnar_wire::upload_pack::FetchRequest;
use gunnar_wire::UploadPack;
mod common;
use common::{index_and_count, Fixture, Response};
// ── the fixture ─────────────────────────────────────────────────────────────
/// How many commits of churn. Small enough that the whole fixture is a few
/// megabytes and the test runs in about a second, large enough that git's
/// delta window (10 by default) has something to find.
const COMMITS: usize = 48;
/// Lines in the churned file. 400 × ~48 bytes ≈ 19 KiB per revision, so the
/// whole fixture is bounded at roughly `COMMITS × 19 KiB` ≈ 900 KiB of raw
/// blob content, deliberately capped: a degenerate size crossed with a large
/// input is how a test in this tree took a 499 GB box down.
const LINES: usize = 400;
/// How many files each commit rewrites in [`Churn::build_with`]. Three of
/// sixty means any given file is revisited about every twenty commits, so a
/// fetch of the last eight sees new revisions of files whose previous
/// revision is well behind the cut.
const TOUCHED_PER_COMMIT: usize = 3;
/// Files in the `S-003` fixture. See [`Churn::build_with`].
const WIDE_FILES: usize = 60;
/// Pushes in the multi-pack fixture, and therefore packs in it. See
/// [`Churn::build_pushed`].
///
/// **Twelve, and it is a floor rather than a preference.** The defect this
/// fixture exists for needs one object to be stored *twice* — whole in the pack
/// whose push introduced its dependant, and as a delta in the pack whose push
/// introduced it — which is what `index-pack --fix-thin` leaves behind when two
/// pushes overlap. MEASURED on this fixture, oden 2026-08-05, `gitoxide`'s
/// verdict on the pack served for a `HEAD~8` cut:
///
/// ```text
/// packs 2 3 6 12 24
/// verdict ok ok ok FAIL FAIL
/// ```
///
/// At six packs the cut has to move to `HEAD~12` before it fires. Twelve is the
/// smallest round count that fires at the same `HEAD~8` cut every other arm in
/// this file uses, so the arms differ in the fixture's *layout* and in nothing
/// else.
const PUSH_ROUNDS: usize = 12;
/// A bare repository whose pack **stock git** built, with real delta chains in
/// it.
struct Churn {
_dir: tempfile::TempDir,
root: PathBuf,
origin: PathBuf,
/// A `Cell`, because [`Churn::push_more`] moves the origin's `HEAD` after
/// construction and every arm that wants the tip must see the move. A plain
/// field would leave a post-gc fixture serving the pre-gc tip, which is a
/// clone of the wrong history that passes every count.
head: std::cell::Cell<ObjectId>,
}
/// How the bare fixture repository is assembled. See [`Churn::build_pushed`].
#[derive(Clone, Copy)]
enum Layout {
/// One `git repack -adf` pack, which is a repository somebody has `gc`'d.
OnePack,
/// One pack per push and no repack, which is every repository that has been
/// pushed to since its last `gc`.
PushedRounds(usize),
/// The first `rounds` of `of` history slices, and no more: a repository
/// that is mid-way through being filled, so a caller can fold it and
/// measure the **same object set** before and after. See
/// [`Churn::build_partial`].
PushedRoundsOf { rounds: usize, of: usize },
}
impl Churn {
fn build() -> Self {
Self::build_with(1)
}
/// The same churn spread over `files` files, `TOUCHED_PER_COMMIT` of them
/// rewritten per commit in rotation.
///
/// `files == 1` is the original fixture and what every `P-001` arm below
/// uses. A **wider** fixture is what `S-003` needs, and the difference is
/// not cosmetic: with one file, blob *n* deltas against blob *n-1*, so a
/// fetch of the last few commits carries almost every base it needs and
/// the defect is invisible — MEASURED on this very fixture, 23 of 24
/// objects copied. With sixty files touched three at a time, a file
/// rewritten inside the fetch window was last written ~20 commits before
/// it, which is on the far side of the negotiation cut. That is what a
/// real repository looks like to a fetch, and it is the only shape in
/// which this measures anything.
fn build_with(files: usize) -> Self {
Self::build_layout(files, Layout::OnePack)
}
/// The same churn, but the bare repository is built by **pushing** in
/// `rounds` slices and is never repacked — so it ends with `rounds` packs
/// and no `.bitmap`.
///
/// That is not a variation for its own sake. It is the *only* shape in
/// which two objects of one transfer can sit in different source packs,
/// which is when `gix_pack`'s copier cannot re-point a delta at its base
/// inside the pack it is writing and names it by object id instead. A
/// single-pack fixture cannot produce that entry at all, which is why every
/// arm above passed while `gunnar.year` was failing on it — and it is also
/// the *production* shape, because a repository grows a pack per push and
/// only `gc` collapses them.
fn build_pushed(files: usize, rounds: usize) -> Self {
Self::build_layout(files, Layout::PushedRounds(rounds))
}
/// The pushed fixture, stopped **before** the `gc`, so a caller can measure
/// it, fold it with [`Churn::gunnar_gc`] and measure the same object set
/// again.
///
/// A before/after on one repository, rather than two fixtures compared: the
/// commits carry real timestamps, so two builds have different object ids
/// and git's delta window makes different choices, and a cross-fixture
/// comparison of copy counts would be reading that noise as a result.
fn build_partial(files: usize, rounds: usize, of: usize) -> Self {
Self::build_layout(files, Layout::PushedRoundsOf { rounds, of })
}
/// Run gunnar's own `gc` over this fixture, in place.
fn gunnar_gc(&self) -> gunnar_store::RepackOutcome {
let store = gunnar_store::fs::FsStore::open_or_init(&self.origin, Kind::Sha1)
.expect("open the fixture for gunnar's own gc");
store
.repack(&gunnar_store::RepackOptions::new())
.expect("gunnar gc")
}
/// Push history slices `from+1..=to` of `of`, one pack each.
fn push_more(&self, from: usize, to: usize, of: usize) {
push_slices(&self.root.join("seed"), &self.origin, from, to, of);
self.head_moved();
}
/// Re-read `HEAD` after the origin has been pushed to, and remember it.
fn head_moved(&self) -> ObjectId {
let head = ObjectId::from_hex(git(&self.origin, &["rev-parse", "HEAD"]).as_bytes())
.expect("the head is an object id");
self.head.set(head);
head
}
fn build_layout(files: usize, layout: Layout) -> Self {
let dir = tempfile::tempdir().expect("a temporary directory");
let root = dir.path().to_path_buf();
let work = root.join("seed");
std::fs::create_dir_all(&work).expect("the work tree");
git(&root, &["init", "-q", "-b", "main", work.to_str().unwrap()]);
git(&work, &["config", "user.email", "bench@gunnar.invalid"]);
git(&work, &["config", "user.name", "gunnar bench"]);
let mut content: Vec<Vec<String>> = (0..files)
.map(|f| {
(0..LINES)
.map(|i| format!("line {i:04} of file {f:03}, original text"))
.collect()
})
.collect();
for revision in 0..COMMITS {
for slot in 0..TOUCHED_PER_COMMIT.min(files) {
let f = (revision * TOUCHED_PER_COMMIT + slot) % files;
// ~2% of the lines rewritten: a diff, not a rewrite. A whole
// new file every commit would delta to nothing and make a
// working encoder look broken.
for step in 0..LINES / 50 {
let index = (revision * 7 + step * 53) % LINES;
content[f][index] = format!("line {index:04} rewritten at revision {revision}");
}
let name = if files == 1 {
"churn.txt".to_owned()
} else {
format!("churn-{f:03}.txt")
};
std::fs::write(work.join(&name), content[f].join("\n")).expect("write the file");
git(&work, &["add", &name]);
}
git(
&work,
&["commit", "-q", "-m", &format!("revision {revision}")],
);
}
let origin = root.join("origin.git");
match layout {
Layout::OnePack => {
git(
&root,
&[
"clone",
"-q",
"--bare",
work.to_str().unwrap(),
origin.to_str().unwrap(),
],
);
// `-a -d -f`: one pack, no loose objects, and **recompute the
// deltas from scratch** rather than reuse whatever the clone
// happened to produce. This is the step that puts the delta
// chains gunnar is about to copy into the fixture, so it is not
// tidying.
git(&origin, &["repack", "-adf", "-q"]);
}
Layout::PushedRounds(rounds) => {
assert!(rounds >= 2, "one round is one pack, which is `OnePack`");
init_pushed_origin(&root, &origin);
push_slices(&work, &origin, 0, rounds, rounds);
// Deliberately no `repack`, no `gc`, no `--prune`. See
// [`Churn::build_pushed`]: the packs ARE the fixture.
assert_eq!(
pack_count(&origin),
rounds,
"{rounds} push(es) left {} pack(s). A one-pack fixture cannot \
produce a cross-pack delta and every arm built on this one would be \
measuring the single-pack case twice",
pack_count(&origin)
);
assert!(
!has_bitmap(&origin),
"the pushed fixture grew a .bitmap, so the bitmap path would answer \
and the walk path — the subject — would never run"
);
}
Layout::PushedRoundsOf { rounds, of } => {
assert!(rounds >= 2 && rounds < of);
init_pushed_origin(&root, &origin);
push_slices(&work, &origin, 0, rounds, of);
assert_eq!(pack_count(&origin), rounds);
assert!(!has_bitmap(&origin));
}
}
let head = ObjectId::from_hex(git(&origin, &["rev-parse", "HEAD"]).as_bytes())
.expect("the head is an object id");
Churn {
_dir: dir,
root,
origin,
head: std::cell::Cell::new(head),
}
}
/// Serve one clone of this repository and hand back what came out.
///
/// One serve function for every arm below, so that two arms being compared
/// differ in exactly the one thing named in the call and nothing else: same
/// object selection, same request, same `fetch`.
///
/// * `ofs_delta` is the **client's own capability**. Since 2026-08-03 it
/// decides how a copied delta *names its base* — by distance backwards
/// (`OBJ_OFS_DELTA`) or by object id (`OBJ_REF_DELTA`) — and no longer
/// whether copying happens at all.
/// * `pack_copy` is **gunnar's own seam**, [`NoPackCopy`]. `false` is the
/// pre-`P-001` server: same store, same walk, `write_pack` instead of
/// `write_pack_copy`.
fn clone_through_gunnar(&self, ofs_delta: bool, pack_copy: bool) -> (Vec<u8>, Counts) {
let store = FsStore::open_or_init(&self.origin, Kind::Sha1).expect("open the fixture");
let objects = store.objects();
let refs = store.refs();
// The fixture guard, and not a formality: on a store with no packs
// every arm here would be the fallback, `recompressed == 0` would be
// unreachable, and the whole comparison would be a re-deflate measured
// against itself. `P-001`'s own bench shipped in exactly that state.
assert!(
objects.packed().is_some(),
"the fs backend reports no pack-resident view of a repository `git repack -adf` \
just packed, so this whole test would be measuring the fallback twice"
);
let request = FetchRequest {
wants: vec![self.head.get()],
done: true,
thin_pack: true,
ofs_delta,
..Default::default()
};
let mut out = Vec::new();
// The two branches differ only in what `packed()` answers. Everything
// downstream — the request, the walk, the writer's options — is shared.
let outcome = if pack_copy {
UploadPack::new(&objects, &refs).fetch(&request, &mut out)
} else {
let blind = NoPackCopy(&objects);
assert!(
blind.packed().is_none(),
"the control arm still reports a pack-resident view, so it is not a control"
);
UploadPack::new(&blind, &refs).fetch(&request, &mut out)
}
.expect("serve the clone");
// Band 1 and nowhere else: the band byte is stripped here, so a valid
// pack coming out of this has already proven the framing was right.
let pack = Response::parse(&out).band(1);
(
pack,
Counts {
sent: outcome.objects_sent,
copied: outcome.objects_copied_from_pack,
recompressed: outcome.objects_recompressed,
declined: outcome.closure_bases_declined,
},
)
}
/// The `n`th ancestor of the tip, as a `have` a real client would send.
fn ancestor(&self, n: usize) -> ObjectId {
git(&self.origin, &["rev-parse", &format!("HEAD~{n}")])
.parse()
.expect("an ancestor is an object id")
}
/// A **real client**: a repository holding exactly `at` and its ancestry,
/// and nothing newer.
///
/// A fetch pack cannot be judged standing on its own. Its commits name
/// parents that live on the client's side of the negotiation cut, so
/// `git index-pack --strict` — which verifies connectivity — refuses it,
/// and refuses it identically whether the pack is thin, whole, correct or
/// wrong. That is not a fixture detail: it is the reason this helper
/// exists. The judge has to be a client that holds the bases, which is the
/// only place a `--fix-thin` completion can even be attempted, and it is
/// what a real `git fetch` does with the bytes.
fn client_at(&self, at: ObjectId) -> PathBuf {
let client = self.root.join(format!("client-{at}.git"));
git(
&self.root,
&[
"clone",
"-q",
"--bare",
self.origin.to_str().unwrap(),
client.to_str().unwrap(),
],
);
// Rewind to the cut and drop everything newer, so the client genuinely
// lacks what the fetch is about to bring. Without the prune it would
// hold the whole history and `--fix-thin` would have nothing to fix.
git(&client, &["update-ref", "refs/heads/main", &at.to_string()]);
git(&client, &["symbolic-ref", "HEAD", "refs/heads/main"]);
git(&client, &["reflog", "expire", "--all", "--expire=now"]);
git(&client, &["gc", "-q", "--prune=now"]);
assert!(
Command::new("git")
.current_dir(&client)
.args(["cat-file", "-e", &format!("{}^{{commit}}", self.head.get())])
.status()
.expect("probe the client")
.code()
!= Some(0),
"the client still holds the tip the fetch is supposed to deliver, so \
`--fix-thin` would be resolving bases against objects a real client \
at this cut would not have"
);
client
}
/// Serve one **negotiated fetch**: the client already holds `haves` and its
/// whole ancestry, and asks for the tip.
///
/// The difference from [`Churn::clone_through_gunnar`] is the only thing
/// that matters here, and it is one field: a non-empty `haves`. A clone's
/// selection is the whole history, so every delta's base travels with it;
/// a fetch's selection is a suffix, so the bases the pushing client's git
/// deltified against are on the *other* side of the cut. What the server
/// does with those is what this measures.
fn fetch_through_gunnar(&self, haves: &[ObjectId]) -> (Vec<u8>, Counts) {
self.fetch_asking(haves, true)
}
/// The same, with the client's `thin-pack` capability under the caller's
/// control. `false` is a client that never asked for one.
fn fetch_asking(&self, haves: &[ObjectId], thin_pack: bool) -> (Vec<u8>, Counts) {
self.fetch_asking_full(haves, thin_pack, true)
}
/// The same, with both capabilities under the caller's control.
fn fetch_asking_full(
&self,
haves: &[ObjectId],
thin_pack: bool,
ofs_delta: bool,
) -> (Vec<u8>, Counts) {
let store = FsStore::open_or_init(&self.origin, Kind::Sha1).expect("open the fixture");
let objects = store.objects();
let refs = store.refs();
assert!(
objects.packed().is_some(),
"the fs backend reports no pack-resident view of a repository `git repack -adf` \
just packed, so this whole test would be measuring the fallback twice"
);
let request = FetchRequest {
wants: vec![self.head.get()],
haves: haves.to_vec(),
done: true,
thin_pack,
ofs_delta,
..Default::default()
};
let mut out = Vec::new();
let outcome = UploadPack::new(&objects, &refs)
.fetch(&request, &mut out)
.expect("serve the fetch");
assert!(
outcome.objects_sent > 0,
"the negotiation left nothing to send, so there is no fetch to measure"
);
(
Response::parse(&out).band(1),
Counts {
sent: outcome.objects_sent,
copied: outcome.objects_copied_from_pack,
recompressed: outcome.objects_recompressed,
declined: outcome.closure_bases_declined,
},
)
}
}
#[derive(Debug)]
struct Counts {
sent: u32,
copied: usize,
recompressed: usize,
/// See [`gunnar_pack::WriteOutcome::closure_bases_declined`].
declined: usize,
}
/// A bare origin that keeps every push as its own pack.
///
/// A small push is unpacked into loose objects by default
/// (`transfer.unpackLimit`, 100), and loose objects have no delta to copy at
/// all. gunnar's own receive-pack always writes a pack, so this is what makes a
/// fixture the production shape rather than a git-only artefact.
///
/// One writer for both pushed layouts (LAW 5): a second copy that forgot the
/// `unpackLimit` line would build a fixture with no packs in it and every arm
/// standing on it would measure the loose-object fallback while claiming to
/// measure pack copy.
fn init_pushed_origin(root: &Path, origin: &Path) {
git(
root,
&[
"init",
"-q",
"-b",
"main",
"--bare",
origin.to_str().unwrap(),
],
);
git(origin, &["config", "transfer.unpackLimit", "1"]);
}
/// Push history slices `from..to` of `total`, one push — and so one pack —
/// each.
fn push_slices(work: &Path, origin: &Path, from: usize, to: usize, total: usize) {
for r in from + 1..=to {
let behind = COMMITS - COMMITS * r / total;
git(
work,
&[
"push",
"-q",
origin.to_str().unwrap(),
&format!("+HEAD~{behind}:refs/heads/main"),
],
);
}
}
fn pack_dir_entries(origin: &Path) -> Vec<PathBuf> {
std::fs::read_dir(origin.join("objects").join("pack"))
.expect("the pack directory")
.filter_map(|e| e.ok())
.map(|e| e.path())
.collect()
}
fn pack_count(origin: &Path) -> usize {
pack_dir_entries(origin)
.iter()
.filter(|p| p.extension().is_some_and(|x| x == "pack"))
.count()
}
fn has_bitmap(origin: &Path) -> bool {
pack_dir_entries(origin)
.iter()
.any(|p| p.extension().is_some_and(|x| x == "bitmap"))
}
fn git(cwd: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(cwd)
.args(args)
.output()
.unwrap_or_else(|err| panic!("running `git {args:?}`: {err}"));
assert!(
out.status.success(),
"git {args:?} failed:\n{}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_owned()
}
/// What **stock git** says is inside a pack gunnar served.
#[derive(Debug)]
struct StockGit {
/// Delta entries, counted from `verify-pack`'s own chain-length histogram
/// after it resolved them.
deltas: usize,
/// `OBJ_OFS_DELTA` entries — a base named by distance backwards, which a
/// client that did not advertise `ofs-delta` cannot decode.
ofs_deltas: usize,
/// `OBJ_REF_DELTA` entries — a base named by object id, which needs no
/// capability and is the older of the two forms.
ref_deltas: usize,
}
/// Hand `pack` to **stock git** and report what it finds.
///
/// `index-pack --strict` is what git runs on a pack it receives: it resolves
/// every delta chain, re-derives every object id and refuses a pack whose
/// objects do not connect. If gunnar's `ofs-delta` base distances were wrong by
/// one byte this is what would say so, and nothing gunnar can check about its
/// own bytes would. It is also the entire safety argument for emitting an
/// `OBJ_REF_DELTA` whose base lies inside the same pack, which `gix-pack`'s own
/// comment calls "a discontinued practice": discontinued is not invalid, and
/// this is the third party whose acceptance is the claim.
///
/// **And it is not the only third party, which cost a fetch on 2026-08-05.**
/// The sentence above is true of a *self-contained* pack and was read as
/// covering a **thin** one, where the same entry means something else: in a
/// thin pack an `OBJ_REF_DELTA` is a claim about what the receiver already
/// holds, and gitoxide enforces exactly that while stock git does not. So the
/// arm that can see the difference is
/// [`a_thin_pack_names_no_base_the_receiver_does_not_already_hold`], which asks
/// both, and the two judges are kept side by side rather than one standing in
/// for the other.
fn stock_git_reads(root: &Path, pack: &[u8], name: &str) -> StockGit {
let dir = root.join(name);
std::fs::create_dir_all(&dir).expect("a directory for the pack");
let pack_path = dir.join("copy.pack");
std::fs::write(&pack_path, pack).expect("write the pack out");
git(
&dir,
&[
"index-pack",
"--strict",
"-o",
"copy.idx",
pack_path.to_str().unwrap(),
],
);
let listing = git(&dir, &["verify-pack", "-v", "copy.idx"]);
// `verify-pack -v` ends with a histogram: `non delta: N objects`, then one
// `chain length = D: M objects` line per delta depth present. Summing those
// is git's own count of how many entries in this pack are deltas, taken
// after it resolved them. Reading the per-object lines instead would mean
// re-deciding what a delta looks like, which is the thing being checked.
let deltas = listing
.lines()
.filter_map(|line| line.trim().strip_prefix("chain length = "))
.filter_map(|rest| rest.split(": ").nth(1))
.filter_map(|rest| rest.split_whitespace().next())
.filter_map(|count| count.parse::<usize>().ok())
.sum();
// The delta **form** is a thing `verify-pack` does not print: its per-object
// lines carry the *object* type (`blob`, `tree`, `commit`) and mark a delta
// by appending `<depth> <base-oid>`, identically for both forms. So the form
// is read where the format records it — the high nibble of the first byte of
// the entry, `6` for `OBJ_OFS_DELTA` and `7` for `OBJ_REF_DELTA` — at the
// offsets **git** printed. Nothing of gunnar's decides where an entry starts
// or whether it is a delta; the only thing read here is one byte at each of
// git's own offsets.
//
// (`gunnar-pack/tests/ofs_delta_off.rs` asserts the same nibble one layer
// down, over `write_pack_copy` called directly. What is being checked here
// is the layer that one cannot see: that `upload_pack` passes the client's
// capability down to the emitter at all.)
let mut ofs_deltas = 0;
let mut ref_deltas = 0;
for line in listing.lines() {
// `<oid> <type> <size> <size-in-pack> <offset>`, plus `<depth> <base-oid>`
// when it is a delta. Histogram lines carry no oid in column one.
let fields: Vec<&str> = line.split_whitespace().collect();
let Some(first) = fields.first() else {
continue;
};
if first.len() < 40 || !first.bytes().all(|b| b.is_ascii_hexdigit()) {
continue;
}
if fields.len() < 7 {
continue;
}
let offset: usize = fields[4].parse().expect("git printed a pack offset");
match (pack[offset] >> 4) & 0b111 {
6 => ofs_deltas += 1,
7 => ref_deltas += 1,
other => panic!("git called offset {offset} a delta, but its type nibble is {other}"),
}
}
assert_eq!(
ofs_deltas + ref_deltas,
deltas,
"git's chain-length histogram counted {deltas} deltas but only \
{ofs_deltas} + {ref_deltas} entries parsed as one, so the two readings \
of the same pack disagree and neither can be trusted"
);
StockGit {
deltas,
ofs_deltas,
ref_deltas,
}
}
// ── the cases ───────────────────────────────────────────────────────────────
/// The headline: a clone of a packed repository is **copied**, and the wire
/// bytes say so.
///
/// # What the control arm is, and why it changed
///
/// Until 2026-08-03 the control was `ofs_delta: false`, because the serve path
/// gated copying on that capability and a client without it got the pre-`P-001`
/// server. That gate is gone — `OBJ_REF_DELTA` needs no capability, so such a
/// client now gets the copied entries too — and with it went the control: both
/// capability arms copy, and an assertion that the `false` arm recompresses
/// everything is now an assertion that the fix is absent.
///
/// So the control is [`NoPackCopy`], gunnar's own seam, which is what the
/// capability was only ever standing in for. It is a **better** control than the
/// capability ever was: it cuts `write_pack_copy` out of the path entirely
/// rather than changing how a copied entry names its base.
#[test]
fn a_clone_of_a_packed_repository_copies_entries_instead_of_recompressing_them() {
let churn = Churn::build();
let (copied_pack, copied) = churn.clone_through_gunnar(true, true);
let (whole_pack, whole) = churn.clone_through_gunnar(true, false);
// ── 1. the provenance the serve path now reports ────────────────────────
assert_eq!(
copied.recompressed, 0,
"a clone of a fully packed repository deflated {} of its {} objects from \
scratch; every one of them was already sitting deflated in the pack \
(P-001). Counts: {copied:?}",
copied.recompressed, copied.sent
);
assert_eq!(
copied.copied, copied.sent as usize,
"the serve path reports {} of {} objects copied. Anything less means \
entries are being decoded and re-deflated on a repository where none \
needs to be: {copied:?}",
copied.copied, copied.sent
);
// The other arm is what the whole tree did before P-001, and it is here so
// that "copied == sent" above is a statement about this change rather than
// about an assertion that can only ever come out one way.
assert_eq!(
(whole.copied, whole.recompressed),
(0, whole.sent as usize),
"the pack-copy-off control is meant to be the OLD behaviour: every \
object deflated from scratch, nothing copied. If it copied anything, \
`NoPackCopy` is not cutting the seam and every count above is measuring \
the same emitter twice: {whole:?}"
);
// ── 2. the same objects, both ways ──────────────────────────────────────
assert_eq!(
copied.sent, whole.sent,
"the two arms packed different object sets, so their sizes are not \
comparable"
);
assert_eq!(index_and_count(&copied_pack, Kind::Sha1), copied.sent);
assert_eq!(index_and_count(&whole_pack, Kind::Sha1), whole.sent);
// ── 3. the bytes, which are the point ───────────────────────────────────
//
// MEASURED on this fixture, oden 2026-08-03: 144 objects, 17 696 B copied
// against 81 580 B with the seam cut, so **4.61×**. The absolute figures
// move a few percent between runs and the ratio barely does: the fixture
// commits carry real timestamps, so its object ids differ every run and
// git's delta window makes slightly different choices (17 696 / 17 722 /
// 19 155 B copied over three runs, 4.26× to 4.61×). Quote the ratio.
//
// The floor is 3× rather than 4.6× because this is a guard and not the
// measurement (the measurement is the head-to-head against stock git), but
// it is far outside anything a re-deflate can reach, which is the whole
// assertion. Seen RED with the seam left uncut: both arms then run the same
// emitter and the ratio is 1.00×, which is what this line and not the count
// above it is here to catch — checked with that count neutralised, because a
// guard standing behind another guard that always fires first is not a
// guard.
let ratio = whole_pack.len() as f64 / copied_pack.len() as f64;
// Printed as well as asserted: the threshold below is a floor chosen to be
// unreachable by a re-deflate, and the actual figure is what tells a reader
// whether the fixture still has anything left to delta. `cargo test --
// --nocapture` shows it.
eprintln!(
"pack_copy: {} objects, copied {} B vs whole-object {} B = {ratio:.2}x smaller",
copied.sent,
copied_pack.len(),
whole_pack.len(),
);
assert!(
ratio > 3.0,
"the copied pack is {} bytes against the whole-object pack's {}, only \
{ratio:.2}× smaller. Delta reuse is not happening, whatever the object \
counts say",
copied_pack.len(),
whole_pack.len()
);
// ── 4. stock git's opinion of those bytes ───────────────────────────────
let copied = stock_git_reads(&churn.root, &copied_pack, "verify-copied");
let whole = stock_git_reads(&churn.root, &whole_pack, "verify-whole");
assert!(
copied.deltas > 0,
"`git verify-pack` resolved no delta chains in the copied pack, so the \
size win above came from somewhere other than delta reuse: {copied:?}"
);
assert_eq!(
whole.deltas, 0,
"the pack-copy-off control emitted {} deltas. gitoxide has no delta \
encoder at all (P-009), so a delta in that pack is not a better \
fallback, it is evidence the control is still copying: {whole:?}",
whole.deltas
);
// A client that *did* advertise the capability should be paying the cheap
// form: base by distance backwards, not 20 bytes of object id per entry.
assert_eq!(
copied.ref_deltas, 0,
"an ofs-delta-capable client was sent {} ref-deltas, each one paying a \
full object id where a 1-3 byte varint would do: {copied:?}",
copied.ref_deltas
);
}
/// A client that never advertised `ofs-delta` must not be sent one — and must
/// still get `P-001`.
///
/// Not a hypothetical politeness: an `OBJ_OFS_DELTA` entry to a client in that
/// mode is a pack it cannot decode.
///
/// Until 2026-08-03 that was honoured by not copying for such a client at all,
/// which handed it the whole of the pre-`P-001` server — MEASURED at **18.3×
/// the wire bytes and 13.0× the server CPU** for a capability that has nothing
/// to do with whether an entry may be copied. `OBJ_REF_DELTA` is the older of
/// the two forms and needs no capability, so the delta bytes survive in full and
/// only the base's *name* changes. Both halves are asserted here, because
/// either one alone is satisfiable by throwing the other away:
///
/// * **no `OBJ_OFS_DELTA` reaches this client** — the readability half, read
/// from the type nibble at stock git's own offsets, and
/// * **the entries were copied, deltas and all** — the `P-001` half, which is
/// what the old gate silently cost.
///
/// The layer under this one — `write_pack_copy` called directly, both forms,
/// byte-identical delta payloads — is `gunnar-pack/tests/ofs_delta_off.rs`. What
/// only this test can see is whether `upload_pack` hands the capability down.
#[test]
fn a_client_without_ofs_delta_gets_a_pack_it_can_read() {
let churn = Churn::build();
let (pack, counts) = churn.clone_through_gunnar(false, true);
// ── the P-001 half ──────────────────────────────────────────────────────
assert_eq!(
counts.recompressed, 0,
"a client without ofs-delta had {} of its {} objects inflated and \
deflated again. The capability decides how a copied delta names its \
base, not whether copying happens: {counts:?}",
counts.recompressed, counts.sent
);
assert_eq!(
counts.copied, counts.sent as usize,
"only {} of {} entries were copied for a client without ofs-delta: {counts:?}",
counts.copied, counts.sent
);
// ── the readability half, in stock git's words ──────────────────────────
let seen = stock_git_reads(&churn.root, &pack, "verify-fallback");
assert_eq!(
seen.ofs_deltas, 0,
"a client that never advertised ofs-delta was sent {} OBJ_OFS_DELTA \
entries it cannot decode: {seen:?}",
seen.ofs_deltas
);
assert!(
seen.ref_deltas > 0,
"not one delta reached this client. Either the copy path fell back to \
re-deflating everything — which is the 18.3x regression this test \
exists for — or the fixture has no deltas to copy: {seen:?}"
);
}
/// A store with no packs answers `None` and keeps working — the default on
/// [`ObjectStore::packed`] is a real answer, not a stub.
///
/// `MemObjectStore` is the case the appliance's non-`fs` build is, and a znippy
/// cold tier that stores inflated content per oid is the same shape (`P-005`).
#[test]
fn a_store_with_no_packs_falls_back_and_says_so() {
let fixture = Fixture::build(Kind::Sha1);
assert!(
fixture.objects.packed().is_none(),
"the in-memory store claims a pack-resident view it cannot have"
);
let upload = UploadPack::new(&fixture.objects, &fixture.refs);
let request = FetchRequest {
wants: vec![fixture.four],
done: true,
thin_pack: true,
ofs_delta: true,
..Default::default()
};
let mut out = Vec::new();
let outcome = upload.fetch(&request, &mut out).expect("serve the clone");
assert!(outcome.objects_sent > 0);
assert_eq!(
outcome.objects_copied_from_pack, 0,
"a store with no packs cannot have copied anything"
);
assert_eq!(
outcome.objects_recompressed, outcome.objects_sent as usize,
"every object had to be deflated, and the report must say so rather \
than leave the fields at zero"
);
assert_eq!(
index_and_count(&Response::parse(&out).band(1), Kind::Sha1),
outcome.objects_sent
);
}
/// **`S-003`: a negotiated fetch must keep the deltas a clone keeps.**
///
/// `P-001` is measured on a clone, where it copies 100% of the entries, and
/// that number hid this: a clone's object selection is the *whole* history, so
/// every `OBJ_OFS_DELTA`'s base is inside the transfer and
/// `PackCopyAndBaseObjects` can re-point it. A **fetch**'s selection is a
/// suffix of history, and the bases the pushing client's git chose live on the
/// other side of the negotiation cut. `gix_pack`'s `Entry::from_pack_entry`
/// then finds no base among `potential_bases`, returns `None` because
/// `allow_thin_pack` is false, and the caller inflates the whole delta chain
/// and re-deflates the object whole.
///
/// So the *same* object, in the *same* pack, is copied on a clone and
/// recompressed on a fetch. That is the whole of gunnar's 1.47x wire-byte loss
/// to gitea, and no clone-shaped test could ever see it.
///
/// What is asserted is applied output — `objects_copied_from_pack` against
/// `objects_recompressed`, and the delta count stock `git` finds in the bytes —
/// for the reason this file's own header gives: both shapes produce a valid,
/// complete, git-readable pack, so nothing about correctness can tell them
/// apart.
#[test]
fn a_negotiated_fetch_keeps_the_deltas_it_already_had() {
let churn = Churn::build_with(WIDE_FILES);
// `S003_DEPTH` re-runs this at another cut without editing it. The
// relationship is worth knowing and is the reason 8 is the default: the
// SHORTER the fetch, the worse the pre-fix server does, because a short
// transfer carries fewer of its own bases. MEASURED on this fixture,
// whole-pack over thin-pack: 4.95x at 8 commits, 5.52x at 24, 1.43x at 40.
// A benchmark that fetches hundreds of commits is measuring the best case.
let depth: usize = std::env::var("S003_DEPTH")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(8);
let cut = churn.ancestor(depth);
let haves = vec![cut];
let (pack, counts) = churn.fetch_through_gunnar(&haves);
// ── stock git is the judge, in the one place a fetch can be judged ──────
let client = churn.client_at(cut);
let (accepted, deltas) = client_accepts(&client, &pack);
fsck(&client);
// Not `==`. A **thin** pack names bases it does not carry, and
// `--fix-thin` appends those completed bases to the index it writes, so
// the client's pack is legitimately larger than the transfer. That
// inequality is itself the applied output: a pack that carried every base
// it referenced would come out exactly equal, which is what the pre-fix
// server produced.
assert!(
accepted >= counts.sent,
"stock git indexed {accepted} objects out of a fetch gunnar says carried {} \
({counts:?})",
counts.sent
);
// ── and stock git is also the yardstick ─────────────────────────────────
//
// The same object set, packed by `git pack-objects` for the same cut, in
// both forms it can take. `--thin` is what `git-upload-pack` sends a
// client that advertised the capability — which this request did — and is
// therefore the number gunnar is actually competing against. Without it is
// what gunnar sends today.
let thin = git_packs(&churn.origin, churn.head.get(), cut, true);
let whole = git_packs(&churn.origin, churn.head.get(), cut, false);
assert!(
whole > thin,
"stock git produced no smaller pack with --thin than without it ({whole} vs \
{thin}), so this fixture has no cross-cut deltas and every ratio below \
would be a comparison of a thing with itself"
);
eprintln!(
"S-003: sent={} copied={} recompressed={} bytes={} deltas={} indexed={} | git thin={} whole={}",
counts.sent,
counts.copied,
counts.recompressed,
pack.len(),
deltas,
accepted,
thin,
whole
);
// The claim, in the unit the loss was reported in: **wire bytes**. A count
// of copied entries cannot carry it, because the entries that matter are
// exactly the ones being expanded, and 23-of-24 looks excellent while the
// pack is twice the size it should be.
let over = pack.len() as f64 / thin as f64;
assert!(
over <= 1.10,
"gunnar served {} bytes where stock git's thin pack of the same objects is \
{thin} ({over:.2}x). Those objects are pack-resident and already deltified; \
{} of {} were re-deflated whole because their delta bases fall on the \
client's side of the negotiation cut. That is `S-003`: {counts:?}",
pack.len(),
counts.recompressed,
counts.sent
);
}
/// `git pack-objects` over the same revision range, thin or whole.
///
/// This is the yardstick, and it is stock git's own answer rather than
/// gunnar's about what the transfer is worth compressing to.
fn git_packs(origin: &Path, head: ObjectId, cut: ObjectId, thin: bool) -> usize {
use std::io::Write as _;
let mut args = vec!["pack-objects", "--revs", "--stdout", "--delta-base-offset"];
if thin {
args.push("--thin");
}
let mut child = Command::new("git")
.current_dir(origin)
.args(&args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn pack-objects");
write!(
child.stdin.take().expect("pack-objects stdin"),
"{head}\n^{cut}\n"
)
.expect("feed the revision range");
let out = child.wait_with_output().expect("pack-objects");
assert!(
out.status.success(),
"git pack-objects failed:\n{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!out.stdout.is_empty(),
"git pack-objects produced no bytes, so the yardstick is zero and every \
ratio against it is meaningless"
);
out.stdout.len()
}
/// Hand `pack` to a **real client** that holds the bases, exactly as `git
/// fetch` does, and report what it accepted.
///
/// `--strict` re-derives every object id and verifies connectivity;
/// `--fix-thin` is what completes a thin pack against objects the receiver
/// already has. Together they are the whole correctness claim for anything
/// this test measures: a delta whose base the client lacks, or a base distance
/// wrong by one byte, dies here rather than on a customer's disk.
///
/// Returns `(objects, deltas)` as **stock git's** own index says, not as
/// gunnar reported them.
fn client_accepts(client: &Path, pack: &[u8]) -> (u32, usize) {
use std::io::Write as _;
let mut child = Command::new("git")
.current_dir(client)
.args(["index-pack", "--strict", "--fix-thin", "--stdin", "-v"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn index-pack");
child
.stdin
.take()
.expect("index-pack stdin")
.write_all(pack)
.expect("feed the pack");
let out = child.wait_with_output().expect("index-pack");
assert!(
out.status.success(),
"stock git REFUSED the pack gunnar served:\n{}",
String::from_utf8_lossy(&out.stderr)
);
// `index-pack -v` answers `pack\t<sha>`; the id is the last field.
let name = String::from_utf8_lossy(&out.stdout)
.split_whitespace()
.next_back()
.unwrap_or_default()
.to_owned();
assert!(
!name.is_empty(),
"index-pack named no pack, so nothing can be counted"
);
let idx = client.join(format!("objects/pack/pack-{name}.idx"));
let listed = git(client, &["verify-pack", "-v", idx.to_str().unwrap()]);
// `verify-pack -v` prints one line per object:
// <oid> <type> <size> <size-in-pack> <offset> — a base
// <oid> <type> <size> <size-in-pack> <offset> <depth> <base-oid> — a delta
// so the seventh field is present exactly on the deltas. The type filter
// drops the trailing chain-length histogram, which is prose.
let mut objects = 0u32;
let mut deltas = 0usize;
for line in listed.lines() {
let fields: Vec<&str> = line.split_whitespace().collect();
if !matches!(fields.get(1), Some(&("commit" | "tree" | "blob" | "tag"))) {
continue;
}
objects += 1;
if fields.len() >= 7 {
deltas += 1;
}
}
assert!(
objects > 0,
"verify-pack listed no objects at all, so both numbers below would be \
zeroes that look exactly like a clean answer"
);
(objects, deltas)
}
/// `git fsck --full --strict` inside the client, after it took the pack.
fn fsck(client: &Path) {
let out = Command::new("git")
.current_dir(client)
.args(["fsck", "--full", "--strict", "--no-progress"])
.output()
.expect("run fsck");
assert!(
out.status.success(),
"the client's object store is broken after taking the pack:\n{}",
String::from_utf8_lossy(&out.stderr)
);
}
/// **The `S-003` gate, and the proof the arm above can go red.**
///
/// A thin pack is only legal because the client asked for one. This drives the
/// same fixture, the same cut and the same walk with `thin-pack` withheld, and
/// two things must follow:
///
/// * the objects whose bases fall outside the cut are **expanded again** —
/// which is the pre-fix server, so this is the red the green arm is
/// measured against, produced by a supported request rather than by an env
/// switch that only tests exist to honour, and
/// * the pack is **self-contained**: `git index-pack --strict` accepts it
/// standing alone, with no repository to resolve bases against. That is the
/// property a client without the capability is entitled to, and it is not
/// implied by the first — a server that emitted thin deltas regardless
/// would still report objects expanded if it expanded some others.
#[test]
fn a_client_that_did_not_ask_for_a_thin_pack_gets_a_whole_one() {
let churn = Churn::build_with(WIDE_FILES);
let cut = churn.ancestor(8);
let (asked, asked_counts) = churn.fetch_asking(&[cut], true);
let (plain, plain_counts) = churn.fetch_asking(&[cut], false);
// ── and the base closure must not have run ──────────────────────────────
//
// `write_pack_copy` may add a delta's unselected base to the pack instead of
// expanding the delta, and on a **clone** that is worth −2.82% of the wire.
// On a negotiated fetch it is the opposite: the base sits below the cut, so
// it is an object the client already holds, and shipping it is the
// duplication thin packs exist to avoid. MEASURED oden 2026-08-05 on this
// fixture with the closure wrongly enabled here: **31 360 → 55 513 bytes,
// +77%**, 57 bases added to recover 24 deltas.
//
// `upload_pack` therefore passes `base_closure: None` whenever the
// negotiation found anything in common, and this is where that goes red —
// as an object count, because a pack that gained 57 objects is not the pack
// this test goes on to compare.
assert_eq!(
asked_counts.sent, plain_counts.sent,
"the two requests selected different objects ({} thin, {} plain). If the plain \
arm is the larger, the base closure ran on a NEGOTIATED fetch: it must be off \
whenever `common` is non-empty, because an unselected base below the cut is an \
object the client already has",
asked_counts.sent, plain_counts.sent,
);
assert_eq!(
asked_counts.recompressed, 0,
"the thin arm expanded {} objects, so the comparison has no baseline",
asked_counts.recompressed
);
assert!(
plain_counts.recompressed > 0,
"withholding `thin-pack` changed nothing: {plain_counts:?}. Either the \
capability is not being honoured — in which case a client that cannot \
complete a thin pack is being sent one — or the fixture has no deltas \
crossing the cut and the green arm proves nothing"
);
assert!(
plain.len() > asked.len(),
"the whole-object pack ({} bytes) is no larger than the thin one ({} \
bytes), so `S-003` is measuring nothing",
plain.len(),
asked.len()
);
// ── self-containment, asserted in both directions ───────────────────────
//
// `index-pack` with neither `--fix-thin` nor a repository behind it can
// resolve a delta only against the pack's own objects, so it is exactly
// the question "does this pack carry every base it names". The whole-object
// pack must pass it and the thin one must **fail** it: asserting only the
// first would be satisfied by a server that never made a thin pack at all,
// which is the thing being disproved two assertions up.
//
// (`--strict` is deliberately not used here. It also verifies
// connectivity, and a fetch pack's commits name parents that live on the
// client's side of the cut, so it refuses both packs for a reason that has
// nothing to do with deltas.)
assert!(
indexes_standalone(&churn.root, &plain, "whole"),
"the pack sent to a client without `thin-pack` names a base it does not \
carry, so that client cannot read it"
);
assert!(
!indexes_standalone(&churn.root, &asked, "thin"),
"the pack sent to a client that DID ask for `thin-pack` resolves standing \
alone, so it is not thin and the byte win above came from somewhere else"
);
eprintln!(
"S-003 gate: thin={} B ({} expanded) vs whole={} B ({} expanded), {} objects",
asked.len(),
asked_counts.recompressed,
plain.len(),
plain_counts.recompressed,
asked_counts.sent
);
}
/// Can stock git index this pack with no repository and no `--fix-thin`?
///
/// That is the definition of a pack being self-contained: every delta base it
/// names is inside it. Returns the verdict rather than asserting, because both
/// answers are load-bearing in different places.
fn indexes_standalone(root: &Path, pack: &[u8], name: &str) -> bool {
let dir = root.join(format!("standalone-{name}"));
std::fs::create_dir_all(&dir).expect("a directory for the pack");
let path = dir.join("p.pack");
std::fs::write(&path, pack).expect("write the pack out");
let out = Command::new("git")
.current_dir(&dir)
.args(["index-pack", "-o", "p.idx", path.to_str().unwrap()])
.output()
.expect("run index-pack");
out.status.success()
}
/// **Both delta forms in one pack: `thin-pack` without `ofs-delta`.**
///
/// The combination is not hypothetical — `OBJ_REF_DELTA` is the older form and
/// needs no capability — and it is the one place the two naming schemes meet.
/// A thin delta must name its base by object id because the base is not in the
/// pack to have a distance to; an *internal* delta, on this client, must ALSO
/// be named by object id, and by a different route through the emitter (gix's
/// `DeltaOid` for the first, this crate's own `written_ids` table for the
/// second). A pack that got either wrong is one stock git refuses.
#[test]
fn a_thin_fetch_to_a_client_without_ofs_delta_is_readable() {
let churn = Churn::build_with(WIDE_FILES);
let cut = churn.ancestor(8);
let (pack, counts) = churn.fetch_asking_full(&[cut], true, false);
assert_eq!(
counts.recompressed, 0,
"withholding ofs-delta cost this client the thin reuse as well: {counts:?}. The two capabilities are independent — one decides how a base is NAMED, the other whether a base outside the pack may be named at all"
);
// The `OBJ_OFS_DELTA` census is deliberately NOT taken here. It needs
// `index-pack` on the pack standing alone, and a thin pack cannot be
// indexed standing alone — the base is the point. That census is taken by
// `a_client_without_ofs_delta_gets_a_pack_it_can_read`, over a clone,
// which is self-contained and where the same emitter branch decides the
// same thing. What is left to this arm is the half that one cannot see:
// that the reuse survives when both capabilities differ from the default,
// and that a real client at the cut can still complete the result.
let client = churn.client_at(cut);
let (accepted, deltas) = client_accepts(&client, &pack);
fsck(&client);
assert!(accepted >= counts.sent);
assert!(deltas > 0, "the completed pack holds no deltas at all");
}
/// **The red the whole vouching mechanism did not have.**
///
/// `client_accepts` proves a served thin pack completes against the client it
/// was vouched for. On its own that is a green nobody has seen red: a voucher
/// that said *yes* to every base would produce a pack that also passes, against
/// that client, because that client happens to hold the bases. The assertion
/// only means something if the same pack is refused by a client that does not.
///
/// So the same bytes are handed to a client at the **root commit**, which holds
/// none of the history the bases live in. `git index-pack --strict --fix-thin`
/// must fail, and must fail *by naming unresolved deltas* rather than by any
/// other unhappiness — a non-zero exit alone would also be produced by a
/// corrupt pack, a missing directory or a mistyped flag, and this test would
/// then pass while proving nothing (`LAW 2`: check output, not exit code).
///
/// # What this pins, and what the measurement behind it says
///
/// A thin pack whose bases the receiver lacks is not a smaller answer, it is a
/// **failed fetch** — this is what that failure looks like. Any future
/// widening of `Selection::client_has` has to keep the arm above green and this
/// one red, and nothing else can tell a widening from a bug.
///
/// MEASURED oden 2026-08-04, `bench/examples/fetchbytes-probe.rs`, 46 negotiated
/// fetches over the 19 real `workspace_nordisk` repositories at three
/// divergence depths, every pack judged by `index-pack --strict`,
/// `fsck --full --strict` and `rev-list --objects` set equality against the
/// server:
///
/// | voucher | thin bases offered | refused | of those, held by the client |
/// |---|---:|---:|---:|
/// | bitmap (ships) | 1 193 | 604 | **0** |
/// | walk, `Exclusion::Edges` | 1 194 | 753 | **138** |
/// | walk, `Exclusion::Full` | 1 193 | 604 | **0** |
///
/// The refusal *rate* is 50.6% and every one of those refusals is **correct**:
/// the base is genuinely not in the receiving repository. The walk's 138 extra
/// refusals are the under-approximation `copy.rs` already names, they are worth
/// 857 939 bytes corpus-wide (1.44%), and the bitmap path does not have them.
/// There is no widening left to take that would not break this test.
#[test]
fn a_thin_pack_is_refused_by_a_client_that_lacks_its_bases() {
let churn = Churn::build_with(WIDE_FILES);
let cut = churn.ancestor(8);
let (pack, counts) = churn.fetch_through_gunnar(&[cut]);
assert!(
counts.copied > 0,
"nothing was copied, so this pack carries no delta whose base could be \
missing and the refusal below would mean nothing ({counts:?})"
);
// The client the fetch was vouched for takes it. This is the green half,
// and it is here rather than borrowed from the arm above so that the two
// halves are the same bytes.
let vouched_for = churn.client_at(cut);
client_accepts(&vouched_for, &pack);
fsck(&vouched_for);
// A client at the root holds no revision any of those bases could be.
//
// ── `--fix-thin` WITHOUT `--strict`, and that is the whole test ─────────
//
// `--strict` also verifies **connectivity**, and a fetch of the last eight
// commits references the cut commit, which this client does not have. So
// `--strict` fails here on *any* pack, thin or whole, and a guard built on
// it is measuring connectivity while claiming to measure vouching.
//
// Found by running the red rather than by reading the code: the first
// version of this test used `--strict`, and re-pointing it at a
// deliberately **non-thin** pack — one with no outside base to be missing —
// left it passing. `--fix-thin` alone fails only on a delta whose base
// cannot be found, which is the one thing being asserted.
let root = churn.ancestor(COMMITS - 1);
let stranger = churn.client_at(root);
let out = {
use std::io::Write as _;
let mut child = Command::new("git")
.current_dir(&stranger)
.args(["index-pack", "--fix-thin", "--stdin", "-v"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn index-pack");
child
.stdin
.take()
.expect("index-pack stdin")
.write_all(&pack)
.expect("feed the pack");
child.wait_with_output().expect("index-pack")
};
let stderr = String::from_utf8_lossy(&out.stderr).to_ascii_lowercase();
assert!(
!out.status.success(),
"a client holding none of the bases COMPLETED the thin pack. Either this \
fixture's deltas all point inside the transfer — in which case the arm \
above is proving nothing about vouching — or `--fix-thin` resolved them \
against objects this client should not have."
);
assert!(
stderr.contains("unresolved delta"),
"index-pack failed, but not by naming an unresolved delta. A non-zero \
exit is also what a corrupt pack, a bad flag or a broken connectivity \
check produces, and this assertion has already been hollow once for \
exactly that reason:\n{stderr}"
);
}
/// Hand `pack` to **gitoxide**, resolving thin bases against `client`.
///
/// This is `gunnar_pack::index_thin_pack`, which is
/// `gix_pack::Bundle::write_to_directory` — the same call `gunnar-client`'s
/// fetch ends in and the same call `receive-pack` ends in. It is a *second*
/// judge, not a cheaper spelling of `index-pack`: the two implementations
/// disagree about one thing, and that disagreement is the defect below.
fn gitoxide_reads(client: &Path, pack: &[u8]) -> Result<usize, String> {
let store = FsStore::open_or_init(client, Kind::Sha1).expect("open the client");
let objects = store.objects();
let bases = gunnar_store::gix_adapter::GixObjects::new(&objects);
let mut reader = std::io::BufReader::new(pack);
match gunnar_pack::index_thin_pack(
&mut reader,
None,
bases,
&gunnar_pack::IndexOptions::new(Kind::Sha1),
None,
) {
Ok(indexed) => Ok(indexed.num_objects as usize),
Err(err) => {
// `Error::repo`'s lesson, one crate over: the top line here is
// "failed to index the pack" and the object id that makes the
// failure diagnosable is two `#[source]`s down.
let mut chain = format!("{err}");
let mut src: Option<&dyn std::error::Error> = std::error::Error::source(&err);
while let Some(s) = src {
chain.push_str(&format!(": {s}"));
src = s.source();
}
Err(chain)
}
}
}
/// **A thin pack may only name a base the receiver already HOLDS.**
///
/// `Selection::client_has` used to vouch for the objects *being sent* as well,
/// on the argument that "naming a base inside the same pack is an
/// `OBJ_REF_DELTA` against an object in the same pack, which stock git
/// accepts". Stock git does accept it. **gitoxide does not**, and gitoxide is
/// what `gunnar-client` — and every other gitoxide-based client — resolves a
/// fetch with:
///
/// `gix_pack::data::input::LookupRefDeltaObjectsIter` treats every
/// `OBJ_REF_DELTA` in a thin pack as naming an object the *receiver* holds. It
/// looks the base up in the local object database and in the bases it has
/// already spliced in, and **nowhere else** — never inside the pack it is
/// reading. A base that is in the transfer but has not been spliced in is
/// `input::Error::NotFound`, and the fetch dies with
/// `The object <base> could not be decoded or wasn't found`.
///
/// So the two judges disagree, and a guard that asks only stock git cannot see
/// it. Reproduced first outside this suite, on a `gunnar.year` run: server
/// tree `1d062a8c` was written into the pack as a delta on a tree the client
/// held, the next entry was a delta on `1d062a8c` — and gitoxide refused the
/// whole pack while `git index-pack --strict --fix-thin` completed it.
///
/// Both judges are kept, and they are not redundant: stock git catches a wrong
/// base *distance* that gitoxide's splice would paper over, and gitoxide
/// catches this.
#[test]
fn a_thin_pack_names_no_base_the_receiver_does_not_already_hold() {
let churn = Churn::build_pushed(WIDE_FILES, PUSH_ROUNDS);
let cut = churn.ancestor(8);
let (pack, counts) = churn.fetch_through_gunnar(&[cut]);
assert!(
counts.copied > 0,
"nothing was copied, so this pack carries no delta whose base could be \
named at all ({counts:?})"
);
let client = churn.client_at(cut);
// **gitoxide first, and the order is load-bearing.** `client_accepts` runs
// `index-pack --fix-thin` *into* the client, so after it the client holds
// every object of the transfer and no thin base can be missing any more.
// Seen for real: with the two calls the other way round this arm passed on
// a pack `gunnar.year` was dying on. `index_thin_pack` with `None` for the
// directory writes nothing, so it leaves the client as it found it.
let gix_verdict = gitoxide_reads(&client, &pack);
// Stock git's verdict, kept as the control: it passed before this defect
// was fixed and it must still pass after, or the fix traded one receiver
// for the other.
client_accepts(&client, &pack);
fsck(&client);
match gix_verdict {
Ok(n) => assert!(
n as u32 >= counts.sent,
"gitoxide indexed {n} object(s) out of a pack that carried {} \
({counts:?})",
counts.sent
),
Err(chain) => panic!(
"the client the fetch was VOUCHED FOR cannot read the pack with \
gitoxide, while stock git completed the same bytes.\n{chain}\n\
A thin base is a promise about what the RECEIVER holds. An object \
this transfer is carrying is not held until the pack has been \
resolved, and gitoxide resolves thin bases before it resolves the \
pack."
),
}
}
/// How many pushes arrive after the `gc` in
/// [`the_pack_copy_guards_hold_after_gunnars_own_gc`].
///
/// # The floor, swept 2026-08-05 on oden
///
/// Not a taste. `sweep_gcd_after` runs the whole arm at 1, 2, 4 and 8 post-gc
/// pushes with [`GCD_ROUNDS`] before the fold, both judges on the cross-fold
/// fetch. MEASURED:
///
/// | after | packs | clone copied, pre-fold → post-fold | fetch copied | gitoxide |
/// |------:|------:|-----------------------------------:|-------------:|:---------|
/// | 1 | 2 | 138/220 → 220/220 | 27 of 40 | accepts |
/// | 2 | 3 | 125/205 → 205/205 | 17 of 40 | accepts |
/// | 4 | 5 | 114/180 → 180/180 | 12 of 40 | accepts |
/// | 8 | 9 | 101/140 → 140/140 | 12 of 40 | accepts |
///
/// Every value comes out clean, and every value puts objects in more than one
/// pack, which is the condition the arm exists for. What the sweep rules out is
/// the value **0**, a folded repository nobody has pushed to since: one pack,
/// nothing cross-pack, and an arm that would be [`Layout::OnePack`] under
/// another name.
///
/// 4 is kept because it is the smallest value that leaves more than one *new*
/// pack beside the consolidated one, so a delta whose base landed in a
/// different post-gc push is reachable and not only one reaching back into the
/// fold — and because the copied fraction of the cross-fold fetch has already
/// flattened by then (12 of 40 at both 4 and 8), so the larger sizes buy
/// nothing but time.
const GCD_AFTER: usize = 4;
/// Pushes before the `gc` in [`Churn::build_gcd`].
///
/// [`PUSH_ROUNDS`]'s twelve, so the pre-fold half of this fixture is the same
/// production shape every other pushed arm stands on and the only variable
/// against them is the `gc`.
const GCD_ROUNDS: usize = PUSH_ROUNDS;
/// **The pack-copy guards, re-run after gunnar's own `gc`.** They had only ever
/// run after a push.
///
/// Both 2026-08-05 sweeps named `gc` under load as unswept, and this is the
/// half of it that is not about concurrency. Three of that day's wrong answers
/// were about *which pack holds which object* — a base closure keying
/// membership by `(pack_id, pack_offset)` when a pack holds an object once, and
/// a multi-pack index giving one pack's object list holes so `PackExtents`
/// measured an entry across the hole. **A gc changes exactly that**, and no arm
/// in this file had ever served a repository gunnar had folded:
/// [`Layout::OnePack`] is stock `git repack -adf`, git's writer, not gunnar's.
///
/// # The comparison is within one repository, and it has to be
///
/// The first version of this test asserted `copied == sent` on a post-gc
/// fixture and failed at 197 of 240 — and that was the **test** being wrong,
/// not the code. MEASURED across four layouts on the same run: one pack copies
/// 240/240, twelve pushed packs copy 158/240, sixteen copy 155/240, and this
/// layout's five copy 197/240. A copy fraction below 1 is what a multi-pack
/// repository *is*; the fold moves it toward 1. So a floor on the fraction
/// would have been a number picked off one run, and a comparison against a
/// separately built pushed fixture would have compared two different object
/// graphs — the commits carry real timestamps, so every build has different ids
/// and git's delta window makes different choices.
///
/// What is asserted instead is the **same object set, before and after the same
/// fold**: build the fixture partly, clone it, fold it, clone it again. The
/// object set is identical by construction, so the two counts are comparable,
/// and the claim — a fold does not make copy worse — is one no run-to-run noise
/// can fake.
///
/// # Result: CLEAN, and what that is worth
///
/// Stated plainly (LAW 7). Copy survives the fold and improves across it, both
/// judges accept both packs, and nothing regressed. This arm found no defect;
/// what it removes is the possibility that one was there unlooked-at, and it is
/// the guard that fires if a future change to the fold breaks copy for everyone
/// who has ever run `gunnar gc`.
///
/// # Seen red
///
/// Three ways, each naming its own subject:
///
/// 1. **the fold removed** — `after.copied == after.sent` fires at 114 of 180,
/// which is the pre-fold number, proving the second clone is measured after
/// the fold and not before it.
/// 2. **`clone_through_gunnar(.., false)` for the post-fold arm** — the "not
/// worse" assertion fires with 0 copied against 114, proving both counts come
/// from the transfers named here and not from the fixture's own history.
/// 3. **`push_more` not re-reading `HEAD`** — the post-push half then serves
/// the pre-push tip, the negotiation cut is already the tip, and
/// `fetch_asking_full`'s own control fires with *"the negotiation left
/// nothing to send"*. That is why `Churn::head` is a `Cell`: a plain field
/// would leave a post-push fixture serving a history it has moved past.
#[test]
fn the_pack_copy_guards_hold_after_gunnars_own_gc() {
// ── the same object set, before and after one fold ──────────────────────
let churn = Churn::build_partial(WIDE_FILES, GCD_ROUNDS, GCD_ROUNDS + GCD_AFTER);
let (before_pack, before) = churn.clone_through_gunnar(true, true);
let folded = churn.gunnar_gc();
assert!(
folded.rewrote,
"gunnar's gc found nothing to do on a {GCD_ROUNDS}-pack fixture, so nothing below is measuring a fold: {folded:?}"
);
assert_eq!(
pack_count(&churn.origin),
1,
"gunnar's gc left {} packs: {folded:?}",
pack_count(&churn.origin)
);
let (after_pack, after) = churn.clone_through_gunnar(true, true);
assert_eq!(
after.sent, before.sent,
"the fold changed how many objects a clone of the same history sends, {} to {}. That is not a copy question, it is a lost or duplicated object",
before.sent, after.sent
);
assert!(
after.copied >= before.copied,
"gunnar's own gc made pack copy WORSE on the same object set: {} of {} copied before the fold, {} after. before={before:?} after={after:?}",
before.copied,
before.sent,
after.copied
);
assert_eq!(
after.copied, after.sent as usize,
"a clone of a repository folded into ONE pack copied {} of {} objects. With a single pack there is no cross-pack base and nothing left to decline: {after:?}",
after.copied, after.sent
);
// Both packs are real packs stock git will take, and both still carry
// deltas — a fold that flattened every chain would satisfy every count
// above while sending far more bytes.
for (label, pack, counts) in [
("pre-gc", &before_pack, &before),
("post-gc", &after_pack, &after),
] {
assert_eq!(
index_and_count(pack, Kind::Sha1),
counts.sent,
"stock git indexed a different number of objects than the {label} clone sent"
);
let read = stock_git_reads(&churn.root, pack, &format!("gcd-{label}"));
assert!(
read.deltas > 0,
"`git verify-pack` resolved no delta chain in the {label} clone, so its size is not coming from delta reuse: {read:?}"
);
}
// ── then the pushes that arrive after the gc ────────────────────────────
//
// A folded repository nobody has pushed to since is the easy shape. This is
// the one an operator actually has: the consolidated pack, `GCD_AFTER` new
// ones beside it, and a `.bitmap` that is stale for everything after the
// fold.
assert!(
has_bitmap(&churn.origin),
"gunnar's gc wrote no .bitmap, so the stale-bitmap-plus-new-packs state this half exists for is not present"
);
churn.push_more(GCD_ROUNDS, GCD_ROUNDS + GCD_AFTER, GCD_ROUNDS + GCD_AFTER);
assert_eq!(
pack_count(&churn.origin),
1 + GCD_AFTER,
"the post-gc pushes did not each land their own pack, so the consolidated pack is not sitting alongside anything and no object can be in two packs"
);
// A negotiated fetch, because it is selection that produces a delta whose
// base is not in the transfer. The cut is on the far side of the fold, so
// the transfer spans the consolidated pack AND the new ones — the
// `(pack_id, pack_offset)` condition, which a cut inside one pack cannot
// produce.
let cut = churn.ancestor(8);
let (fetch_pack, fetched) = churn.fetch_through_gunnar(&[cut]);
assert!(
fetched.copied > 0,
"a negotiated fetch across the fold copied nothing, so it carries no delta whose base could be misnamed and this half is vacuous ({fetched:?})"
);
// Both judges. Stock git 2.53.0 is the judge on anything on the wire, and
// gitoxide's `index_thin_pack` is the second because the two disagree: git
// accepts a thin base that gitoxide refuses. See
// `a_thin_pack_names_no_base_the_receiver_does_not_already_hold`.
let client = churn.client_at(cut);
// gitoxide first: `client_accepts` runs `index-pack --fix-thin` into the
// client, after which no thin base can be missing any more.
let gix = gitoxide_reads(&client, &fetch_pack);
client_accepts(&client, &fetch_pack);
fsck(&client);
match gix {
Ok(n) => assert!(
n as u32 >= fetched.sent,
"gitoxide indexed {n} of the {} objects a post-gc fetch carried ({fetched:?})",
fetched.sent
),
Err(chain) => panic!(
"gitoxide cannot read a pack served across gunnar's own fold, while stock git completed the same bytes.\n{chain}"
),
}
eprintln!(
"post-gc: clone copied {}/{} before the fold and {}/{} after; \
cross-fold fetch {} objects, {} copied",
before.copied, before.sent, after.copied, after.sent, fetched.sent, fetched.copied
);
}
/// Stock git's verdict on the emitted pack: the index it writes, or the reason
/// it refused.
///
/// `index-pack --strict` is what `transfer.fsckObjects=true` runs on a pack a
/// client receives, and it is the judge that **refuses a pack carrying one
/// object twice**. gitoxide does not; see [`gitoxide_reads_standalone`]. The
/// two are kept side by side because each sees something the other does not,
/// and this is the direction that cost `gunnar.year` a clone.
fn stock_git_reads_standalone(dir: &Path, pack: &[u8]) -> Result<PathBuf, String> {
std::fs::create_dir_all(dir).expect("a directory for the pack");
let path = dir.join("p.pack");
std::fs::write(&path, pack).expect("write the pack out");
let out = Command::new("git")
.current_dir(dir)
.args([
"index-pack",
"--strict",
"-o",
"p.idx",
path.to_str().unwrap(),
])
.output()
.expect("run index-pack");
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_owned());
}
Ok(dir.join("p.idx"))
}
/// Hand a **self-contained** pack to gitoxide, the second judge, and keep the
/// index it writes.
///
/// `index_pack` rather than `index_thin_pack`: a clone's pack names no base it
/// does not carry, so there is no receiver to resolve against and a failure
/// here is a failure of the bytes.
///
/// The index is written rather than discarded because gitoxide **accepts** a
/// pack that carries an object twice — MEASURED, it returned `Ok(241)` for a
/// 241-entry pack of 240 objects — so its index is the one listing of the
/// pack's entries that survives the defect and can be counted. See
/// [`census_of`].
fn gitoxide_reads_standalone(dir: &Path, pack: &[u8], kind: Kind) -> Result<u32, String> {
std::fs::create_dir_all(dir).expect("a directory for the index");
let mut cursor = std::io::Cursor::new(pack);
gunnar_pack::index_pack(
&mut cursor,
Some(dir),
&gunnar_pack::IndexOptions::new(kind),
None,
)
.map(|indexed| indexed.num_objects)
.map_err(|err| {
let mut chain = format!("{err}");
let mut src: Option<&dyn std::error::Error> = std::error::Error::source(&err);
while let Some(s) = src {
chain.push_str(&format!(": {s}"));
src = s.source();
}
chain
})
}
/// Every object id a **v2 pack index** names, in index order.
///
/// # Why this parses the format rather than shelling out
///
/// Because `git verify-pack -v` is the obvious way to list an index's entries
/// and it **cannot see the defect**: MEASURED oden 2026-08-05, handed an index
/// carrying one object twice it prints nothing and exits non-zero with
/// `fatal: The same object <id> appears twice in the pack`. So does `git
/// index-pack`. git refuses a duplicate at every entrance, which is right of
/// git and useless as an instrument — a census taken through it is an
/// assertion that can never fail, and this arm had one for an afternoon.
///
/// The v2 layout is fixed and small: the magic `\xfftOc`, the version, a
/// 256-entry fanout whose last cell is the object count, and then that many
/// raw hashes. Nothing here decides what an object is; it reads the count and
/// the names out of the one file that lists them.
fn index_object_ids(idx: &Path, kind: Kind) -> Vec<String> {
let bytes = std::fs::read(idx).expect("read the pack index");
assert!(
bytes.starts_with(&[0xff, b't', b'O', b'c']),
"{idx:?} is not a v2 pack index"
);
assert_eq!(
u32::from_be_bytes(bytes[4..8].try_into().expect("the version")),
2,
"{idx:?} is not version 2"
);
let fanout_end = 8 + 256 * 4;
let count = u32::from_be_bytes(
bytes[fanout_end - 4..fanout_end]
.try_into()
.expect("the last fanout cell"),
) as usize;
let width = kind.len_in_bytes();
let names = &bytes[fanout_end..fanout_end + count * width];
names
.chunks_exact(width)
.map(|id| id.iter().map(|b| format!("{b:02x}")).collect())
.collect()
}
/// The one index file in `dir`.
fn sole_index(dir: &Path) -> PathBuf {
let mut found: Vec<PathBuf> = std::fs::read_dir(dir)
.expect("an index directory")
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "idx"))
.collect();
assert_eq!(found.len(), 1, "expected exactly one .idx in {dir:?}");
found.pop().expect("one index")
}
/// The object count the pack's own header declares, read out of the bytes.
///
/// The header is written before the first entry, from a count decided before
/// the generator runs. Comparing it against the entries stock git actually
/// found is what catches a writer that emitted a different number of objects
/// than it promised.
fn pack_header_objects(pack: &[u8]) -> u32 {
assert!(pack.starts_with(b"PACK"), "not a packfile");
u32::from_be_bytes(pack[8..12].try_into().expect("a pack header"))
}
/// Files in the fixture for [`a_clone_of_a_multi_pack_store_carries_no_object_twice`],
/// against [`WIDE_FILES`]'s sixty.
///
/// Sixteen, because the condition is a **property of git's delta search** and
/// not something the fixture can state directly: it needs one blob to be the
/// base of a delta *inside an early pack* and to be spliced into a later one as
/// well, which happens when a file is revised more than once inside a single
/// push round. At sixty files and forty-eight commits a file is touched about
/// every twenty commits, so it almost never is; at sixteen it is.
///
/// MEASURED oden 2026-08-05 at these sixteen files, the same clone at
/// `origin/master` (`8f79cd7`, the defect present) against this commit, and the
/// closure's own `closure_bases_declined`:
///
/// ```text
/// packs 2 3 4 5 6 8 12 15
/// before ok ok ok FAIL FAIL FAIL FAIL FAIL
/// declined 0 0 0 4 8 5 3 7
/// ```
///
/// So **five packs is the floor** and [`PUSH_ROUNDS`]'s twelve is well inside
/// it. Four is not a smaller version of the defect, it is the absence of it,
/// and a fixture built at four packs would be a guard that could never fail.
///
/// # Fifteen was the ceiling, and the reason for it is closed
///
/// At **sixteen** packs `FsStore::open_or_init` writes a multi-pack index
/// (`gunnar_store::midx::MULTI_PACK_INDEX_THRESHOLD`), and from there
/// `write_pack_copy` emitted a pack **stock git and gitoxide both refused** —
/// `fatal: pack is corrupted (SHA1 mismatch)` — with `GUNNAR_BASE_CLOSURE=off`
/// as well as on, and with `NoPackCopy` clean on the same fixture. That was a
/// separate defect of the copy path under a multi-pack index and this arm's
/// fixture stayed below it so the two could not be confused for one another.
///
/// It was `P-025`'s entry-length table sizing an entry across an object the
/// index had attributed to the other pack, fixed in `0d10e17`. The ceiling is
/// gone and **it is now a case rather than a boundary**:
/// [`a_clone_of_a_store_with_a_multi_pack_index_is_not_corrupt`] is this same
/// fixture at `MULTI_PACK_INDEX_THRESHOLD` packs, where the index appears by
/// itself. This arm still sits at [`PUSH_ROUNDS`] so that it keeps measuring the
/// closure with the extent table **live** on every pack — above the threshold
/// the short-list packs decline their table and `closure_bases_declined` falls
/// to 0, which would make the canary below unreachable.
const DUP_FILES: usize = 16;
/// **A pack carries every object once, and a multi-pack store is where that
/// stops being automatic.**
///
/// `WriteOptions::base_closure` names a delta's unselected base and adds it to
/// the pack rather than exploding the delta. "Unselected" was decided by pack
/// **offset**, which is the right question for `Entry::from_pack_entry` and the
/// wrong one for the pack: on a multi-pack store one object id has two
/// locations, `location_by_oid` answers with one of them, and a delta in the
/// other pack names an offset the selection does not hold whose *object* it
/// does. The base was then added to a pack that already carried it, and stock
/// git refuses such a pack outright:
///
/// ```text
/// fatal: The same object 00d6fd78e6c2b91b311f54f5401b6ca5eb635f33 appears twice in the pack
/// fatal: fetch-pack: invalid index-pack output
/// ```
///
/// That is where `gunnar.year` died at week 12, on the quarterly clone.
///
/// # Why every existing arm was green while it happened
///
/// Because every fixture in this tree was built by `git clone --bare`, which
/// **repacks**. A repacked store holds each object once, one object has one
/// location, and the two questions above are the same question. The shape is
/// [`Churn::build_pushed`]'s — one pack per push, no `gc` — which is also the
/// production shape, and [`DUP_FILES`] carries the sweep that says how many
/// packs it takes.
///
/// # What is asserted, and why the count is not enough on its own
///
/// The **entries** of the emitted pack, from stock git's own `verify-pack -v`,
/// not the `.idx` and not a count gunnar reports about itself. A duplicated
/// object is two entries with one id, and it is invisible to `objects_sent`,
/// to `bytes_written` and to the pack's own header — all three were correct
/// while the pack was unreadable.
///
/// `closure_bases_declined` is the **canary**, and it is not decoration: it is
/// the number of times the closure was offered this exact case and turned it
/// down. Without it a fixture that drifted out of the shape would leave every
/// assertion below trivially true.
#[test]
fn a_clone_of_a_multi_pack_store_carries_no_object_twice() {
let churn = Churn::build_pushed(DUP_FILES, PUSH_ROUNDS);
let (pack, counts) = churn.clone_through_gunnar(true, true);
assert!(
counts.copied > 0,
"nothing was copied out of a pack, so no delta base was named at all \
and there is no closure to judge ({counts:?})"
);
let header = pack_header_objects(&pack);
// **gitoxide first, and it is not the judge that catches this.** It indexes
// a pack carrying an object twice without complaint, which is exactly what
// makes its index usable as a census: it is a listing of the pack's
// ENTRIES that survives the defect. Stock git's would not exist to read.
let gix_dir = churn.root.join("gix-index");
let gix = gitoxide_reads_standalone(&gix_dir, &pack, Kind::Sha1);
let ids = match &gix {
Ok(_) => index_object_ids(&sole_index(&gix_dir), Kind::Sha1),
Err(chain) => panic!(
"gitoxide could not index the clone pack:\n{chain}\n\
the header declares {header} objects and the server reported \
{counts:?}"
),
};
let mut seen = std::collections::HashSet::new();
let duplicated: Vec<&String> = ids.iter().filter(|id| !seen.insert(*id)).collect();
assert!(
duplicated.is_empty(),
"the clone pack carries {} object(s) twice — {duplicated:?} — out of \
{} entries for {} distinct objects. An added delta base must never be \
an object the pack already holds, by any route, including one reached \
through a different pack than the walk selected from",
duplicated.len(),
ids.len(),
seen.len(),
);
assert_eq!(
ids.len(),
header as usize,
"the pack's header promised {header} objects and its index lists {} \
entries",
ids.len()
);
assert_eq!(
gix.as_ref().ok().copied(),
Some(header),
"gitoxide's object count for the same bytes is {gix:?}, against a \
header of {header}"
);
// Stock git, which is the receiver that refuses a duplicate and the one a
// customer runs. It is asked after the census so that a duplicated object
// is named by gunnar's own assertion first and confirmed by git's second,
// rather than only appearing as somebody else's error string.
let stock_dir = churn.root.join("stock-index");
let stock_idx = match stock_git_reads_standalone(&stock_dir, &pack) {
Ok(idx) => idx,
Err(stderr) => panic!(
"stock git refused the clone pack:\n{stderr}\n\
gitoxide accepted the same bytes ({gix:?}); the header declares \
{header} objects and the server reported {counts:?}"
),
};
assert_eq!(
index_object_ids(&stock_idx, Kind::Sha1).len(),
header as usize,
"stock git's index of the pack names a different number of objects \
than the pack's header declares"
);
// **The canary, and it runs last on purpose.** With the decline removed the
// census above goes red first and names the duplicated object, which is the
// diagnosis a reader wants. Reaching here means the pack was clean, and a
// clean pack proves something only if the closure was offered the case at
// all.
assert!(
counts.declined > 0,
"the closure was never offered a base the pack already carried, so this \
arm asserted nothing ({counts:?}). Either the fixture drifted out of \
the shape — see `DUP_FILES` for the pack-count sweep — or the decline \
it counts is gone"
);
// Every object the clone asked for arrived. A pack that dropped one is
// duplicate-free for a reason nobody wants.
let want: std::collections::HashSet<String> = git(
&churn.origin,
&["rev-list", "--objects", &churn.head.get().to_string()],
)
.lines()
.filter_map(|line| line.split_whitespace().next().map(str::to_owned))
.collect();
for id in &want {
assert!(seen.contains(id), "the clone pack is missing {id}");
}
}
/// The parameter sweep behind [`GCD_AFTER`], run on demand.
///
/// `#[ignore]`d because it is a measurement rather than a guard, but it still
/// asserts the two things its own table rests on — that the fold makes the
/// clone wholly copyable and that each post-gc push lands its own pack — so it
/// cannot print nonsense and be believed.
#[test]
#[ignore = "a measurement, not a guard; run explicitly with --ignored"]
fn sweep_gcd_after() {
for after in [1usize, 2, 4, 8] {
let churn = Churn::build_partial(WIDE_FILES, GCD_ROUNDS, GCD_ROUNDS + after);
let (_, before) = churn.clone_through_gunnar(true, true);
churn.gunnar_gc();
let (_, folded) = churn.clone_through_gunnar(true, true);
churn.push_more(GCD_ROUNDS, GCD_ROUNDS + after, GCD_ROUNDS + after);
let packs = pack_count(&churn.origin);
let cut = churn.ancestor(8);
let (pack, fetched) = churn.fetch_through_gunnar(&[cut]);
let client = churn.client_at(cut);
let gix = gitoxide_reads(&client, &pack).is_ok();
client_accepts(&client, &pack);
eprintln!(
"after={after} packs={packs} clone {}/{} -> {}/{} fetch sent={} copied={} gix_ok={gix}",
before.copied, before.sent, folded.copied, folded.sent, fetched.sent, fetched.copied
);
assert_eq!(folded.copied, folded.sent as usize);
assert_eq!(packs, 1 + after);
}
}
/// **A whole clone off a store that grew a multi-pack index by itself, judged
/// by both receivers.**
///
/// The corpus shape this repository had no arm for, and the absence is why the
/// defect below lived from `fc2e0c3` (2026-08-04 20:54) to `0d10e17`
/// (2026-08-05 11:47). Every fixture that reached sixteen packs was either a
/// `git clone --bare` carve — which repacks, so one pack — or
/// `nornir-bench`'s `packed_fixture`, which writes `ids.chunks(per)` into
/// disjoint packs and never serves any of them. Disjoint packs and lookups
/// cannot express the defect: it needs one object in **two** packs and a
/// receiver looking at the served bytes.
///
/// # What it guards
///
/// `PackExtents` (`P-025`) sizes an entry as the distance to the next offset in
/// the same pack, which is exact only if it holds every offset that pack has. A
/// multi-pack index keeps one **deduplicated** oid table, so an object living in
/// two packs is recorded against exactly one of them and `gix-odb` builds the
/// other pack's list by filtering that table by pack — short by precisely the
/// objects the index attributed elsewhere. The entry before each hole is then
/// measured across it, the copied entry carries its neighbour's bytes as a tail,
/// and the receiver parses every entry the header promised and reads its trailer
/// from the wrong offset.
///
/// # Why this pack count and not a bigger round number
///
/// [`MULTI_PACK_INDEX_THRESHOLD`] exactly, because the index has to appear
/// **unasked** — nothing here writes one, and a fixture that called
/// `write_multi_pack_index` itself would guard the writer instead of the store's
/// own policy. Reading the constant rather than spelling `16` is what makes the
/// arm follow the policy if it moves.
///
/// Pack count is not the trigger and this arm does not claim it is. MEASURED
/// oden 2026-08-06 on this fixture, `write_pack_copy` with the completeness
/// check removed, stock git 2.53.0 as the receiver:
///
/// ```text
/// threshold 16 packs 2 ok · 4 ok · 8 ok · 13 ok · 15 ok · 16 FAIL · 17 FAIL · 20 FAIL
/// threshold 8 packs 2 ok · 4 ok · 8 FAIL · 12 FAIL · 16 FAIL
/// threshold 2 packs 2 FAIL · 4 FAIL
/// ```
///
/// The cliff moves with the constant, to the pack. **Two packs is the floor** —
/// the fewest a multi-pack index can span — and the threshold only decides when
/// a store starts having the index that exposes it. The fixture already holds
/// 16 objects in two packs at two packs, so the duplicate is not what the cliff
/// tracks either. `crates/gunnar-pack/tests/pack_extents.rs` carries the
/// two-pack case with a hand-written index; this one carries the production
/// shape, where nobody asked for the index at all.
///
/// # Seen red
///
/// All three of its own conditions, oden 2026-08-06:
///
/// * with the completeness check removed from `PackedObjects::learn_pack`, on
/// stock git's own words — `fatal: pack is corrupted (SHA1 mismatch)`, 201 501
/// bytes served against 194 782 accepted on a green run, ≈6.7 kB of surplus.
/// The two figures are two builds of this fixture, which carries real
/// timestamps, so they are not the same repository to the byte;
/// `gunnar.multi_pack_serve` does the A/B inside one open store and measured
/// **+16 137** exactly, at sha256;
/// * with the fixture at 4 packs — *"opening a 16-pack store wrote no
/// multi-pack-index"*;
/// * with the fixture repacked to one pack — *"no object of the 240 in this
/// fixture lives in more than one pack"*.
///
/// The shape assertions stay green under the first, so that failure names the
/// defect and not the fixture.
#[test]
fn a_clone_of_a_store_with_a_multi_pack_index_is_not_corrupt() {
let churn = Churn::build_pushed(DUP_FILES, MULTI_PACK_INDEX_THRESHOLD);
// ── the shape, stated as numbers before gunnar opens anything ──────────
//
// Neither of these is a formality. Without an object in two packs the index
// deduplicates nothing, no entry list has a hole, and every assertion below
// is trivially true on a fixture that cannot fail.
let (distinct, duplicated) = objects_by_pack_residency(&churn.origin);
assert!(
duplicated > 0,
"no object of the {distinct} in this fixture lives in more than one \
pack, so a multi-pack index has nothing to choose between and this arm \
cannot see the defect it exists for"
);
assert!(
!churn.origin.join("objects/pack/multi-pack-index").is_file(),
"the fixture already had a multi-pack index before gunnar opened it, so \
this arm would not be watching the store's own policy write one"
);
let (pack, counts) = churn.clone_through_gunnar(true, true);
// The index appeared by itself, during the open inside the serve. If it did
// not, everything below is the single-pack path measured again.
assert!(
churn.origin.join("objects/pack/multi-pack-index").is_file(),
"opening a {MULTI_PACK_INDEX_THRESHOLD}-pack store wrote no \
multi-pack-index, so this arm served the configuration it is not about"
);
assert!(
counts.copied > 0,
"nothing was copied out of a pack, so `write_pack_copy` never ran the \
path this arm guards ({counts:?})"
);
// ── stock git, the receiver that names the defect ──────────────────────
let stock_dir = churn.root.join("midx-stock");
let stock_idx = match stock_git_reads_standalone(&stock_dir, &pack) {
Ok(idx) => idx,
Err(stderr) => panic!(
"stock git refused a clone served off a {MULTI_PACK_INDEX_THRESHOLD}-pack \
store with a multi-pack index over it:\n{stderr}\n\
{duplicated} of {distinct} objects live in more than one pack, the \
pack's header declares {} objects, it is {} bytes long and the \
server reported {counts:?}",
pack_header_objects(&pack),
pack.len()
),
};
// ── gitoxide, the second decoder ───────────────────────────────────────
//
// The two refuse this defect with different words — `pack is corrupted
// (SHA1 mismatch)` against a failed index — so a guard that asked only one
// would be a guard against one implementation's parser.
let gix_dir = churn.root.join("midx-gix");
let gix = gitoxide_reads_standalone(&gix_dir, &pack, Kind::Sha1)
.unwrap_or_else(|chain| panic!("gitoxide refused the same clone pack:\n{chain}"));
// The header, both indices and the server's own count are one number, or
// some entry went somewhere nobody is looking.
let header = pack_header_objects(&pack);
assert_eq!(
index_object_ids(&stock_idx, Kind::Sha1).len(),
header as usize,
"stock git indexed a different number of objects than the pack's header \
declares"
);
assert_eq!(gix, header, "gitoxide's count for the same bytes differs");
assert_eq!(
counts.sent, header,
"the server reported sending {} objects into a pack whose header says \
{header}",
counts.sent
);
}
/// `(distinct objects, how many of them live in more than one pack)`, read out
/// of the `.idx` files themselves.
///
/// First-party for the reason [`index_object_ids`] gives: `git verify-pack` and
/// `git index-pack` both refuse a duplicate at the door and print nothing, so a
/// census taken through either is an assertion that can never fail. This one
/// reads the v2 index format directly, which is also the only listing that
/// survives the defect it is measuring.
fn objects_by_pack_residency(origin: &Path) -> (usize, usize) {
let mut per_id: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut indices: Vec<PathBuf> = std::fs::read_dir(origin.join("objects/pack"))
.expect("the fixture's pack directory")
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "idx"))
.collect();
indices.sort();
for idx in &indices {
for id in index_object_ids(idx, Kind::Sha1) {
*per_id.entry(id).or_default() += 1;
}
}
let duplicated = per_id.values().filter(|count| **count > 1).count();
(per_id.len(), duplicated)
}