bench/src/store_bytes.rs
//! **How many bytes a repository's object store holds — on either engine.**
//!
//! One counter, three arms: `gunnar.real_repo_clone`, `gunnar.vs_forge_fetch_gc`
//! and `gunnar.vs_forge_gc_orders`. It replaces a `read_dir` of
//! `objects/pack/*.pack` that was correct for exactly one of the two engines and
//! returned **0** for the other.
//!
//! # The defect this exists to end
//!
//! `h2h_pack_bytes` summed `<repo>/objects/pack/*.pack`. A znippy repository has
//! no `objects/` directory at all — its bytes live in
//! `<repo>/gunnar-git.znippy/objects.pack` — so the counter returned `Ok(0)`,
//! and `Ok(0)` is indistinguishable from *an empty repository*. Both columns of
//! `real_repo_clone` read 0 (measured, the laptop, 2026-08-10 — see the report), and a
//! head-to-head row of `0 / 0` does not look broken, **it looks like a tie**.
//! That is the familiar failure: five instruments measured the walk path and published
//! it as the bitmap path, and *"that is the definition of a change no test can
//! see"*.
//!
//! So this module refuses. A path whose shape it does not recognise is an
//! `Err` with the directory listing in it, never a zero — the same direction
//! `GitServe::select` fails in when it returns `Ok(None)` rather than guessing,
//! and the same direction the seam-1 server fails in rather than serving a short
//! pack.
//!
//! # The two shapes, and what makes their numbers the same quantity
//!
//! | | git (`storage-git-gix`, gitea, Forgejo, any stock repository) | znippy (`znippy-plugin-git`) |
//! |---|---|---|
//! | **payload** — *the comparable number* | `objects/pack/*.pack` | `gunnar-git.znippy/objects.pack` |
//! | index, derived, rebuildable | `*.idx`, `*.rev`, `*.bitmap`, `multi-pack-index*` | `objects.tail`, `objects.exploded` (both redb) |
//! | logs | — | `objects.pack.journal`, `refs.log` |
//! | sealed snapshots | — | `repository.znippy`, `repository.g<N>.znippy` |
//!
//! **`payload` is one quantity under one name and not two**: on both engines it
//! is *the verbatim pack bytes the store was handed and kept*. That is not an
//! analogy, it is the pack-copy contract read off the disk, and it is
//! measurable rather than argued — the same push into both engines, the laptop,
//! 2026-08-10, 180 objects:
//!
//! ```text
//! git shape objects/pack/pack-b502ec…6c607.pack 1 246 466
//! znippy shape gunnar-git.znippy/objects.pack 1 246 466
//! ```
//!
//! Byte for byte. The two engines' `git-store-serve` both announce it the same
//! way — `1 verbatim pack(s), 1246466 bytes` — which is the same reason the
//! `GUNNAR_BENCH_COPY_STATS` receipt is byte-identical on both arms.
//!
//! # Why `objects.pack` and not the sealed archive
//!
//! `GitStore`'s own field comment settles it: `blobs` is *"the verbatim pack
//! bytes. The **truth**"*, and `archive` is *"the sealed znippy archive
//! `gc` compacts. Only GC and `seal` touch it"*. `seal_generation_zero` **copies**
//! `objects.pack` into the new generation and leaves it in place; nothing ever
//! truncates it. So `objects.pack` is always the complete live payload and a
//! `repository*.znippy` is a snapshot *of* it — counting both would count the
//! same bytes twice.
//!
//! They are still reported, in [`StoreBytes::sealed`], because they are real
//! bytes on a real disk and because **they are what gunnar's `gc` moves**: on
//! this engine a `gc` leaves `objects.pack` untouched and writes a sealed
//! generation beside it, so an arm asking *"did maintenance do anything"* has to
//! look at [`StoreBytes::total`], not at `payload`. See [`StoreBytes::moved`].
//!
//! # Why not the alternatives
//!
//! * **Extend the `GUNNAR_BENCH_COPY_STATS` receipt.** It is the established
//! store-shaped channel and it was the first candidate, and it does not reach
//! any of the three arms. The receipt is written by
//! `crates/gunnar-server/examples/git-store-serve.rs` and its edda twin —
//! **seam 1** — and all three broken arms are on other seams:
//! `real_repo_clone` drives a real `gunnar serve` out of `installtest`, and
//! the two gc arms drive a whole server binary in a podman container (**seam
//! 2**). Neither ever sets the variable. It is also the wrong *quantity*: the
//! receipt counts a pack **emitted** for one fetch, and every one of these
//! three arms asks what the server **stored**. And a gc arm needs a reading
//! with no fetch in flight at all, which a per-serve receipt cannot give.
//! * **Ask the store through `GitOps` / `GitServe`.** That is the right answer
//! and it cannot be had here. The contract is frozen and carries no
//! "how big are you" method (`gc()`'s [`GcReport`] carries `bytes_before` /
//! `bytes_after`, but only for a run that has already happened, and only for
//! the archive). `bench/` also links **no engine crate** by construction —
//! see [`crate::engine`] — so there is nothing here to call it on. Recorded as
//! a gap in the report, not patched around by editing the trait.
//! * **`du` the store directory.** That is [`StoreBytes::total`], and it is
//! reported. It is not the comparable number: it folds in two redb databases
//! whose *apparent* size is not their allocated size (`objects.exploded` was
//! measured at 1028 MiB apparent / 608 MiB allocated on the linear fixture),
//! so "the size of the store" would depend on which of the two you meant and
//! on the filesystem's sparse-file behaviour. `payload` depends on neither:
//! `objects.pack` is written by append and is not sparse. Every size here is
//! **apparent** (`metadata().len()`), which is what `objects/pack/*.pack` has
//! always been measured with, so the git column does not move by one byte.
use std::collections::BTreeMap;
use std::path::Path;
use anyhow::{bail, Result};
/// The store layout a repository turned out to have.
///
/// Detected, never configured. A bench that was *told* which engine it was
/// looking at would report the told answer when the two disagreed, and the whole
/// reason this module exists is that a store was not the shape its caller
/// assumed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StoreShape {
/// An ordinary git object database: `objects/pack`, `objects/??`, `refs`.
/// Stock git, gitea, Forgejo, and gunnar's `engine-gix` arm, which
/// deliberately writes a store `git` itself can read.
Git,
/// `znippy-plugin-git`: one `gunnar-git.znippy` directory, no `objects/`.
Znippy,
}
impl StoreShape {
pub fn name(self) -> &'static str {
match self {
StoreShape::Git => "git",
StoreShape::Znippy => "znippy",
}
}
}
impl std::fmt::Display for StoreShape {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
/// What one repository's object store holds, split so that the comparable part
/// is comparable and the rest is still visible.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StoreBytes {
/// Which layout produced these numbers. Goes on the row: a reader who does
/// not know which definition a figure was taken under cannot check it.
pub shape: StoreShape,
/// **The comparable number.** The verbatim pack bytes this store holds. See
/// the module docs for why the two engines' versions of this are the same
/// quantity and not two quantities under one name.
pub payload: u64,
/// How many **files** the payload is spread over. git's pack count, and
/// always 1 for znippy.
///
/// ⚠ Read the name literally: it is not "how many packs are in there". A
/// znippy store appends every pack it is handed into one `objects.pack`, so
/// a store holding two pushes reads `containers = 1` with a payload that is
/// the sum of both (measured, the laptop 2026-08-10: a 180-object push and a
/// 600-object push into one repository, `containers=1 payload=4 162 494`).
/// Counting the appended packs would mean parsing `objects.pack.journal`,
/// which is a znippy-internal format `bench/` has no business knowing. So
/// `packs_before == packs_after` is the *expected* reading on that engine
/// and [`Self::moved`] does not rely on it.
pub containers: u64,
/// Derived indexes and logs: rebuildable from `payload`, and excluded from
/// it on **both** sides, exactly as git's own pack-byte count has always
/// excluded `.idx` and `.bitmap`.
pub index: u64,
/// Sealed snapshots of the payload — `repository*.znippy`. Always 0 on the
/// git shape, which has no such thing. **This is what gunnar's `gc`
/// produces**, so an arm that wants to know whether maintenance happened has
/// to read it (or [`total`](Self::total)) rather than `payload`.
pub sealed: u64,
/// Every byte of every file under the object store, `payload` and `index`
/// and `sealed` and anything this module did not classify. The number `du
/// --apparent-size` would give.
pub total: u64,
}
impl StoreBytes {
/// Did maintenance change anything at all?
///
/// **`payload` alone cannot answer this and answering it wrong is the whole
/// hazard.** `git gc` rewrites `objects/pack`, so the git shape moves in
/// `payload` and `containers`. gunnar's `gc` seals and compacts *the
/// archive* and leaves `objects.pack` byte-for-byte identical (measured,
/// the laptop, 2026-08-10: `1 246 466 -> 1 246 466` payload while a
/// `repository.g1.znippy` of `1 257 574` appeared beside it), so on the
/// znippy shape the only thing that moves is `sealed`, and therefore
/// `total`. A `moved()` that read `payload` would report gunnar's `gc` as a
/// no-op — which is precisely the "a gc that silently no-ops looks exactly
/// like a real result" failure the gc arms exist to refuse.
pub fn moved(&self, after: &StoreBytes) -> bool {
self.payload != after.payload
|| self.containers != after.containers
|| self.sealed != after.sealed
|| self.total != after.total
}
/// One line for an error message or a log: everything, so a surprising
/// number can be argued with rather than guessed at.
pub fn describe(&self) -> String {
format!(
"{} shape: payload={} B in {} container(s), index={} B, sealed={} B, total={} B",
self.shape, self.payload, self.containers, self.index, self.sealed, self.total
)
}
}
/// A store's files, as `(path relative to the repository directory, bytes)`.
///
/// The seam between *finding* the files and *classifying* them, and it is a seam
/// because the two gc arms cannot read gitea's and Forgejo's stores from the
/// host at all — those live inside a container's user namespace and are reached
/// through `podman exec`. One classifier, two collectors, so a containerised
/// store and a bind-mounted one cannot come to two different definitions of the
/// same column. That is the failure `Forge::pack_stats` already shipped once:
/// busybox `find` has no `-printf`, the pipeline's exit status was `cat`'s, and
/// both Go forges reported 0 packs and 0 bytes on both sides of their own `gc`.
pub type Listing = Vec<(String, u64)>;
/// Every file under `repo`, relative to it. The host-filesystem collector.
///
/// Symlinks are not followed and their target size is not counted: a store that
/// points somewhere else is not a store whose bytes are here.
pub fn listing(repo: &Path) -> Result<Listing> {
fn walk(root: &Path, dir: &Path, out: &mut Listing) -> Result<()> {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
// A directory that vanished under us is not a file we can count, and
// it is not a reason to fail: the classifier's own refusal is what
// catches a store that is not there.
Err(_) => return Ok(()),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
let kind = entry.file_type()?;
if kind.is_dir() {
walk(root, &path, out)?;
} else if kind.is_file() {
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
out.push((rel, entry.metadata()?.len()));
}
}
Ok(())
}
let mut out = Listing::new();
walk(repo, repo, &mut out)?;
out.sort();
Ok(out)
}
/// The one shell command that produces a [`Listing`] **inside a container**.
///
/// POSIX `find` plus `wc -c`, and deliberately not `find -printf` or `stat -c`:
/// gitea's and Forgejo's images are Alpine, their `find` and `stat` are
/// busybox's, and neither supports the format flags. A `-printf` that is not
/// understood makes the pipeline print nothing and exit 0, which is the shape
/// this whole module is about.
///
/// Feed the output to [`parse_listing`].
pub fn listing_command(dir: &str) -> String {
format!(
"cd {dir} 2>/dev/null || exit 0; \
find . -type f 2>/dev/null | while IFS= read -r f; do \
printf '%s\\t%s\\n' \"$(wc -c < \"$f\")\" \"${{f#./}}\"; \
done"
)
}
/// The other half of [`listing_command`]: `<bytes>\t<relative path>` per line.
///
/// A line that does not parse is an **error**, not a skipped line. Silently
/// dropping malformed output is how the busybox `-printf` bug produced a
/// confident zero.
pub fn parse_listing(text: &str) -> Result<Listing> {
let mut out = Listing::new();
for line in text.lines() {
let line = line.trim_end_matches('\r');
if line.trim().is_empty() {
continue;
}
let Some((size, path)) = line.split_once('\t') else {
bail!("a store listing line carries no tab and cannot be parsed: {line:?}");
};
let bytes: u64 = size
.trim()
.parse()
.map_err(|e| anyhow::anyhow!("a store listing line has a bad size {size:?}: {e}"))?;
out.push((path.trim().to_string(), bytes));
}
out.sort();
Ok(out)
}
/// The store root a znippy repository keeps its bytes in, relative to the
/// repository directory. `crates/gunnar-server/src/storage/znippy_engine.rs`'s
/// `store_root`, which puts it *inside* the git directory, beside where
/// `objects/` would have been, "because it **is** the object storage".
const ZNIPPY_DIR: &str = "gunnar-git.znippy";
/// The verbatim blob region — `GitStore`'s `blobs`, "the truth".
const ZNIPPY_PAYLOAD: &str = "objects.pack";
/// **Classify a listing and count it.** The whole instrument.
///
/// `label` is only for the refusal message; it should name the repository the
/// way the arm's reader would recognise it.
pub fn classify(label: &str, files: &Listing) -> Result<StoreBytes> {
let znippy: Vec<&(String, u64)> = files
.iter()
.filter(|(p, _)| p.starts_with(&format!("{ZNIPPY_DIR}/")))
.collect();
let git_objects: Vec<&(String, u64)> = files
.iter()
.filter(|(p, _)| p.starts_with("objects/"))
.collect();
// Both shapes at once is not "pick one", it is a store nobody meant to
// build: a repository that a gix arm and a znippy arm have both written
// into, whose two halves disagree about what it holds. The bench server's
// own store_root comment says the same thing about two engines at one root —
// "whichever opened second would either refuse or — worse — succeed".
if !znippy.is_empty() && git_objects.iter().any(|(p, _)| p.ends_with(".pack")) {
bail!(
"{label} carries BOTH a {ZNIPPY_DIR}/ store and packs in objects/pack. That is two \
engines' layouts in one repository, and any single number taken off it is one \
engine's bytes labelled with both engines' names.\n{}",
render(files)
);
}
if !znippy.is_empty() {
let size = |name: &str| -> u64 {
znippy
.iter()
.find(|(p, _)| p == &format!("{ZNIPPY_DIR}/{name}"))
.map(|(_, b)| *b)
.unwrap_or(0)
};
let payload = size(ZNIPPY_PAYLOAD);
let sealed: u64 = znippy
.iter()
.filter(|(p, _)| {
let f = p.rsplit('/').next().unwrap_or("");
f.starts_with("repository") && f.ends_with(".znippy")
})
.map(|(_, b)| *b)
.sum();
let total: u64 = znippy.iter().map(|(_, b)| *b).sum();
return Ok(StoreBytes {
shape: StoreShape::Znippy,
payload,
// One blob region, always — see the field's doc. Not a
// placeholder and not a pack count: the store appends every pack
// into `objects.pack` and there is never a second file.
containers: 1,
index: total.saturating_sub(payload).saturating_sub(sealed),
sealed,
total,
});
}
// The git shape. `objects/` present is enough — an empty bare repository has
// `objects/pack` and no packs in it, and **0 is the right answer there**.
// What must never come back as 0 is a store this function did not recognise,
// which is the `bail!` below.
let has_objects_dir = files.iter().any(|(p, _)| p.starts_with("objects/"))
|| files
.iter()
.any(|(p, _)| p == "HEAD" || p == "config" || p.starts_with("refs/"));
if !has_objects_dir {
bail!(
"{label} holds no object store this bench can read: no objects/ directory and no \
{ZNIPPY_DIR}/ store. A byte count taken here would be 0, and a 0 from a counter \
pointed at the wrong place is shaped exactly like an empty repository — which is \
how a head-to-head row of 0/0 comes to read as a tie.\n{}",
render(files)
);
}
let mut payload = 0u64;
let mut containers = 0u64;
let mut index = 0u64;
let mut total = 0u64;
for (path, bytes) in files {
total += bytes;
if !path.starts_with("objects/pack/") {
continue;
}
match path.rsplit('.').next().unwrap_or("") {
"pack" => {
payload += bytes;
containers += 1;
}
"idx" | "rev" | "bitmap" | "promisor" | "keep" | "mtimes" => index += bytes,
_ => index += bytes, // multi-pack-index, tmp_pack_*, anything else
}
}
Ok(StoreBytes {
shape: StoreShape::Git,
payload,
containers,
index,
sealed: 0,
total,
})
}
/// **The directory this repository keeps its pack bytes in**, whichever engine
/// wrote it.
///
/// One place, because three call sites had `repo.join("objects/pack")` written
/// into them and every one of them was a page-cache instrument:
/// `nornir-bench`'s `pack_files`, and `readside`'s `evict` and
/// `pack_resident_bytes`. `mincore` and `posix_fadvise` are only measurements
/// of *this store* if they are pointed at the files the engine actually reads,
/// and on the znippy arm `objects/pack` is not there at all — the probe exited
/// non-zero with an empty stderr and `gunnar.fetch_readside` reported it as
/// "the full readside child exited exit status: 1".
///
/// The shape comes from [`classify`], so a directory that is neither layout is
/// an `Err` naming what is in it, never a silently empty file list.
pub fn pack_dir(repo: &Path) -> Result<std::path::PathBuf> {
Ok(match of(repo)?.shape {
StoreShape::Git => repo.join("objects").join("pack"),
StoreShape::Znippy => repo.join(ZNIPPY_DIR),
})
}
/// [`classify`] over the host filesystem. The call every arm on this side makes.
pub fn of(repo: &Path) -> Result<StoreBytes> {
let files = listing(repo)?;
classify(&repo.display().to_string(), &files)
}
/// A listing, folded to one line per directory, for a refusal message.
///
/// The whole listing would be thousands of lines on a real corpus member and
/// nobody would read it; the per-directory roll-up is enough to see *where* the
/// bytes are, which is the only question a reader of this message has.
fn render(files: &Listing) -> String {
if files.is_empty() {
return " (the directory holds no files at all)".to_string();
}
let mut dirs: BTreeMap<&str, (u64, u64)> = BTreeMap::new();
for (path, bytes) in files {
let dir = path.rsplit_once('/').map(|(d, _)| d).unwrap_or(".");
let e = dirs.entry(dir).or_insert((0, 0));
e.0 += 1;
e.1 += bytes;
}
let mut out = String::from(" what is actually there:\n");
for (dir, (n, bytes)) in dirs {
out.push_str(&format!(" {dir}/ {n} file(s), {bytes} byte(s)\n"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn l(pairs: &[(&str, u64)]) -> Listing {
let mut v: Listing = pairs.iter().map(|(p, b)| (p.to_string(), *b)).collect();
v.sort();
v
}
/// The two shapes, same content, and the number they must agree on.
///
/// These are the real listings measured on the laptop, 2026-08-10, from one
/// 180-object push prepared into both engines by their own
/// `git-store-serve prepare`.
#[test]
fn the_two_engines_report_the_same_payload_for_the_same_push() {
let git = classify(
"gix arm",
&l(&[
("HEAD", 23),
("config", 92),
("objects/pack/pack-b502ec.idx", 6112),
("objects/pack/pack-b502ec.pack", 1_246_466),
("refs/heads/master", 41),
]),
)
.expect("the gix arm's store is a git store");
let znippy = classify(
"znippy arm",
&l(&[
("gunnar-git.znippy/objects.exploded", 4_747_264),
("gunnar-git.znippy/objects.pack", 1_246_466),
("gunnar-git.znippy/objects.pack.journal", 424),
("gunnar-git.znippy/objects.tail", 1_589_248),
("gunnar-git.znippy/refs.log", 3600),
]),
)
.expect("the znippy arm's store is a znippy store");
assert_eq!(git.shape, StoreShape::Git);
assert_eq!(znippy.shape, StoreShape::Znippy);
assert_eq!(git.payload, 1_246_466);
assert_eq!(znippy.payload, 1_246_466, "this is the number that read 0");
assert_eq!(git.payload, znippy.payload);
// The indexes are NOT the same and are not claimed to be. They are
// excluded from `payload` on both sides, which is what makes the
// comparison one.
assert_eq!(git.index, 6112);
assert_eq!(znippy.index, 4_747_264 + 424 + 1_589_248 + 3600);
assert_eq!(znippy.sealed, 0);
}
/// The red test, kept as a test: the old rule, applied to a znippy store,
/// finds nothing. If a future change makes `objects/pack` appear in a znippy
/// repository this fails and says so.
#[test]
fn a_znippy_store_has_no_objects_pack_at_all() {
let files = l(&[
("gunnar-git.znippy/objects.pack", 1_246_466),
("gunnar-git.znippy/objects.tail", 1_589_248),
]);
assert!(
!files.iter().any(|(p, _)| p.starts_with("objects/pack/")),
"the old counter's directory exists after all; its 0 was not a miss"
);
assert_eq!(classify("znippy", &files).unwrap().payload, 1_246_466);
}
/// A directory that is not a store refuses. This is requirement 3: a column
/// that cannot be measured must not report 0.
#[test]
fn an_unrecognised_directory_refuses_instead_of_reporting_zero() {
let err = classify("scratch", &l(&[("notes.txt", 10)])).unwrap_err();
let text = err.to_string();
assert!(text.contains("no object store"), "{text}");
assert!(
text.contains("0/0"),
"the message must name the failure mode"
);
}
/// An empty bare repository is 0 bytes and that is a fact, not a miss. The
/// difference between this and the test above is the whole design.
#[test]
fn an_empty_git_repository_is_a_real_zero() {
let s = classify("empty", &l(&[("HEAD", 23), ("config", 92)])).unwrap();
assert_eq!(s.shape, StoreShape::Git);
assert_eq!(s.payload, 0);
assert_eq!(s.containers, 0);
}
/// An empty znippy repository, likewise — `objects.pack` exists and is 0
/// bytes long. Measured on a fresh `gunnar repo create`, the laptop 2026-08-10.
#[test]
fn an_empty_znippy_repository_is_a_real_zero() {
let s = classify(
"empty",
&l(&[
("gunnar-git.znippy/objects.exploded", 3_686_400),
("gunnar-git.znippy/objects.pack", 0),
("gunnar-git.znippy/objects.pack.journal", 200),
("gunnar-git.znippy/objects.tail", 3_686_400),
("gunnar-git.znippy/refs.log", 1800),
]),
)
.unwrap();
assert_eq!(s.shape, StoreShape::Znippy);
assert_eq!(s.payload, 0);
assert!(
s.total > 0,
"the store is there; it is the payload that is 0"
);
}
/// **The instrument must be able to tell two different stores apart.** A
/// fixture where the two numbers happen to agree cannot distinguish a
/// working counter from one that returns a constant.
#[test]
fn a_bigger_store_reads_bigger_on_both_shapes() {
let small_git = classify(
"small/git",
&l(&[
("objects/pack/p.pack", 94_912),
("objects/pack/p.idx", 2000),
]),
)
.unwrap();
let big_git = classify("big/git", &l(&[("objects/pack/p.pack", 1_246_466)])).unwrap();
let small_zn = classify(
"small/znippy",
&l(&[("gunnar-git.znippy/objects.pack", 94_912)]),
)
.unwrap();
let big_zn = classify(
"big/znippy",
&l(&[("gunnar-git.znippy/objects.pack", 1_246_466)]),
)
.unwrap();
assert_eq!(small_git.payload, small_zn.payload);
assert_eq!(big_git.payload, big_zn.payload);
assert!(small_zn.payload < big_zn.payload);
assert!(small_zn.payload > 0);
}
/// gunnar's `gc` leaves the payload alone and writes a sealed generation, so
/// `moved()` has to see the archive. Measured, the laptop 2026-08-10.
#[test]
fn a_znippy_gc_moves_even_though_the_payload_does_not() {
let before = classify(
"before",
&l(&[
("gunnar-git.znippy/objects.pack", 1_246_466),
("gunnar-git.znippy/objects.tail", 3_686_400),
]),
)
.unwrap();
let after = classify(
"after",
&l(&[
("gunnar-git.znippy/objects.pack", 1_246_466),
("gunnar-git.znippy/objects.tail", 3_686_400),
("gunnar-git.znippy/repository.g1.znippy", 1_257_574),
]),
)
.unwrap();
assert_eq!(
before.payload, after.payload,
"the blob region is untouched"
);
assert_eq!(before.sealed, 0);
assert_eq!(after.sealed, 1_257_574);
assert!(
before.moved(&after),
"a maintenance that sealed 1.2 MB must not report as a no-op"
);
}
/// `git gc` moves in the payload, which is the case that already worked and
/// must keep working.
#[test]
fn a_git_gc_moves_in_the_payload() {
let before = classify(
"before",
&l(&[
("objects/pack/a.pack", 500_000),
("objects/pack/b.pack", 400_000),
]),
)
.unwrap();
let after = classify("after", &l(&[("objects/pack/c.pack", 700_000)])).unwrap();
assert_eq!(before.containers, 2);
assert_eq!(after.containers, 1);
assert!(before.moved(&after));
}
/// Two engines in one directory is refused rather than half-counted.
#[test]
fn both_layouts_at_one_root_is_a_refusal() {
let err = classify(
"confused",
&l(&[
("gunnar-git.znippy/objects.pack", 10),
("objects/pack/p.pack", 20),
]),
)
.unwrap_err();
assert!(err.to_string().contains("two engines"), "{err}");
}
/// **Off a real filesystem**, not off a hand-written listing.
///
/// [`listing`] is the collector the two host-side arms actually use, and a
/// classifier proven only against literals cannot catch a walker that
/// misses a subdirectory or strips the wrong prefix — which is the half of
/// this module that touches the disk. The trees below are the exact file
/// names and sizes measured on the laptop, 2026-08-10, from one 180-object push
/// prepared into both engines.
#[test]
fn the_counter_reads_both_shapes_off_a_real_directory() {
let root = tempfile::tempdir().expect("tempdir");
let write = |rel: &str, bytes: u64| {
let p = root.path().join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, vec![0u8; bytes as usize]).unwrap();
};
write("gitshape/HEAD", 23);
write("gitshape/config", 92);
write("gitshape/refs/heads/master", 41);
write("gitshape/objects/pack/pack-b502ec.idx", 6112);
write("gitshape/objects/pack/pack-b502ec.pack", 124_646);
write("znshape/gunnar-git.znippy/objects.pack", 124_646);
write("znshape/gunnar-git.znippy/objects.pack.journal", 424);
write("znshape/gunnar-git.znippy/objects.tail", 4096);
write("znshape/gunnar-git.znippy/objects.exploded", 4096);
write("znshape/gunnar-git.znippy/refs.log", 3600);
let git = of(&root.path().join("gitshape")).expect("a git store");
let zn = of(&root.path().join("znshape")).expect("a znippy store");
assert_eq!(git.shape, StoreShape::Git);
assert_eq!(zn.shape, StoreShape::Znippy);
assert_eq!(git.payload, 124_646);
assert_eq!(zn.payload, 124_646);
assert_eq!(git.containers, 1);
assert_eq!(zn.containers, 1);
// The old rule, run over the same znippy directory, for the contrast.
assert!(
!root.path().join("znshape/objects/pack").exists(),
"the counter that read this directory before found no objects/pack, and reported 0"
);
// And a directory that is neither refuses rather than reporting 0.
std::fs::create_dir_all(root.path().join("neither")).unwrap();
std::fs::write(root.path().join("neither/notes.txt"), b"hi").unwrap();
assert!(of(&root.path().join("neither")).is_err());
}
/// The container collector's parser. A malformed line is an error, because
/// the last time this pipeline printed nothing it was read as "0 packs".
#[test]
fn the_container_listing_parses_and_refuses_garbage() {
let parsed = parse_listing("1246466\tobjects/pack/p.pack\n6112\tobjects/pack/p.idx\n")
.expect("well-formed");
assert_eq!(classify("c", &parsed).unwrap().payload, 1_246_466);
assert!(parse_listing("no tab here").is_err());
assert!(parse_listing("notanumber\tp.pack").is_err());
// An empty listing is not a parse error — it is an empty directory, and
// `classify` is what refuses it.
assert!(classify("c", &parse_listing("").unwrap()).is_err());
}
}