crates/gunnar-pack/tests/pack_extents.rs
//! **`P-025`: an entry's length comes from the pack index, not from inflating
//! the entry.**
//!
//! `iter_from_counts` resolves every count through
//! `gix_pack::Find::location_by_oid` before it produces one entry, and
//! `gix-odb`'s implementation of that method inflates the whole object — into a
//! buffer it zeroes first — and keeps nothing but how many compressed bytes the
//! inflate consumed. On the pack-**copy** path those inflated bytes are never
//! read: the entry goes onto the wire still compressed. MEASURED oden
//! 2026-08-04, `perf -F 4999` over a whole-repository clone of `znippy.git`:
//! `zlib_rs::inflate*` held **51.3%** of the profile.
//!
//! `PackedObjects` now answers that question out of the `.idx` instead — an
//! entry ends where the next one begins, which is what git's own
//! `pack-revindex.c` exists for and how `builtin/pack-objects.c`'s
//! `write_reuse_object` sizes a reused entry.
//!
//! # Why this file is shaped the way it is
//!
//! **The change is defined to produce identical bytes.** So a test that only
//! compared packs would pass just as happily with the whole table disabled,
//! never built, or silently refusing every pack — which is exactly the shape of
//! blind guard this repository has found roughly twenty of. Every case here
//! therefore asserts **both** halves:
//!
//! 1. the bytes are identical to the arm that inflates, and
//! 2. `gunnar_pack::extent_lookups()` says the index answered ~every object,
//! which is the only observable that separates the two arms at all.
//!
//! `GUNNAR_PACK_EXTENTS=off` is the A/B switch and is what makes (1) a
//! comparison against a *different code path* rather than against itself.
//!
//! **A missing `git` is a failure, not a skip** (`S-026`).
use gix_hash::{Kind, ObjectId};
use gunnar_pack::{extent_lookups, write_pack_copy, PackedObjects, WriteOptions};
mod common;
use common::{churned_repo, git_available, packed_objects};
/// `GUNNAR_PACK_EXTENTS` is process-wide and `cargo test` runs cases on threads
/// of one process, so every case that touches it takes this first.
///
/// Not a nicety: without it the disabled arm of one case is read by another
/// case as its enabled arm, and the failure is a flake that looks like a real
/// difference in the bytes.
static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// One served pack, and how it was sized.
struct Served {
bytes: Vec<u8>,
/// Locations answered out of the pack index.
hits: u64,
/// Locations that fell through to `gix-odb`'s inflate.
misses: u64,
/// Entries gix could not copy and deflated afresh. Zero on a repacked
/// single-pack fixture; **not** zero once a second, overlapping pack is
/// added, because a copy whose base landed in the other pack is downgraded.
recompressed: usize,
}
/// Serve `ids` out of `repo` and hand back the pack bytes plus how many
/// locations the extent table answered *during this call*.
///
/// The counters are process-wide and monotonic, so the delta is taken here
/// rather than read absolutely: `cargo test` runs cases on threads of one
/// process and an absolute reading would be every case's work at once. This is
/// the same mistake a counting `#[global_allocator]` in this repository made
/// before.
fn serve(repo: &std::path::Path, ids: &[ObjectId], hash: Kind) -> Served {
let before = extent_lookups();
let packed = packed_objects(repo);
let options = WriteOptions::new(hash);
let mut bytes = Vec::new();
let outcome =
write_pack_copy(ids, &packed, &mut bytes, &options).expect("writing the copied pack");
let after = extent_lookups();
Served {
bytes,
hits: after.0 - before.0,
misses: after.1 - before.1,
recompressed: outcome.objects_recompressed,
}
}
/// Both arms of the switch, on one repository, in one process.
///
/// `GUNNAR_PACK_EXTENTS` is read per call rather than cached, which is what
/// lets one test flip it — and the reason the switch is an environment variable
/// and not a build feature.
fn both_arms(repo: &std::path::Path, ids: &[ObjectId], hash: Kind) -> (Served, Served) {
// Both arms run inside the caller's `ENV` guard, so no other case can read
// the variable while it is flipped.
std::env::set_var("GUNNAR_PACK_EXTENTS", "off");
let inflating = serve(repo, ids, hash);
std::env::remove_var("GUNNAR_PACK_EXTENTS");
let indexed = serve(repo, ids, hash);
(inflating, indexed)
}
#[test]
fn the_index_and_the_inflate_agree_byte_for_byte_and_the_index_is_what_ran() {
let _serial = ENV.lock().unwrap_or_else(|e| e.into_inner());
assert!(git_available(), "stock git is required (S-026)");
let repo = tempfile::tempdir().expect("tempdir");
let ids = churned_repo(repo.path());
let (off, on) = both_arms(repo.path(), &ids, Kind::Sha1);
// A recompressed entry on a repacked single-pack fixture means the copy path
// was not taken at all, and then neither arm is measuring what it says.
assert_eq!((off.recompressed, on.recompressed), (0, 0));
let (inflated_bytes, off_hits, off_misses) = (off.bytes, off.hits, off.misses);
let (indexed_bytes, on_hits, on_misses) = (on.bytes, on.hits, on.misses);
// ── 1. the bytes ────────────────────────────────────────────────────────
assert_eq!(
indexed_bytes.len(),
inflated_bytes.len(),
"the two arms produced packs of different lengths"
);
assert!(
indexed_bytes == inflated_bytes,
"the pack served with entry lengths read out of the index is not byte-identical to the \
one served with them inflated. This is a pure cost optimisation; a different byte is a \
behaviour change"
);
assert!(
indexed_bytes.len() > 1024,
"the fixture served {} bytes; two nearly-empty packs are equal for the wrong reason",
indexed_bytes.len()
);
// ── 2. the path that ran ────────────────────────────────────────────────
//
// Without this the case above is blind: it would pass unchanged if
// `extent_location` returned `None` for every object on both arms.
assert_eq!(
off_hits, 0,
"GUNNAR_PACK_EXTENTS=off still answered {off_hits} locations out of the index, so the \
'before' arm is not the before arm"
);
assert!(
off_misses as usize >= ids.len(),
"the disabled arm resolved only {off_misses} locations for {} objects",
ids.len()
);
// One miss per pack bootstraps the table (the location gix resolves is the
// only way to learn a pack id through the trait) and one more is the pack's
// last entry, which has no successor to measure against. The fixture is
// repacked into a single pack, so two is the whole of it.
assert!(
on_misses <= 2,
"the enabled arm still inflated {on_misses} entries; at most one bootstrap and one \
final entry per pack were expected"
);
assert_eq!(
on_hits + on_misses,
off_misses,
"the two arms resolved a different number of locations, so they did not do the same work"
);
assert!(
on_hits as f64 / (on_hits + on_misses) as f64 > 0.9,
"only {on_hits} of {} locations came out of the index",
on_hits + on_misses
);
}
/// The pack a client actually receives, checked by **stock git**.
///
/// Byte-equality against gunnar's own other arm cannot catch a length that is
/// wrong in both — the two arms share everything downstream of the location. So
/// the pack is handed to `git index-pack --strict`, which resolves every delta
/// chain and re-hashes every object, and would reject an entry whose compressed
/// bytes were cut short or ran long.
#[test]
fn stock_git_accepts_the_pack_sized_out_of_the_index() {
let _serial = ENV.lock().unwrap_or_else(|e| e.into_inner());
assert!(git_available(), "stock git is required (S-026)");
let repo = tempfile::tempdir().expect("tempdir");
let ids = churned_repo(repo.path());
std::env::remove_var("GUNNAR_PACK_EXTENTS");
let Served {
bytes,
hits,
misses,
recompressed,
} = serve(repo.path(), &ids, Kind::Sha1);
assert_eq!(
recompressed, 0,
"the fixture is packed; nothing should be re-deflated"
);
assert!(
hits > 0,
"no location came out of the index, so this checks the old path"
);
let _ = misses;
let out = tempfile::tempdir().expect("tempdir");
let verdict = stock_git_verdict(&bytes, out.path(), Kind::Sha1);
assert!(
verdict.status.success(),
"git index-pack --strict refused a pack whose entry lengths came out of the index: {}",
String::from_utf8_lossy(&verdict.stderr).trim()
);
let listing = common::git(
&[
"verify-pack",
"-v",
&out.path().join("served.idx").to_string_lossy(),
],
out.path(),
);
let named = listing
.lines()
.filter(|l| {
l.split_whitespace()
.next()
.is_some_and(|f| f.len() >= 40 && f.bytes().all(|b| b.is_ascii_hexdigit()))
})
.count();
assert_eq!(
named,
ids.len(),
"git named {named} entries in a pack that was supposed to carry {}",
ids.len()
);
}
/// The refusal, driven red.
///
/// `PackExtents::install` declines a pack whose entry offsets are not strictly
/// ascending, because `next - this` is only an entry length when the entries
/// tile the pack. That branch is unreachable from any pack git writes, so the
/// only way to see it fire is to hand it the offsets directly — which is what
/// this does, through the same public seam `PackedObjects` uses.
#[test]
fn a_pack_whose_offsets_repeat_is_refused_rather_than_mis_sized() {
let _serial = ENV.lock().unwrap_or_else(|e| e.into_inner());
assert!(git_available(), "stock git is required (S-026)");
let repo = tempfile::tempdir().expect("tempdir");
let ids = churned_repo(repo.path());
// A `Find` that reports the same offset twice: two objects claiming one
// entry. `next - this` would be 0 for the first of them, and a zero-length
// entry is a truncated deflate stream on the wire.
struct Duplicated(Box<dyn gix_pack::Find + Send>);
impl gix_pack::Find for Duplicated {
fn contains(&self, id: &gix_hash::oid) -> bool {
self.0.contains(id)
}
fn try_find_cached<'a>(
&self,
id: &gix_hash::oid,
buffer: &'a mut Vec<u8>,
cache: &mut dyn gix_pack::cache::DecodeEntry,
) -> gix_error::Result<
Option<(
gix_object::Data<'a>,
Option<gix_pack::data::entry::Location>,
)>
> {
self.0.try_find_cached(id, buffer, cache)
}
fn location_by_oid(
&self,
id: &gix_hash::oid,
buf: &mut Vec<u8>,
) -> gix_error::Result<Option<gix_pack::data::entry::Location>> {
self.0.location_by_oid(id, buf)
}
fn pack_offsets_and_oid(
&self,
pack_id: u32,
) -> gix_error::Result<Option<Vec<(gix_pack::data::Offset, ObjectId)>>> {
let Some(mut pairs) = self.0.pack_offsets_and_oid(pack_id)? else {
return Ok(None);
};
// Collapse the second entry onto the first.
let first = pairs[0].0;
pairs[1].0 = first;
Ok(Some(pairs))
}
fn entry_by_location(
&self,
location: &gix_pack::data::entry::Location,
) -> Option<gix_pack::find::Entry> {
self.0.entry_by_location(location)
}
}
let gix_repo = gix::open(repo.path()).expect("opening the fixture repository");
let store = gix_repo.objects.store();
let packed = PackedObjects::new(move || {
let mut handle = store.to_handle_arc();
handle.prevent_pack_unload();
Box::new(Duplicated(
Box::new(handle) as Box<dyn gix_pack::Find + Send>
)) as Box<dyn gix_pack::Find + Send>
});
std::env::remove_var("GUNNAR_PACK_EXTENTS");
let before = extent_lookups();
let mut bytes = Vec::new();
write_pack_copy(&ids, &packed, &mut bytes, &WriteOptions::new(Kind::Sha1))
.expect("writing the copied pack");
let after = extent_lookups();
assert_eq!(
after.0 - before.0,
0,
"the table was installed for a pack whose offsets repeat; it would have emitted a \
zero-length entry"
);
assert!(
after.1 - before.1 >= ids.len() as u64,
"the refusal did not fall back: {} locations resolved for {} objects",
after.1 - before.1,
ids.len()
);
}
/// Feed `stdin` to `git <args>` in `cwd` and fail loudly.
fn git_stdin(args: &[&str], cwd: &std::path::Path, stdin: &str) {
use std::io::Write as _;
let mut child = std::process::Command::new("git")
.args(args)
.current_dir(cwd)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap_or_else(|err| panic!("running git {args:?}: {err}"));
child
.stdin
.as_mut()
.expect("git stdin")
.write_all(stdin.as_bytes())
.expect("feeding git");
let out = child.wait_with_output().expect("waiting for git");
assert!(
out.status.success(),
"git {args:?} exited {:?}: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr).trim()
);
}
/// **Two packs that OVERLAP without either containing the other**, which is what
/// a forge looks like between `gc`s.
///
/// The hazard the extent table introduces, and the one byte-equality on a
/// single-pack fixture cannot see: an object that lives in two packs has two
/// *different* entries — different delta base, possibly a different deflate —
/// and both are correct. `gix-odb` picks by move-to-front, so answering out of
/// whichever table this type happened to hold picks the other one.
///
/// **This case caught exactly that**, 2026-08-04: answering from any held table
/// served **287 638** bytes where `gix-odb` served **287 691** — a valid pack,
/// a different pack, and no other test in the tree could see it. The fix is
/// `PackedObjects::front`, which answers only for the pack `gix-odb` would have
/// searched first.
///
/// The fixture is built so all three regions are non-empty — objects only in
/// `A`, objects only in `B`, objects in both — because that is what makes gix
/// hand back locations in *both* packs and so puts two tables in the map at
/// once. With only one pack learned there is nothing to be ambiguous about and
/// the veto could never fire.
#[test]
fn overlapping_packs_do_not_let_a_hash_seed_choose_the_bytes() {
let _serial = ENV.lock().unwrap_or_else(|e| e.into_inner());
assert!(git_available(), "stock git is required (S-026)");
let repo = tempfile::tempdir().expect("tempdir");
let (ids, _shared) = two_overlapping_packs(repo.path(), Kind::Sha1);
let (off, on) = both_arms(repo.path(), &ids, Kind::Sha1);
assert!(
on.bytes == off.bytes,
"with objects in two overlapping packs the two arms served different bytes: {} vs {}",
off.bytes.len(),
on.bytes.len()
);
assert!(on.bytes.len() > 1024, "the fixture served almost nothing");
assert_eq!(
off.recompressed, on.recompressed,
"the two arms downgraded a different number of entries to whole objects"
);
assert_eq!(
on.hits + on.misses,
off.misses,
"the arms resolved a different number of locations"
);
// **The table ran.** Without this the byte comparison above is a comparison
// of the old path against itself, which is green for the wrong reason.
assert!(
on.hits > 0,
"nothing was answered out of a pack index, so this case checked the old path twice"
);
// Both packs were genuinely in play: some objects only `gix-odb` could
// place, which is what puts a second table in the map and makes the
// front-index rule load-bearing rather than vacuous.
assert!(
on.misses > 0,
"every location came out of one table, so the second pack was never reached"
);
}
/// Build the fixture above and hand back `(every object, the shared region)`.
///
/// Extracted because the multi-pack-index case below needs the very same three
/// non-empty regions — an object in two packs is the whole precondition of both
/// defects — and a second copy of sixty lines of `pack-objects` plumbing is what
/// LAW 5 forbids.
fn two_overlapping_packs(repo: &std::path::Path, hash: Kind) -> (Vec<ObjectId>, Vec<ObjectId>) {
two_packs(repo, hash, Overlap::Shared)
}
/// Whether the two packs the fixture builds hold an object in common.
///
/// The whole trigger, expressed as one enum: a multi-pack index only hides an
/// entry when two packs claim the same object, and `Disjoint` is the control arm
/// that says so. Same history, same pack count, same index — one cut moved.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Overlap {
/// A ∩ B is commits 7..=12 — what a forge looks like between `gc`s.
Shared,
/// A and B partition the history and share nothing.
Disjoint,
}
fn two_packs(
repo: &std::path::Path,
hash: Kind,
overlap: Overlap,
) -> (Vec<ObjectId>, Vec<ObjectId>) {
common::churned_repo_with(repo, hash);
let commits: Vec<String> = common::git(&["rev-list", "--reverse", "HEAD"], repo)
.lines()
.map(str::to_owned)
.collect();
assert!(commits.len() >= 24, "the fixture lost its history");
let early = &commits[11]; // objects of commits 1..=12
// The floor of B. At commit 6 the two packs share commits 7..=12; at commit
// 12 they share nothing and B is exactly the rest of the history.
let mid = match overlap {
Overlap::Shared => &commits[5],
Overlap::Disjoint => early,
};
let pack_dir = repo.join(".git").join("objects").join("pack");
// A = everything reachable from commit 12. B = everything reachable from the
// tip that is NOT reachable from commit 6. So A ∩ B is commits 7..=12, A-only
// is 1..=6 and B-only is 13..=24 — none of the three empty.
let a = common::git(&["rev-list", "--objects", early], repo);
let b = common::git(&["rev-list", "--objects", "HEAD", &format!("^{mid}")], repo);
git_stdin(
&["pack-objects", "-q", &pack_dir.join("a").to_string_lossy()],
repo,
&a,
);
git_stdin(
&["pack-objects", "-q", &pack_dir.join("b").to_string_lossy()],
repo,
&b,
);
// Drop the repack's own pack so exactly the two overlapping ones remain.
for entry in std::fs::read_dir(&pack_dir).expect("reading objects/pack") {
let path = entry.expect("a directory entry").path();
let name = path.file_name().unwrap_or_default().to_string_lossy();
if name.starts_with("pack-") {
std::fs::remove_file(&path).expect("removing the original pack");
}
}
let packs = std::fs::read_dir(&pack_dir)
.expect("reading objects/pack")
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "pack"))
.count();
assert_eq!(packs, 2, "the fixture holds {packs} packs, not two");
// Serve **everything**, which is what forces BOTH packs into the table:
// gix resolves an A-only object out of A and a B-only object out of B, and
// each of those misses teaches the table about the pack it came from. Serving
// only the shared region would leave one pack learned and nothing to be
// ambiguous about — which is how the first version of this case passed with
// the veto deleted.
let ids: Vec<ObjectId> = common::git(&["rev-list", "--objects", "--all"], repo)
.lines()
.filter_map(|l| l.split_whitespace().next())
.filter_map(|hex| ObjectId::from_hex(hex.as_bytes()).ok())
.collect();
let shared: Vec<ObjectId> =
common::git(&["rev-list", "--objects", early, &format!("^{mid}")], repo)
.lines()
.filter_map(|l| l.split_whitespace().next())
.filter_map(|hex| ObjectId::from_hex(hex.as_bytes()).ok())
.collect();
match overlap {
Overlap::Shared => assert!(
shared.len() > 10 && ids.len() > shared.len(),
"{} objects in both packs out of {} — the three regions are not all non-empty",
shared.len(),
ids.len()
),
Overlap::Disjoint => assert!(
shared.is_empty() && ids.len() > 20,
"the control arm's packs share {} objects out of {}",
shared.len(),
ids.len()
),
}
(ids, shared)
}
/// Hand `bytes` to **stock git** as a packfile and give back its verdict.
///
/// `index-pack` writes the `.idx` next to the pack, so `out` also holds
/// `served.idx` afterwards for a caller that wants to read what git thinks is
/// inside. The verdict is handed back rather than asserted here because one
/// caller has to observe git **refusing** (LAW 2), and a helper that asserted
/// success could never be driven red.
fn stock_git_verdict(bytes: &[u8], out: &std::path::Path, hash: Kind) -> std::process::Output {
let pack = out.join("served.pack");
std::fs::write(&pack, bytes).expect("saving the pack");
// Run outside a repository, `git index-pack` assumes sha1 and would refuse a
// sha256 pack for the wrong reason — a refusal that looks exactly like the
// defect this file is about.
let format = match hash {
Kind::Sha1 => "--object-format=sha1",
_ => "--object-format=sha256",
};
common::git_raw(
&[
"index-pack",
"--strict",
format,
"-v",
&pack.to_string_lossy(),
],
out,
)
}
/// Write a `multi-pack-index` over every `.idx` in `pack_dir`, exactly as
/// `gunnar_store::FsStore::write_multi_pack_index` does.
///
/// The same `gix_pack::multi_index::write_from_index_paths` call, because the
/// defect below is a property of **what a multi-pack index records**, not of who
/// wrote it: stock `git multi-pack-index write` produces the same shape and
/// `gix-odb` reads either. `gunnar-store` is not reachable from this crate's
/// tests — it depends on this one — so the writer is called directly.
fn write_multi_pack_index(pack_dir: &std::path::Path, hash: Kind) {
let mut indices: Vec<std::path::PathBuf> = std::fs::read_dir(pack_dir)
.expect("reading objects/pack")
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "idx"))
.collect();
indices.sort();
assert!(
indices.len() >= 2,
"a multi-pack index over {} index needs at least two",
indices.len()
);
let file =
std::fs::File::create(pack_dir.join("multi-pack-index")).expect("creating the index");
let mut out = std::io::BufWriter::new(file);
gix_pack::multi_index::write_from_index_paths(
indices,
&mut out,
&mut gix_utils::progress::Discard,
&std::sync::atomic::AtomicBool::new(false),
gix_pack::multi_index::write::Options { object_hash: hash },
)
.expect("writing the multi-pack index");
use std::io::Write as _;
out.flush().expect("flushing the multi-pack index");
}
/// **A multi-pack index records each object once, so a pack's entry list read
/// through one has holes — and a hole is an entry sized across its neighbour.**
///
/// `PackExtents` sizes an entry as the distance to the next offset **in the same
/// pack**, which is exact only if it can see every offset that pack holds. It
/// gets them from `gix_pack::Find::pack_offsets_and_oid`, and `gix-odb` answers
/// that out of whatever index is in its snapshot. Once a `multi-pack-index` is
/// on disk that index is the multi-pack one, and the multi-pack format holds a
/// **deduplicated** oid table: an object living in two packs is recorded against
/// exactly one of them. `gix-odb`'s
/// `IndexLookup::iter` then filters that table by `pack_index`, so the other
/// pack's list is **missing that object's offset**, and the entry before the
/// hole is measured to the offset after it.
///
/// The copied entry then carries its neighbour's bytes as a tail. The pack's
/// object count still accounts for every entry, so a receiver parses `n`
/// entries, leaves the surplus unconsumed, and reads its "trailer" from the
/// wrong place:
///
/// ```text
/// stock git: fatal: pack is corrupted (SHA1 mismatch)
/// gitoxide: failed index
/// ```
///
/// Three things have to be true at once, which is why 1139 packs in
/// `bench/year` clone clean and 16 here do not: a multi-pack index has to
/// exist, an object has to live in **two** of the packs it spans, and the pack
/// that lost the object has to hold an entry **before** it. Pack count alone is
/// not the trigger; the duplicate is.
#[test]
fn a_multi_pack_index_must_not_size_an_entry_across_the_object_it_hides() {
a_multi_pack_index_must_not_size_an_entry_across_the_object_it_hides_at(Kind::Sha1);
}
/// **The same case in sha256, because "sha1 only" was a corpus, not a hash.**
///
/// The defect was first seen on sha1 corpora and `tunnr`, the sha256 corpus,
/// stayed clean — which reads like immunity and is not. Nothing in the trigger
/// is hash-shaped: a multi-pack index deduplicates by object id in either hash,
/// `gix-odb` filters the same table by pack in either, and an entry sized across
/// a hidden neighbour is the same wrong length. What the sha256 corpus lacked
/// was the **shape** — the same object stored in two packs. Given it, sha256
/// reproduces exactly as sha1 does, which is what this case exists to say. It is
/// the same fixture builder with one argument changed, so it cannot be the
/// repository that differs.
#[test]
fn a_multi_pack_index_must_not_size_an_entry_across_the_object_it_hides_sha256() {
a_multi_pack_index_must_not_size_an_entry_across_the_object_it_hides_at(Kind::Sha256);
}
fn a_multi_pack_index_must_not_size_an_entry_across_the_object_it_hides_at(hash: Kind) {
let _serial = ENV.lock().unwrap_or_else(|e| e.into_inner());
assert!(git_available(), "stock git is required (S-026)");
let repo = tempfile::tempdir().expect("tempdir");
let (ids, shared) = two_overlapping_packs(repo.path(), hash);
let pack_dir = repo.path().join(".git").join("objects").join("pack");
// The same two packs, before the index exists. Its `hits` is what the extent
// table is worth on this fixture, and the number the refusal below is
// measured against.
std::env::remove_var("GUNNAR_PACK_EXTENTS");
let bare = serve(repo.path(), &ids, hash);
write_multi_pack_index(&pack_dir, hash);
assert!(
pack_dir.join("multi-pack-index").is_file(),
"the fixture has no multi-pack index, so this case cannot see the defect"
);
// The precondition, stated as a number rather than assumed: without objects
// in both packs the multi-pack index hides nothing and the entry lists have
// no holes.
assert!(shared.len() > 10, "the packs do not overlap");
let (off, on) = both_arms(repo.path(), &ids, hash);
// **The table ran.** Otherwise everything below compares the fallback
// against itself and is green for the wrong reason.
assert!(
on.hits > 0,
"nothing was answered out of a pack index, so this case checked the old path twice"
);
// **And the refusal ran.** Without this the case is one-sided: refusing
// *every* pack would satisfy the byte comparison below and quietly revert
// `P-025` on every repository with a multi-pack index. The pack the index
// kept whole keeps its table; the pack it took entries from does not, so the
// hit count has to fall and may not fall to zero.
assert!(
on.hits < bare.hits,
"the same two packs answered {} locations out of the index with a multi-pack index over \
them and {} without, so no pack's short entry list was refused",
on.hits,
bare.hits
);
// The receiver's verdict first, because it is the defect's own signature and
// the only judge that can see a length wrong in both arms.
let out = tempfile::tempdir().expect("tempdir");
let verdict = stock_git_verdict(&on.bytes, out.path(), hash);
assert!(
verdict.status.success(),
"git index-pack --strict refused the pack served over a multi-pack index: {}",
String::from_utf8_lossy(&verdict.stderr).trim()
);
// ── the second receiver ─────────────────────────────────────────────────
//
// gitoxide reads a pack through a different decoder than git's, and the two
// refuse this defect with different words — `pack is corrupted (SHA1
// mismatch)` against `failed index`. A guard that consulted only one would
// be a guard against one implementation's parser.
let out2 = tempfile::tempdir().expect("tempdir");
let indexed = gunnar_pack::index_pack(
&mut std::io::BufReader::new(std::io::Cursor::new(&on.bytes)),
Some(out2.path()),
&gunnar_pack::IndexOptions::new(hash),
None,
);
let indexed = indexed.expect("gitoxide refused the pack served over a multi-pack index");
assert_eq!(
indexed.num_objects as usize,
ids.len(),
"gitoxide indexed a different number of objects than were selected"
);
// Then the byte count, which names the surplus. MEASURED before the fix on
// this very fixture: 287 739 served against 287 691, **48 surplus bytes** —
// one entry sized across the neighbour the index hid.
assert_eq!(
on.bytes.len(),
off.bytes.len(),
"the extent table sized {} bytes of entries where the inflate sized {} — it measured \
across an object the multi-pack index attributed to the other pack",
on.bytes.len(),
off.bytes.len()
);
assert!(
on.bytes == off.bytes,
"the two arms served packs of the same length and different content"
);
}
/// **The control arm: a multi-pack index over packs that share nothing takes no
/// table away.**
///
/// This is the case that makes the claim above a claim about *shape* rather than
/// about pack count. `bench/year` reaches **1139** packs and clones clean; the
/// fixture above corrupts at **two**. The difference is not how many packs there
/// are, it is whether two of them hold the same object — because that is the
/// only thing a multi-pack index has to choose between, and choosing is what
/// leaves the other pack's entry list short.
///
/// Same history, same builder, same index, one cut moved. Every pack's list is
/// then whole, the completeness check passes, and `P-025` is untouched: the hit
/// count with the index over the packs equals the hit count without it.
///
/// Without this case the fix could tighten into "refuse every pack a multi-pack
/// index spans" and nothing in the tree would notice — which is a revert of
/// `P-025` on exactly the busy forge `P-012` built the index for.
#[test]
fn a_multi_pack_index_over_disjoint_packs_costs_no_extent_table() {
let _serial = ENV.lock().unwrap_or_else(|e| e.into_inner());
assert!(git_available(), "stock git is required (S-026)");
let repo = tempfile::tempdir().expect("tempdir");
let (ids, shared) = two_packs(repo.path(), Kind::Sha1, Overlap::Disjoint);
assert!(shared.is_empty(), "the control arm's packs overlap");
let pack_dir = repo.path().join(".git").join("objects").join("pack");
std::env::remove_var("GUNNAR_PACK_EXTENTS");
let bare = serve(repo.path(), &ids, Kind::Sha1);
write_multi_pack_index(&pack_dir, Kind::Sha1);
let with_index = serve(repo.path(), &ids, Kind::Sha1);
assert!(
bare.hits > 0,
"the table never ran even without an index, so this case compares nothing"
);
assert_eq!(
with_index.hits,
bare.hits,
"a multi-pack index over packs that share no object cost {} of {} index-answered \
locations, so the completeness check is refusing packs whose lists are whole",
bare.hits as i64 - with_index.hits as i64,
bare.hits
);
let out = tempfile::tempdir().expect("tempdir");
let verdict = stock_git_verdict(&with_index.bytes, out.path(), Kind::Sha1);
assert!(
verdict.status.success(),
"git index-pack --strict refused the control arm's pack: {}",
String::from_utf8_lossy(&verdict.stderr).trim()
);
}