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

nordisk/gunnar

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