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