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

nordisk/edda

crates/storage-git-gix/tests/bitmap_and_walk_agree.rs

//! The reachability bitmap, judged by **stock git** and by the walk it replaces.
//!
//! # The two questions, and neither one alone is enough
//!
//! 1. **Does stock `git` accept the file?** `git rev-list --test-bitmap` over
//!    every stored entry, `git rev-list --use-bitmap-index`, `git verify-pack`
//!    and `git fsck`. A `.bitmap` only its own reader can read has proved
//!    nothing — the same reason `tests/round_trip.rs` exists, and the same
//!    reason these tests **fail** when `git` is missing rather than skipping
//!    (`S-026`: *"the real-git interop suites self-skip and report green"*).
//! 2. **Does the bitmap path select the same objects as the walk?** That
//!    equality is the entire safety argument. A bitmap answer that is one object
//!    short produces a pack that `index-pack --strict` accepts, that `fsck`
//!    passes, and that leaves the client's working tree wrong.
//!
//! # Why every arm asserts which path answered, first
//!
//! Both paths are **defined** to return the same object set, so every assertion
//! below is equally true of the walk. Without
//! [`GixStore::bitmap_selection_counts`] this whole file is green with the
//! reader switched off, which is `P-027` — *"five instruments measured the walk
//! path and published it as the bitmap path"* — reproduced rather than avoided.
//!
//! # The guard's own red, proved rather than assumed
//!
//! [`a_bitmap_missing_one_object_makes_the_comparison_go_red`] rewrites the
//! written bitmap with a single bit dropped and asserts the comparison **fails**.
//! A set-equality guard that cannot see a one-object under-send is the hollow
//! guard LAW 2 is about, and three agents have built one while hunting for one.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;

use git_storage_trait::GitOps;
use gix_hash::ObjectId;
use storage_git_gix::GixStore;

const HASH: gix_hash::Kind = gix_hash::Kind::Sha1;

/// Serialises the tests in this file.
///
/// [`GixStore::bitmap_selection_counts`] is **process-wide**, deliberately — the
/// crate's own note on `keep_removal_failures` says why, and says that three
/// parallel tests reading such a counter race each other. Every arm here reads a
/// delta across one call, so they take turns. Without this the file is flaky in
/// exactly the direction that reads as "the bitmap path did not answer".
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());

fn serial() -> std::sync::MutexGuard<'static, ()> {
    SERIAL
        .lock()
        .unwrap_or_else(std::sync::PoisonError::into_inner)
}

/// Run `git` in `dir`, failing loudly. **Never skips** — see the module doc.
fn git(dir: &Path, args: &[&str]) -> String {
    let out = Command::new("git")
        .current_dir(dir)
        .args(args)
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("GIT_CONFIG_SYSTEM", "/dev/null")
        .output()
        .unwrap_or_else(|e| panic!("`git {}` could not be run: {e}", args.join(" ")));
    assert!(
        out.status.success(),
        "`git {}` in {} failed: {}",
        args.join(" "),
        dir.display(),
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8_lossy(&out.stdout).trim().to_string()
}

/// Run `git` where failure is a possible outcome worth reading.
fn git_try(dir: &Path, args: &[&str]) -> (bool, String) {
    let out = Command::new("git")
        .current_dir(dir)
        .args(args)
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("GIT_CONFIG_SYSTEM", "/dev/null")
        .output()
        .unwrap_or_else(|e| panic!("`git {}` could not be run: {e}", args.join(" ")));
    let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
    text.push_str(&String::from_utf8_lossy(&out.stderr));
    (out.status.success(), text)
}

fn oid(hex: &str) -> ObjectId {
    ObjectId::from_hex(hex.trim().as_bytes()).expect("a hex object id")
}

/// A bare `GixStore` holding a repository with real history, plus the work tree
/// it came from so more commits can be pushed later.
struct Fixture {
    _dir: tempfile::TempDir,
    work: PathBuf,
    bare: PathBuf,
    /// Newest first, from `rev-list HEAD`.
    commits: Vec<ObjectId>,
    /// The merge commit at the tip.
    head: ObjectId,
    side: ObjectId,
}

impl Fixture {
    /// 130 commits rather than a handful, and the number is load-bearing: git
    /// stores every one of the newest 100 commits unconditionally, so under that
    /// threshold **every** commit has a bitmap, the bounded walk in
    /// `bitmap::accumulate` never runs, and a small fixture would pass whatever
    /// that walk did.
    fn build(commits: usize) -> Self {
        let dir = tempfile::tempdir().expect("tempdir");
        let work = dir.path().join("work");
        std::fs::create_dir_all(&work).expect("mkdir");
        git(&work, &["init", "-q", "--initial-branch=main"]);
        git(&work, &["config", "user.email", "t@example.invalid"]);
        git(&work, &["config", "user.name", "T"]);
        for i in 0..commits {
            // Several files, only some of which change, so trees are shared
            // between commits and the tree walk has something to short-circuit
            // on. A fixture where every commit rewrites everything makes the
            // "already set, do not descend" rule untested.
            std::fs::write(work.join(format!("f{}", i % 7)), format!("{i}\n")).expect("write");
            std::fs::create_dir_all(work.join("sub")).expect("mkdir");
            std::fs::write(work.join("sub").join("shared"), format!("{i}\n")).expect("write");
            git(&work, &["add", "-A"]);
            git(&work, &["commit", "-q", "-m", &format!("c{i}")]);
        }
        // A side branch and a real merge: a linear fixture never exercises the
        // union of two parents' bitmaps, which is where a wrong OR hides.
        git(&work, &["checkout", "-q", "-b", "side", "HEAD~20"]);
        std::fs::write(work.join("sideways"), "side\n").expect("write");
        git(&work, &["add", "-A"]);
        git(&work, &["commit", "-q", "-m", "side"]);
        let side = oid(&git(&work, &["rev-parse", "HEAD"]));
        git(&work, &["checkout", "-q", "main"]);
        git(&work, &["merge", "-q", "--no-ff", "-m", "merge", "side"]);
        // An annotated tag, whose tag object is in no commit's bitmap.
        git(&work, &["tag", "-a", "-m", "v1", "v1"]);

        // `file://`, not a bare path, and it is not decoration. `git clone
        // --bare /some/path` **hardlinks the object store**: a work tree whose
        // objects are all loose produces a bare repository with no packfile at
        // all, and this whole file then measured a store with nothing to
        // bitmap. Found the hard way — `write_reachability_bitmap` answered
        // `Ok(None)` and every arm below read it as "the feature is broken".
        // A `file://` URL forces the real transport, so what lands is one pack
        // and one `.idx`, which is what a pushed-to server holds.
        let bare = dir.path().join("bare.git");
        git(
            dir.path(),
            &[
                "clone",
                "-q",
                "--bare",
                &format!("file://{}", work.display()),
                bare.to_str().unwrap(),
            ],
        );

        let head = oid(&git(&bare, &["rev-parse", "HEAD"]));
        let commits: Vec<ObjectId> = git(&bare, &["rev-list", "HEAD"]).lines().map(oid).collect();
        Fixture {
            _dir: dir,
            work,
            bare,
            commits,
            head,
            side,
        }
    }

    fn store(&self) -> GixStore {
        GixStore::open_or_init(&self.bare, HASH).expect("open the store")
    }

    /// Commit `n` more times in the work tree and push them into the bare
    /// repository, which lands them in a **second** pack.
    fn push_more(&self, n: usize) -> ObjectId {
        for i in 0..n {
            std::fs::write(self.work.join("after"), format!("{i}\n")).expect("write");
            git(&self.work, &["add", "-A"]);
            git(&self.work, &["commit", "-q", "-m", &format!("after{i}")]);
        }
        git(
            &self.work,
            &["push", "-q", self.bare.to_str().unwrap(), "main"],
        );
        oid(&git(&self.bare, &["rev-parse", "main"]))
    }

    /// The single `.idx` the bitmap is expected to sit beside.
    fn bitmap_path(&self) -> PathBuf {
        let dir = self.bare.join("objects").join("pack");
        let mut found: Vec<PathBuf> = std::fs::read_dir(&dir)
            .expect("read pack dir")
            .flatten()
            .map(|e| e.path())
            .filter(|p| p.extension().is_some_and(|x| x == "bitmap"))
            .collect();
        assert_eq!(
            found.len(),
            1,
            "expected exactly one .bitmap in {}, found {found:?}",
            dir.display()
        );
        found.pop().expect("checked")
    }
}

/// Everything the bare repository's refs point at, as `want` tips.
fn tips(store: &GixStore) -> Vec<ObjectId> {
    store
        .refs()
        .expect("refs")
        .into_iter()
        .filter_map(|r| r.oid)
        .filter_map(|raw| ObjectId::try_from(raw.as_slice()).ok())
        .collect()
}

/// A compact description of how two object sets differ.
///
/// Printing two 400-element vectors buries the finding; the interesting part is
/// always which handful of objects moved and in which direction.
fn diff(a: &[ObjectId], b: &[ObjectId]) -> String {
    let sa: BTreeSet<&ObjectId> = a.iter().collect();
    let sb: BTreeSet<&ObjectId> = b.iter().collect();
    format!(
        "{} vs {}; {} only in the first (e.g. {:?}); {} only in the second (e.g. {:?})",
        a.len(),
        b.len(),
        sa.difference(&sb).count(),
        sa.difference(&sb).take(4).collect::<Vec<_>>(),
        sb.difference(&sa).count(),
        sb.difference(&sa).take(4).collect::<Vec<_>>(),
    )
}

/// Run one `want`/`have` shape through the store and return the selection,
/// asserting first that the **bitmap** answered it.
fn from_bitmap(
    label: &str,
    store: &GixStore,
    want: &[ObjectId],
    have: &[ObjectId],
) -> storage_git_gix::graph::Selection {
    let (before, _) = GixStore::bitmap_selection_counts();
    let sel = store.selection(want, have).expect("a selection");
    let (after, _) = GixStore::bitmap_selection_counts();
    assert_eq!(
        after,
        before + 1,
        "{label}: the walk answered, so nothing this arm asserts tests the bitmap path"
    );
    sel
}

// ── 1. stock git is the judge ────────────────────────────────────────────────

/// `gc` writes a `.bitmap` and **stock git reads it**.
///
/// Four separate questions, because each catches something the others do not:
///
/// * `--test-bitmap` over **every** stored entry, not one. Measured 2026-08-04,
///   git 2.53: flipping a byte inside the entry region of a 28 520-byte
///   `.bitmap` still produced `OK!` and exit 0, because the corrupted entry was
///   not the one being tested. One call is a probe that passes through real
///   corruption.
/// * `--use-bitmap-index` against the plain traversal, which is git deciding to
///   *use* the file rather than merely validate it.
/// * `verify-pack`, which is the pack the bitmap claims to describe.
/// * `fsck --full --strict`, because none of the above looks at the repository.
#[test]
fn stock_git_reads_the_bitmap_gc_wrote() {
    let _serial = serial();
    let f = Fixture::build(130);
    let store = f.store();

    let report = store.gc().expect("gc");
    assert_eq!(
        report.retired_packs, 0,
        "every object in this fixture is live, so a retired pack means the sweep is wrong \
         and the object sets compared elsewhere are not comparable"
    );
    let bitmap_path = f.bitmap_path();
    assert!(
        std::fs::metadata(&bitmap_path).expect("stat").len() > 0,
        "gc wrote an empty .bitmap"
    );
    drop(store);

    // Every bitmapped commit, not one. `--test-bitmap` walks that commit for
    // real and compares, so this is git's own traversal against our file.
    let index = gunnar_bitmap::Index::at(&bitmap_path, HASH).expect("git-readable header");
    index.verify_checksum().expect("our own trailing checksum");
    let idx = gix_pack::index::File::at(bitmap_path.with_extension("idx"), HASH).expect("idx");
    let stored: Vec<String> = index
        .commit_index_positions()
        .map(|pos| idx.oid_at_index(pos).to_string())
        .collect();
    assert!(
        stored.len() >= 100,
        "only {} commits got a bitmap out of 131; git stores every one of the newest 100, \
         so the selection window is wrong",
        stored.len()
    );
    for commit in &stored {
        let (ok, out) = git_try(&f.bare, &["rev-list", "--test-bitmap", commit]);
        assert!(
            ok && out.contains("OK!"),
            "git rejected our bitmap for {commit}:\n{out}"
        );
    }

    // git decides to *use* it, and gets the same answer as without it.
    let with = git(
        &f.bare,
        &["rev-list", "--use-bitmap-index", "--count", "--all"],
    );
    let without = git(&f.bare, &["rev-list", "--count", "--all"]);
    assert_eq!(
        with, without,
        "`git rev-list --use-bitmap-index --count --all` disagreed with the plain count"
    );
    assert!(
        without.parse::<u32>().expect("a count") >= 131,
        "the fixture lost commits: {without}"
    );

    // The pack the bitmap describes, and the repository around it.
    let pack = bitmap_path.with_extension("pack");
    let (ok, out) = git_try(&f.bare, &["verify-pack", "-v", pack.to_str().unwrap()]);
    assert!(ok, "git verify-pack rejected the pack:\n{out}");
    let (ok, out) = git_try(&f.bare, &["fsck", "--full", "--strict"]);
    assert!(
        ok,
        "git fsck --full --strict rejected the repository:\n{out}"
    );
}

/// A real `git clone` off the store, with the bitmap in place, lands every
/// object.
///
/// The end-to-end statement the unit arms cannot make: `clone` runs git's own
/// bitmap reader over our file on the *serving* side too, since the local
/// transport reads `objects/pack` directly.
#[test]
fn a_real_git_clone_off_the_bitmapped_store_is_complete() {
    let _serial = serial();
    let f = Fixture::build(130);
    let store = f.store();
    store.gc().expect("gc");
    drop(store);

    let dest = f.bare.parent().expect("parent").join("cloned.git");
    git(
        f.bare.parent().expect("parent"),
        &[
            "clone",
            "-q",
            "--bare",
            f.bare.to_str().unwrap(),
            dest.to_str().unwrap(),
        ],
    );
    let (ok, out) = git_try(&dest, &["fsck", "--full", "--strict"]);
    assert!(ok, "the clone did not fsck:\n{out}");
    assert_eq!(
        git(&dest, &["rev-list", "--count", "--all"]),
        git(&f.bare, &["rev-list", "--count", "--all"]),
        "the clone is missing commits"
    );
    assert_eq!(
        git(&dest, &["rev-parse", "HEAD"]),
        f.head.to_string(),
        "the clone's HEAD is not the source's"
    );
}

// ── 2. the two paths select the same objects ─────────────────────────────────

/// Every shape, walk against bitmap, on the **object set**.
///
/// One test rather than six because the fixture costs 131 `git commit`
/// invocations and the walk answers have to be taken from the same store
/// contents the bitmap answers are: `gc` is what installs the bitmap, so the
/// before/after ordering is the comparison.
///
/// The `retired_packs == 0` assertion is what makes that ordering sound. A gc
/// that deleted a pack between the two halves would be comparing two different
/// repositories and could report agreement about nothing.
#[test]
fn the_bitmap_selects_exactly_what_the_walk_selects() {
    let _serial = serial();
    let f = Fixture::build(130);
    let store = f.store();
    let all_tips = tips(&store);
    assert!(
        all_tips.len() >= 3,
        "want main, side and the tag: {all_tips:?}"
    );
    let tag = oid(&git(&f.bare, &["rev-parse", "refs/tags/v1"]));

    // Shapes, each of which fails for a different reason if it fails at all.
    // The `floor` is what stops an arm from being evidence-free: a clone must
    // reach the whole repository, while a five-commit fetch reaching 200 objects
    // would mean the exclusion did nothing.
    let shapes: Vec<(&str, Vec<ObjectId>, Vec<ObjectId>, usize)> = vec![
        ("clone", vec![f.head], vec![], 200),
        ("clone-all-tips", all_tips.clone(), vec![], 200),
        ("fetch-5", vec![f.head], vec![f.commits[5]], 5),
        ("fetch-100", vec![f.head], vec![f.commits[100]], 5),
        (
            "across-the-merge",
            vec![f.head],
            vec![f.side, f.commits[30]],
            5,
        ),
        ("tag-want", vec![tag], vec![], 200),
        ("tag-want-deep-have", vec![tag], vec![f.commits[60]], 5),
    ];

    // ── the walk, before there is any bitmap ────────────────────────────────
    assert!(
        !f.bare
            .join("objects")
            .join("pack")
            .read_dir()
            .unwrap()
            .flatten()
            .any(|e| e.path().extension().is_some_and(|x| x == "bitmap")),
        "the fixture already has a .bitmap, so the 'before' half is not the walk"
    );
    let mut walked = Vec::new();
    for (label, want, have, floor) in &shapes {
        let (bm_before, walk_before) = GixStore::bitmap_selection_counts();
        let sel = store.selection(want, have).expect("a selection");
        let (bm_after, walk_after) = GixStore::bitmap_selection_counts();
        assert_eq!(
            (bm_after, walk_after),
            (bm_before, walk_before + 1),
            "{label}: something answered from a bitmap before one existed"
        );
        assert!(
            sel.objects.len() > *floor,
            "{label}: a selection of {} objects is too small to be evidence",
            sel.objects.len()
        );
        walked.push(sel);
    }

    // ── gc, then the same questions again ───────────────────────────────────
    let report = store.gc().expect("gc");
    assert_eq!(
        report.retired_packs, 0,
        "the sweep retired a pack, so the two halves are not the same repository"
    );
    drop(store);
    let store = f.store();

    for ((label, want, have, _), before) in shapes.iter().zip(&walked) {
        let after = from_bitmap(label, &store, want, have);
        assert!(
            after.objects == before.objects,
            "{label}: the bitmap and the walk selected different OBJECTS: {}",
            diff(&after.objects, &before.objects)
        );
        assert!(
            after.commits == before.commits,
            "{label}: the bitmap and the walk disagree about which of them are COMMITS: {}",
            diff(&after.commits, &before.commits)
        );
        assert!(
            after.client_has == before.client_has,
            "{label}: the bitmap and the walk disagree about what the client already HAS: {}",
            diff(&after.client_has, &before.client_has)
        );
    }

    // The comparison itself must be able to tell two shapes apart, or it would
    // hold for a selection that ignored its arguments and returned a constant.
    let clone = &walked[0].objects;
    let fetch = &walked[2].objects;
    assert!(
        fetch.len() * 4 < clone.len(),
        "a fetch 5 commits deep selected {} objects against a clone's {}; the haves were \
         not subtracted, so every equality above could hold for a path that subtracts nothing",
        fetch.len(),
        clone.len()
    );
}

/// A `want` with **no stored bitmap** — the bounded walk — still agrees, and is
/// still bounded.
///
/// # The probe the count exists for
///
/// ORing a commit's stored bitmap in and then walking its parents anyway changes
/// **no answer**: a bitmap is closed under reachability, so everything the extra
/// walk finds is already set. Measured on the quarantined lane, 2026-08-04, with
/// that `continue` deliberately removed: **9 of 9 set-equality tests and 25 of
/// 25 end-to-end cases against stock git stayed green.** A correctness guard
/// cannot see it. A count can.
#[test]
fn a_want_pushed_after_gc_takes_a_bounded_walk_and_still_agrees() {
    let _serial = serial();
    let f = Fixture::build(130);
    let store = f.store();
    store.gc().expect("gc");
    drop(store);

    // A clone off the fully bitmapped store opens no commit at all.
    let store = f.store();
    let before = GixStore::bitmap_commits_walked();
    let bitmapped = from_bitmap("clone", &store, &[f.head], &[]);
    assert_eq!(
        GixStore::bitmap_commits_walked(),
        before,
        "a clone off a fully bitmapped repository opened commits; it should open none"
    );
    drop(store);

    // Four commits pushed after the gc. They land in a second pack, so none of
    // them is in the bitmapped pack at all: the walk must open exactly those
    // four and then stop, because the fifth step down reaches a commit that has
    // a bitmap.
    let head = f.push_more(4);
    let store = f.store();
    let before = GixStore::bitmap_commits_walked();
    let after = from_bitmap("post-push", &store, &[head], &[]);
    let opened = GixStore::bitmap_commits_walked() - before;
    assert_eq!(
        opened, 4,
        "the bounded walk opened {opened} commits where exactly 4 lack a bitmap; more means \
         it walked past a bitmapped ancestor instead of stopping at it"
    );

    // ...and the answer is right. The four new commits and their objects, on top
    // of everything the clone selected.
    assert!(
        after.objects.len() > bitmapped.objects.len(),
        "the post-push selection ({}) is no bigger than the pre-push one ({}), so the \
         bounded walk found nothing",
        after.objects.len(),
        bitmapped.objects.len()
    );
    let missing: Vec<&ObjectId> = bitmapped
        .objects
        .iter()
        .filter(|id| !after.objects.contains(id))
        .take(4)
        .collect();
    assert!(
        missing.is_empty(),
        "the post-push selection dropped objects the pre-push one had: {missing:?}"
    );

    // Stock git agrees the whole thing is still sound with two packs and one
    // bitmap.
    let (ok, out) = git_try(&f.bare, &["fsck", "--full", "--strict"]);
    assert!(ok, "fsck failed after the second pack landed:\n{out}");
}

// ── 3. the guard's own red ───────────────────────────────────────────────────

/// **The negative control for every equality above.**
///
/// Drop one object from one stored bitmap and the comparison must fail. Without
/// this, `the_bitmap_selects_exactly_what_the_walk_selects` could be an
/// assertion that two identical constants are equal — and a bitmap short by one
/// object is not a hypothetical failure, it is the failure this whole path is
/// prone to and the only one nothing downstream can see.
///
/// It also re-proves that `git rev-list --test-bitmap` can go red at all, aimed
/// at the commit whose entry was damaged.
#[test]
fn a_bitmap_missing_one_object_makes_the_comparison_go_red() {
    let _serial = serial();
    let f = Fixture::build(130);
    let store = f.store();
    let honest = store.selection(&[f.head], &[]).expect("the walk");
    store.gc().expect("gc");
    drop(store);

    let path = f.bitmap_path();
    let index = gunnar_bitmap::Index::at(&path, HASH).expect("parse what gc wrote");
    let idx = gix_pack::index::File::at(path.with_extension("idx"), HASH).expect("idx");

    // The entry for HEAD, so the damage is on the path a clone takes.
    let head_pos = idx.lookup(f.head.as_ref()).expect("HEAD is in the pack");
    let victim = index
        .entries()
        .iter()
        .position(|e| e.commit_index_pos == head_pos)
        .expect("HEAD has a stored bitmap");

    let mut writer = gunnar_bitmap::Writer::new(HASH, index.pack_checksum().to_owned());
    for kind in gunnar_bitmap::Kind::ALL {
        writer.type_bitmap_encoded(kind, index.type_bitmaps()[kind.slot()].clone());
    }
    let mut dropped = None;
    for i in 0..index.entries().len() {
        let mut bm = index.resolve(i).expect("resolve");
        if i == victim {
            // The highest set bit. git writes commits first in a pack, so the
            // highest pack position in a reachability bitmap is a tree or a blob
            // and never the commit itself — which keeps this a pure "one object
            // went missing" and not an unrelated "the commit is absent from its
            // own bitmap".
            let bit = bm
                .iter_set_bits()
                .last()
                .expect("a bitmapped commit reaches more than nothing");
            bm.unset(bit);
            dropped = Some(bit);
        }
        writer.push_commit(index.entries()[i].commit_index_pos, 0, bm);
    }
    writer.xor_policy(gunnar_bitmap::XorPolicy::PreviousIfSmaller);
    let bytes = writer.to_vec().expect("write");
    assert!(dropped.is_some(), "nothing was sabotaged");
    std::fs::write(&path, &bytes).expect("install the damaged bitmap");

    // 1. Our comparison sees it.
    let store = f.store();
    let short = from_bitmap("sabotaged", &store, &[f.head], &[]);
    assert_ne!(
        short.objects, honest.objects,
        "a bitmap with one object dropped selected the SAME set as the walk. The equality \
         assertions in this file cannot see a one-object under-send, which is the only \
         failure mode that matters, and every green from them is worthless."
    );
    assert_eq!(
        short.objects.len() + 1,
        honest.objects.len(),
        "exactly one object should have gone missing, got {}",
        diff(&short.objects, &honest.objects)
    );
    drop(store);

    // 2. So does stock git, aimed at the damaged entry.
    let (ok, out) = git_try(&f.bare, &["rev-list", "--test-bitmap", &f.head.to_string()]);
    assert!(
        !ok,
        "git accepted a bitmap that is missing an object for HEAD, so `--test-bitmap` \
         cannot see this class of bug and every green from it here is worthless:\n{out}"
    );
    assert!(
        out.contains("mismatch") || out.contains("error") || out.contains("fatal"),
        "git failed, but not because of a bitmap mismatch, so the red above may be for an \
         unrelated reason:\n{out}"
    );
}

/// A `.bitmap` describing a **different pack** is declined, not trusted.
///
/// The stale-sidecar case, and it is the one where a wrong answer is silent:
/// positions resolving against the wrong `.idx` yield a set of the right size
/// made of the wrong objects. Corrupting the pack checksum is the cheapest way
/// to make the file describe a pack that is not there.
#[test]
fn a_bitmap_for_another_pack_falls_back_to_the_walk() {
    let _serial = serial();
    let f = Fixture::build(130);
    let store = f.store();
    let honest = store.selection(&[f.head], &[]).expect("the walk");
    store.gc().expect("gc");
    drop(store);

    let path = f.bitmap_path();
    let mut bytes = std::fs::read(&path).expect("read");
    // Byte 12 is the first byte of the pack checksum. Flipping it also breaks
    // the trailing hash, so this covers both refusals at once — which is fine:
    // the property under test is *that it falls back*, and the two arms of
    // `PackBitmap::at` are each documented where they are.
    bytes[12] ^= 0xff;
    std::fs::write(&path, &bytes).expect("write");

    let store = f.store();
    let (bm_before, walk_before) = GixStore::bitmap_selection_counts();
    let sel = store.selection(&[f.head], &[]).expect("a selection");
    let (bm_after, walk_after) = GixStore::bitmap_selection_counts();
    assert_eq!(
        (bm_after, walk_after),
        (bm_before, walk_before + 1),
        "a bitmap describing another pack was used anyway"
    );
    assert_eq!(
        sel.objects,
        honest.objects,
        "the fallback did not answer what the walk answers: {}",
        diff(&sel.objects, &honest.objects)
    );
}