Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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