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

nordisk/gunnar

crates/gunnar-wire/tests/common/mod.rs

//! A repository built object by object in memory, and the little pkt-line
//! reader the byte-level tests read responses with.
//!
//! In-memory on purpose: these tests are about the *protocol*, and a fixture
//! that needs a filesystem would quietly stop the appliance case from being
//! testable at all.
//!
//! # 2026-08-10 — one store, not two
//!
//! It used to hold a `MemObjectStore` **and** a `MemRefStore`, because
//! `ObjectStore` and `RefStore` were two traits. They are one now
//! ([`GitServe`], which extends `GitOps`), so the fixture holds one
//! [`MemGitServe`] and the references live in it. That is not only tidier: an
//! advertisement and the pack it promises are now provably one snapshot,
//! because there is only one thing to snapshot.
//!
//! Two consequences worth naming, because tests here depend on both:
//!
//! * **Objects arrive as packs.** `GitOps` has no `write(kind, bytes)`; the
//!   only way in is [`GitOps::put_pack`]. So [`commit`] builds a three-object
//!   pack (blob, tree, commit) with `gunnar_pack`'s writer — the same writer
//!   the shipping send-pack path uses, rather than a second spelling of git's
//!   format here (one implementation).
//! * **`HEAD` is not a row** (`locked_on_target.md`'s first contract question). It is set through
//!   [`GitServe::set_head`] and read through [`GitServe::head`], and
//!   `Fixture::build` asserts it does not appear in `refs()`.

#![allow(dead_code)]

use gix_hash::{Kind, ObjectId};
use gunnar_pktline::{decode, Decoded, Packet};
use gunnar_store::git_storage_trait::{
    Caps, Extent, GcReport, GitOps, GitServe, ObjType, Oid, PackStats, ReachSet, RefCas, RefRow,
    RefUpdate, Stored, TxId,
};
use gunnar_store::mem::MemGitServe;

/// The history every byte-level test runs against.
///
/// ```text
///   one ── two ─────── merge ── four        refs/heads/main
///     └─── three ──────┘                    refs/heads/side
///                    (annotated v1 on merge)
/// ```
///
/// A **merge**, not a line. A three-commit chain makes negotiation look easy:
/// the client's first `have` is always the answer. The second parent is what
/// catches a traversal that only follows `parent[0]`.
pub struct Fixture {
    /// Objects *and* references, wrapped in [`Counting`] so the cost suites can
    /// ask what a request charged the store.
    pub git: Counting<MemGitServe>,
    pub kind: Kind,
    pub one: ObjectId,
    pub two: ObjectId,
    pub three: ObjectId,
    pub merge: ObjectId,
    pub four: ObjectId,
    pub tag: ObjectId,
}

impl Fixture {
    pub fn build(kind: Kind) -> Self {
        let git = Counting::new(MemGitServe::new(kind));

        let one = commit(&git, kind, "a.txt", "one", &[], 1_700_000_001);
        let two = commit(&git, kind, "b.txt", "two", &[one], 1_700_000_002);
        let three = commit(&git, kind, "c.txt", "three", &[one], 1_700_000_003);
        let merge = commit(&git, kind, "d.txt", "merge", &[two, three], 1_700_000_004);
        let four = commit(&git, kind, "e.txt", "four", &[merge], 1_700_000_005);
        let tag = write_object(&git, kind, gix_object::Kind::Tag, &tag_bytes(&merge, "v1"));

        git.put_refs(&[
            RefUpdate::set("refs/heads/main", four.to_string()),
            RefUpdate::set("refs/heads/side", three.to_string()),
            RefUpdate::set("refs/tags/v1", tag.to_string()),
        ])
        .expect("install the references");
        git.set_head("refs/heads/main").expect("point HEAD at main");

        // Asserted rather than assumed: `HEAD` is a pseudo-ref and has
        // an accessor, not a row. Every advertisement test below reads the ref
        // stream, and a store that published `HEAD` in it would make each of
        // them assert the wrong shape while still passing.
        assert!(
            !git.refs()
                .expect("list refs")
                .iter()
                .any(|row| row.name == "HEAD"),
            "the fixture store publishes HEAD as a row; the contract says it must not"
        );

        Fixture {
            git,
            kind,
            one,
            two,
            three,
            merge,
            four,
            tag,
        }
    }

    /// Append `count` commits onto `tip`, returning the new tip.
    pub fn extend(&self, tip: ObjectId, count: usize, prefix: &str) -> ObjectId {
        self.extend_dated(tip, count, prefix, |index| 1_700_100_000 + index as i64)
    }

    /// [`Self::extend`] with the committer date chosen per commit.
    ///
    /// Negotiation is bounded by the oldest `have`'s committer date, so a test
    /// that wants a *constant* traversal cost across rounds has to control that
    /// date rather than inherit an ascending one.
    pub fn extend_dated(
        &self,
        tip: ObjectId,
        count: usize,
        prefix: &str,
        at: impl Fn(usize) -> i64,
    ) -> ObjectId {
        let mut head = tip;
        for index in 0..count {
            // Distinct content per commit, so the object count is
            // `3 * count` and not silently smaller because forty commits
            // shared one blob. A test whose expected number depends on
            // accidental deduplication is a test nobody can read.
            head = commit(
                &self.git,
                self.kind,
                &format!("{prefix}{index}.txt"),
                &format!("{prefix} {index}"),
                &[head],
                at(index),
            );
        }
        head
    }

    /// Every commit id from `tip` back down its first-parent line, `count`
    /// long, newest first, the order a git client offers `have`s in.
    pub fn first_parent_haves(&self, tip: ObjectId, count: usize) -> Vec<ObjectId> {
        let mut out = Vec::with_capacity(count);
        let mut current = tip;
        for _ in 0..count {
            out.push(current);
            let (_, data) = self
                .git
                .read(current.as_bytes())
                .expect("read a commit")
                .expect("the commit is here");
            let parent = gix_object::CommitRef::from_bytes(&data, self.kind)
                .expect("parse a commit")
                .parents()
                .next();
            match parent {
                Some(id) => current = id,
                None => break,
            }
        }
        out
    }

    // ── the reference namespace, through the one store ──────────────────────
    //
    // `RefStore::transaction` / `read` / `set_head` are gone. These are the
    // three shapes the suites used, spelled once here rather than at every
    // call site: `put_refs` is the force update `RefEdit::force` was, and
    // `refs()` is the listing `read` walked.

    /// Force `name` to `id`, whatever it held.
    pub fn force_ref(&self, name: &str, id: ObjectId) {
        self.git
            .put_refs(&[RefUpdate::set(name, id.to_string())])
            .unwrap_or_else(|e| panic!("install {name}: {e:#}"));
    }

    /// What `name` points at, or `None` if it is not there.
    ///
    /// Direct references only, which is every reference these suites install.
    /// A symbolic one would need [`gunnar_wire::RefSnapshot`], and the tests
    /// that care about symrefs use it.
    pub fn read_ref(&self, name: &str) -> Option<ObjectId> {
        self.git
            .refs()
            .expect("list refs")
            .into_iter()
            .find(|row| row.name == name)
            .and_then(|row| row.oid)
            .map(|raw| ObjectId::try_from(raw.as_slice()).expect("a well-sized oid"))
    }

    /// Every reference name in the store, sorted, `HEAD` excluded because it
    /// is not a row.
    pub fn ref_names(&self) -> Vec<String> {
        let mut names: Vec<String> = self
            .git
            .refs()
            .expect("list refs")
            .into_iter()
            .map(|row| row.name)
            .collect();
        names.sort();
        names
    }

    /// Delete every reference, leaving the objects where they are.
    ///
    /// What a mirror push into a fresh repository sees: a store that holds the
    /// history and names none of it. It used to be spelled *"build a second,
    /// empty `MemRefStore` beside the shared object store"*, which one store
    /// cannot do — and this says the same thing about the same store.
    pub fn clear_refs(&self) {
        let names: Vec<RefUpdate> = self
            .git
            .refs()
            .expect("list refs")
            .into_iter()
            .map(|row| RefUpdate::delete(row.name))
            .collect();
        if !names.is_empty() {
            self.git.put_refs(&names).expect("delete every reference");
        }
    }
}

/// A store that reports what was asked of it.
///
/// `has`, `header` and `read` are counted **separately and deliberately not
/// folded together**: an existence probe is not the same cost as inflating an
/// object, and a change that turned reads into probes would otherwise look like
/// a change that removed work.
///
/// # It wraps a `GitServe` now, and it no longer emits anything itself
///
/// It used to carry a whole-object pack writer, because `MemObjectStore` had
/// nothing to copy and the serve path refuses a store that cannot emit. That is
/// gone: [`MemGitServe::emit_pack`] is a genuine pack copy (`gunnar-store`
/// 2026-08-10), verified against `gunnar_pack::index_pack` as an outside
/// oracle, so this forwards to it and counts nothing extra. Three tests that
/// were `#[ignore]`d for want of an emitting backend run again because of it.
pub struct Counting<S = MemGitServe> {
    pub inner: S,
    reads: std::sync::atomic::AtomicUsize,
    /// Reads that came back a **commit**, kept apart from the rest.
    ///
    /// The walk's guard is a *ratio* — reads of commits over commits in the
    /// walk — and a total that mixes commits with their trees and blobs cannot
    /// express it. `nornir-bench`'s `commit_reads_per_commit` is the same
    /// number on a million-commit repository; this is the same number in
    /// `cargo test`, so the invariant is held by something that runs on every
    /// change and not only by a benchmark someone remembers to run.
    commit_reads: std::sync::atomic::AtomicUsize,
    headers: std::sync::atomic::AtomicUsize,
    contains: std::sync::atomic::AtomicUsize,
    /// Reference transactions (`put_refs_cas` calls): each is a durable commit on a
    /// real engine, so the count for a push is its fsync count (GUN-PERF).
    ref_txns: std::sync::atomic::AtomicUsize,
}

impl<S: GitServe> Counting<S> {
    pub fn new(inner: S) -> Self {
        Counting {
            inner,
            reads: std::sync::atomic::AtomicUsize::new(0),
            commit_reads: std::sync::atomic::AtomicUsize::new(0),
            headers: std::sync::atomic::AtomicUsize::new(0),
            contains: std::sync::atomic::AtomicUsize::new(0),
            ref_txns: std::sync::atomic::AtomicUsize::new(0),
        }
    }

    pub fn reset(&self) {
        for counter in [
            &self.reads,
            &self.commit_reads,
            &self.headers,
            &self.contains,
            &self.ref_txns,
        ] {
            counter.store(0, std::sync::atomic::Ordering::SeqCst);
        }
    }

    pub fn read_count(&self) -> usize {
        self.reads.load(std::sync::atomic::Ordering::SeqCst)
    }

    /// How many of the reads answered with a commit.
    pub fn commit_read_count(&self) -> usize {
        self.commit_reads.load(std::sync::atomic::Ordering::SeqCst)
    }

    /// How many reference transactions (`put_refs_cas` calls) were made.
    pub fn ref_txn_count(&self) -> usize {
        self.ref_txns.load(std::sync::atomic::Ordering::SeqCst)
    }

    pub fn contains_count(&self) -> usize {
        self.contains.load(std::sync::atomic::Ordering::SeqCst)
    }

    pub fn touches(&self) -> usize {
        self.reads.load(std::sync::atomic::Ordering::SeqCst)
            + self.headers.load(std::sync::atomic::Ordering::SeqCst)
            + self.contains.load(std::sync::atomic::Ordering::SeqCst)
    }
}

/// The twelve, forwarded. [`GitOps::has`] and `put_refs_cas` are counted; the rest are writes
/// or bulk index questions that no cost test asserts on, and inventing a
/// counter nothing reads would be a number that could drift unnoticed.
impl<S: GitServe> GitOps for Counting<S> {
    fn put(&self, pack: &[u8], refs: &[RefUpdate]) -> anyhow::Result<TxId> {
        self.inner.put(pack, refs)
    }
    fn put_pack(&self, bytes: &[u8]) -> anyhow::Result<TxId> {
        self.inner.put_pack(bytes)
    }
    fn put_refs(&self, updates: &[RefUpdate]) -> anyhow::Result<TxId> {
        self.inner.put_refs(updates)
    }
    fn get(&self, oid: Oid<'_>) -> anyhow::Result<Option<Stored>> {
        self.inner.get(oid)
    }
    /// Counted. This is the existence probe `ObjectStore::contains` was, and
    /// `contains_count()` is what `negotiate_cost.rs` asserts a floor on.
    fn has(&self, oid: Oid<'_>) -> anyhow::Result<bool> {
        self.contains
            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        self.inner.has(oid)
    }
    fn size(&self, oid: Oid<'_>) -> anyhow::Result<Option<u64>> {
        self.inner.size(oid)
    }
    fn extents(&self, oids: &[Oid<'_>]) -> anyhow::Result<Vec<Option<Extent>>> {
        self.inner.extents(oids)
    }
    fn refs(&self) -> anyhow::Result<Vec<RefRow>> {
        self.inner.refs()
    }
    fn update_ref(
        &self,
        name: &str,
        old: Option<Oid<'_>>,
        new: Option<Oid<'_>>,
    ) -> anyhow::Result<TxId> {
        self.inner.update_ref(name, old, new)
    }
    fn put_refs_cas(&self, edits: &[RefCas<'_>]) -> anyhow::Result<TxId> {
        self.ref_txns
            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        self.inner.put_refs_cas(edits)
    }
    fn reachable(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> anyhow::Result<Vec<Vec<u8>>> {
        self.inner.reachable(want, have)
    }
    fn gc(&self) -> anyhow::Result<GcReport> {
        self.inner.gc()
    }
}

impl<S: GitServe> GitServe for Counting<S> {
    fn read(&self, oid: Oid<'_>) -> anyhow::Result<Option<(ObjType, Vec<u8>)>> {
        self.reads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        let got = self.inner.read(oid)?;
        // Charged on what came back, not on what was asked for: the caller
        // does not know the kind before the read, so counting by the answer is
        // the only spelling that cannot be gamed by asking differently.
        if matches!(got, Some((ObjType::Commit, _))) {
            self.commit_reads
                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        }
        Ok(got)
    }
    fn header(&self, oid: Oid<'_>) -> anyhow::Result<Option<(ObjType, u64)>> {
        self.headers
            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        self.inner.header(oid)
    }
    /// Counted **once per id asked about**, not once per call. `sizes` is the
    /// bulk spelling of `header` and the negotiation path calls it a thousand
    /// at a time; charging it one would let a change that moved work from
    /// `header` into `sizes` read as a change that removed the work.
    fn sizes(&self, oids: &[Oid<'_>]) -> anyhow::Result<Vec<Option<u64>>> {
        self.headers
            .fetch_add(oids.len(), std::sync::atomic::Ordering::SeqCst);
        self.inner.sizes(oids)
    }
    fn head(&self) -> anyhow::Result<Option<RefRow>> {
        self.inner.head()
    }
    fn set_head(&self, target: &str) -> anyhow::Result<TxId> {
        self.inner.set_head(target)
    }
    fn emit_pack(
        &self,
        want: &[Oid<'_>],
        have: &[Oid<'_>],
        caps: &Caps,
        out: &mut dyn std::io::Write,
    ) -> anyhow::Result<PackStats> {
        self.inner.emit_pack(want, have, caps, out)
    }
    fn select(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> anyhow::Result<Option<ReachSet>> {
        self.inner.select(want, have)
    }
}

/// A reference name, checked against the rules that used to live in
/// `FullName::new`.
///
/// `FullName` died with `RefStore`; the rules did not, and they are
/// [`gunnar_store::refname::validate`] now — one authority, the same one
/// `receive_pack::parse_command` uses as its path-traversal boundary. This
/// keeps the *validating* half at the fixture's call sites so a test cannot
/// quietly install `refs/heads/../../etc/passwd` and call it a reference.
pub fn name(text: &str) -> String {
    gunnar_store::refname::validate(text).expect("test reference names are valid");
    text.to_owned()
}

/// Store `objects` as one pack and hand back their ids, in the order given.
///
/// `GitOps` takes packs and nothing else, which is the contract being honest:
/// an engine that stores pack entries verbatim has no place to put a loose
/// object. The writer is `gunnar_pack`'s — the one the shipping send-pack path
/// uses — rather than a second spelling of git's entry format in a test module
/// (one implementation); `common/mod.rs` writing its own headers is exactly
/// the twinning that let the old fixture and the code under test agree with
/// each other and with nothing else.
pub fn write_pack_of(
    git: &dyn GitOps,
    kind: Kind,
    objects: &[(gix_object::Kind, Vec<u8>)],
) -> Vec<ObjectId> {
    let mut bytes = Vec::new();
    let count = u32::try_from(objects.len()).expect("a fixture under 4 billion objects");
    let mut writer =
        gunnar_pack::PackWriter::new(&mut bytes, count, &gunnar_pack::WriteOptions::new(kind))
            .expect("start a pack");
    let mut ids = Vec::with_capacity(objects.len());
    for (obj_kind, data) in objects {
        writer.add_object(*obj_kind, data).expect("add an object");
        ids.push(gix_object::compute_hash(kind, *obj_kind, data).expect("hash an object"));
    }
    writer.finish().expect("finish the pack");
    git.put_pack(&bytes).expect("store the pack");
    ids
}

/// One object, stored.
pub fn write_object(
    git: &dyn GitOps,
    kind: Kind,
    obj_kind: gix_object::Kind,
    bytes: &[u8],
) -> ObjectId {
    write_pack_of(git, kind, &[(obj_kind, bytes.to_vec())])[0]
}

/// One commit, with its own blob and tree, on `parents`, dated `at`.
///
/// `pub` because the traversal tests in `src/graph.rs` build shapes
/// [`Fixture::extend_dated`] cannot — a merge of two lines, above all — and a
/// second commit writer under `src/` is exactly the twin one implementation
/// forbids.
///
/// The three objects go in as **one** pack rather than three. Nothing in these
/// suites can see the difference, and it keeps a 300-commit `extend` from
/// leaving nine hundred packs behind.
pub fn commit(
    git: &dyn GitOps,
    kind: Kind,
    file: &str,
    content: &str,
    parents: &[ObjectId],
    at: i64,
) -> ObjectId {
    let blob_bytes = format!("{content}\n").into_bytes();
    let blob =
        gix_object::compute_hash(kind, gix_object::Kind::Blob, &blob_bytes).expect("hash the blob");
    let tree_bytes = tree_bytes(file, &blob);
    let tree =
        gix_object::compute_hash(kind, gix_object::Kind::Tree, &tree_bytes).expect("hash the tree");

    let mut text = format!("tree {}\n", tree.to_hex());
    for parent in parents {
        text.push_str(&format!("parent {}\n", parent.to_hex()));
    }
    text.push_str(&format!(
        "author Gunnar Test <gunnar@example.invalid> {at} +0000\n\
         committer Gunnar Test <gunnar@example.invalid> {at} +0000\n\n{content}\n"
    ));

    let ids = write_pack_of(
        git,
        kind,
        &[
            (gix_object::Kind::Blob, blob_bytes),
            (gix_object::Kind::Tree, tree_bytes),
            (gix_object::Kind::Commit, text.into_bytes()),
        ],
    );
    ids[2]
}

fn tree_bytes(file: &str, blob: &gix_hash::oid) -> Vec<u8> {
    let mut out = Vec::new();
    out.extend_from_slice(b"100644 ");
    out.extend_from_slice(file.as_bytes());
    out.push(0);
    out.extend_from_slice(blob.as_bytes());
    out
}

fn tag_bytes(target: &gix_hash::oid, tag: &str) -> Vec<u8> {
    format!(
        "object {}\ntype commit\ntag {tag}\n\
         tagger Gunnar Test <gunnar@example.invalid> 1700000006 +0000\n\nrelease\n",
        target.to_hex()
    )
    .into_bytes()
}

/// Every pkt-line in a buffered response, plus whatever tail was not framed.
pub struct Response {
    pub packets: Vec<Packet>,
    pub tail: Vec<u8>,
}

impl Response {
    pub fn parse(bytes: &[u8]) -> Self {
        let mut packets = Vec::new();
        let mut rest = bytes;
        while let Ok(Decoded::Packet(packet, used)) = decode(rest) {
            packets.push(packet);
            rest = &rest[used..];
        }
        Response {
            packets,
            tail: rest.to_vec(),
        }
    }

    /// The text of every data packet, newline stripped.
    pub fn lines(&self) -> Vec<String> {
        self.packets
            .iter()
            .filter_map(|packet| packet.text_str().map(str::to_owned))
            .collect()
    }

    /// Is there a data packet whose text is exactly this?
    pub fn has_line(&self, text: &str) -> bool {
        self.lines().iter().any(|line| line == text)
    }

    /// The payload of one sideband, concatenated.
    ///
    /// The band byte is stripped here and nowhere else, so a test that gets a
    /// valid pack out of this has proven the band byte was present. That is
    /// the assertion, not an incidental detail.
    pub fn band(&self, band: u8) -> Vec<u8> {
        let mut out = Vec::new();
        for packet in &self.packets {
            if let Packet::Data(payload) = packet {
                if payload.first() == Some(&band) {
                    out.extend_from_slice(&payload[1..]);
                }
            }
        }
        out
    }

    /// The bytes that follow the `packfile` section header, undecoded.
    pub fn after_packfile_header(&self) -> Vec<u8> {
        let mut out = Vec::new();
        let mut seen = false;
        for packet in &self.packets {
            if let Packet::Data(payload) = packet {
                if seen {
                    out.extend_from_slice(payload);
                } else if payload.strip_suffix(b"\n").unwrap_or(payload) == b"packfile" {
                    seen = true;
                }
            }
        }
        out
    }
}

/// Frame `lines` as a v2 argument section: caps, delim, arguments, flush.
pub fn v2_request(command: &str, capabilities: &[&str], arguments: &[&str]) -> Vec<u8> {
    let mut out = Vec::new();
    gunnar_pktline::write_text(&mut out, format!("command={command}").as_bytes()).unwrap();
    for capability in capabilities {
        gunnar_pktline::write_text(&mut out, capability.as_bytes()).unwrap();
    }
    gunnar_pktline::write_delim(&mut out).unwrap();
    for argument in arguments {
        gunnar_pktline::write_text(&mut out, argument.as_bytes()).unwrap();
    }
    gunnar_pktline::write_flush(&mut out).unwrap();
    out
}

/// How many objects a pack holds, according to **gix's own indexer** rather
/// than to gunnar re-reading its own bytes.
pub fn index_and_count(pack: &[u8], kind: Kind) -> u32 {
    let mut cursor = std::io::Cursor::new(pack);
    gunnar_pack::index_pack(
        &mut cursor,
        None,
        &gunnar_pack::IndexOptions::new(kind),
        None,
    )
    .expect("gix could not index the pack gunnar produced")
    .num_objects
}

// ── the receive-pack side ───────────────────────────────────────────────────

pub fn push_bytes(commands: &[(&str, &str, &str)], capabilities: &str, pack: &[u8]) -> Vec<u8> {
    let mut out = Vec::new();
    for (index, (old, new, name)) in commands.iter().enumerate() {
        let mut line = format!("{old} {new} {name}").into_bytes();
        if index == 0 {
            line.push(0);
            line.extend_from_slice(capabilities.as_bytes());
        }
        gunnar_pktline::write_text(&mut out, &line).unwrap();
    }
    gunnar_pktline::write_flush(&mut out).unwrap();
    out.extend_from_slice(pack);
    out
}

/// `gunnar-pack`'s receipt in the words the protocol reports it in.
///
/// The two structs are deliberately not related by a `From` impl in either
/// crate: `gunnar-wire` is a **dev**-dependent of `gunnar-pack` now and must
/// stay that way (see `gunnar-wire/src/pack.rs`), so the mapping lives here, in
/// the only place that holds both.
fn as_receipt(indexed: gunnar_pack::IndexedPack) -> gunnar_wire::pack::IndexedPack {
    gunnar_wire::pack::IndexedPack {
        num_objects: indexed.num_objects,
        pack_id: indexed.pack_id,
        index_id: indexed.index_id,
        pack_version: gunnar_wire::pack::PackVersion::from_u32(match indexed.pack_version {
            gix_pack::data::Version::V3 => 3,
            _ => 2,
        }),
        hash_kind: indexed.hash_kind,
        index_path: indexed.index_path,
        data_path: indexed.data_path,
        keep_path: indexed.keep_path,
        // `gunnar-pack` writes a real `.pack`, so `data_path` IS the
        // measurement here and there is nothing to carry beside it. The field
        // exists for the engine that writes no file.
        stored_bytes: None,
    }
}

/// The objects are already in the store, so the ingest only has to *validate*
/// the pack. Storing a pushed pack is a storage decision, and the real one is
/// proven by `real_git_interop.rs` against a filesystem repository.
///
/// Generic over the error on purpose. This module is compiled **twice**: into
/// the library's own unit tests and into every integration test. It can name
/// `gunnar_wire::` types in both, since `lib.rs` declares
/// `extern crate self as gunnar_wire`, but callers still differ in what they
/// want the failure as — `gunnar_wire::Error` here, `anyhow::Error` there — and
/// a second copy of the helper is what one implementation forbids.
pub fn validating_ingest<E>(
    kind: Kind,
) -> impl Fn(&mut dyn std::io::BufRead) -> std::result::Result<gunnar_wire::pack::IndexedPack, E>
where
    E: From<gunnar_wire::Error>,
{
    move |pack: &mut dyn std::io::BufRead| {
        gunnar_pack::index_pack(pack, None, &gunnar_pack::IndexOptions::new(kind), None)
            .map(as_receipt)
            .map_err(|err| gunnar_wire::Error::pack(err).into())
    }
}

pub fn empty_pack(kind: Kind) -> Vec<u8> {
    gunnar_pack::empty_pack(kind).expect("an empty pack is legal")
}

/// [`validating_ingest`] with the per-entry allocation ceiling set explicitly.
///
/// The ceiling is what stands between a receive path and a pack that asks it to
/// materialise more memory than the machine has, so a test needs to be able to
/// move it.
pub fn limited_ingest<E>(
    kind: Kind,
    limit: usize,
) -> impl Fn(&mut dyn std::io::BufRead) -> std::result::Result<gunnar_wire::pack::IndexedPack, E>
where
    E: From<gunnar_wire::Error>,
{
    move |pack: &mut dyn std::io::BufRead| {
        gunnar_pack::index_pack(
            pack,
            None,
            &gunnar_pack::IndexOptions::new(kind).alloc_limit_bytes(limit),
            None,
        )
        .map(as_receipt)
        .map_err(|err| gunnar_wire::Error::pack(err).into())
    }
}

/// A store that **declines to answer reachability**, forwarding everything
/// else.
///
/// # Why a wire test needs one
///
/// `select_objects`' step 0 asks [`GitServe::select`] before it walks anything,
/// and [`MemGitServe`] answers: it is a real reachability walk over an
/// in-memory store. So a fixture built on it serves a clone without ever
/// running the traversal — which is correct, and fatal to any test whose
/// subject *is* the traversal. The walk ceiling, the walk pool and their two
/// refusal sentences are charged against ids the walk holds, and a walk that
/// never runs holds none: `walk_limit.rs` saw a one-byte ceiling admit a clone.
///
/// `Ok(None)` is not a hack here. It is the contract's own escape hatch —
/// *"cannot answer cheaply, walk it yourself"* — and the frozen
/// `GitServe` signature says it MUST stay reachable, because the Arrow arm returns it whenever its
/// reachability projection is empty, which a clean restart produces. This is a
/// backend in exactly that state, which is also the state the old
/// `MemObjectStore` fixture was permanently in (`reachable()` → `None`).
///
/// It borrows rather than owns so it can be laid over the fixture's own store
/// for one test without rebuilding the repository.
pub struct Unindexed<'a>(pub &'a dyn GitServe);

impl GitOps for Unindexed<'_> {
    fn put(&self, pack: &[u8], refs: &[RefUpdate]) -> anyhow::Result<TxId> {
        self.0.put(pack, refs)
    }
    fn put_pack(&self, bytes: &[u8]) -> anyhow::Result<TxId> {
        self.0.put_pack(bytes)
    }
    fn put_refs(&self, updates: &[RefUpdate]) -> anyhow::Result<TxId> {
        self.0.put_refs(updates)
    }
    fn get(&self, oid: Oid<'_>) -> anyhow::Result<Option<Stored>> {
        self.0.get(oid)
    }
    fn has(&self, oid: Oid<'_>) -> anyhow::Result<bool> {
        self.0.has(oid)
    }
    fn size(&self, oid: Oid<'_>) -> anyhow::Result<Option<u64>> {
        self.0.size(oid)
    }
    fn extents(&self, oids: &[Oid<'_>]) -> anyhow::Result<Vec<Option<Extent>>> {
        self.0.extents(oids)
    }
    fn refs(&self) -> anyhow::Result<Vec<RefRow>> {
        self.0.refs()
    }
    fn update_ref(
        &self,
        name: &str,
        old: Option<Oid<'_>>,
        new: Option<Oid<'_>>,
    ) -> anyhow::Result<TxId> {
        self.0.update_ref(name, old, new)
    }
    fn put_refs_cas(&self, edits: &[RefCas<'_>]) -> anyhow::Result<TxId> {
        self.0.put_refs_cas(edits)
    }
    fn reachable(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> anyhow::Result<Vec<Vec<u8>>> {
        self.0.reachable(want, have)
    }
    fn gc(&self) -> anyhow::Result<GcReport> {
        self.0.gc()
    }
}

impl GitServe for Unindexed<'_> {
    fn read(&self, oid: Oid<'_>) -> anyhow::Result<Option<(ObjType, Vec<u8>)>> {
        self.0.read(oid)
    }
    fn header(&self, oid: Oid<'_>) -> anyhow::Result<Option<(ObjType, u64)>> {
        self.0.header(oid)
    }
    fn sizes(&self, oids: &[Oid<'_>]) -> anyhow::Result<Vec<Option<u64>>> {
        self.0.sizes(oids)
    }
    fn head(&self) -> anyhow::Result<Option<RefRow>> {
        self.0.head()
    }
    fn set_head(&self, target: &str) -> anyhow::Result<TxId> {
        self.0.set_head(target)
    }
    fn emit_pack(
        &self,
        want: &[Oid<'_>],
        have: &[Oid<'_>],
        caps: &Caps,
        out: &mut dyn std::io::Write,
    ) -> anyhow::Result<PackStats> {
        self.0.emit_pack(want, have, caps, out)
    }
    /// **The whole point.** No index, so no answer.
    fn select(&self, _want: &[Oid<'_>], _have: &[Oid<'_>]) -> anyhow::Result<Option<ReachSet>> {
        Ok(None)
    }
}

// ── the byte-ceiling fixtures, shared by the two files that measure one ─────
//
// `tests/pack_ceiling.rs` (one push against its ceiling) and
// `tests/concurrent_pack_ceiling.rs` (eight at once against one namespace's
// room) need the same two things: a pack whose size on the wire means what it
// says, and a reader that records what was actually taken out of it. They were
// one file's private helpers until the second file wanted them; a second copy
// is what one implementation forbids, and a drifting copy is worse than a
// shared one here, because both files assert EXACT byte counts against them.

/// A packfile of `blobs` incompressible blobs of `size` bytes.
///
/// **Incompressible on purpose.** `PackBuilder` deflates every entry, so a
/// megabyte of `b'a'` is a few hundred bytes on the wire and a ceiling test
/// built on it would be measuring zlib. The bytes come from a 64-bit LCG, which
/// deflate cannot shrink, so the pack's size on the wire is within a percent of
/// `blobs * size` and the fixture means what it says.
pub fn incompressible_pack(kind: Kind, blobs: usize, size: usize) -> Vec<u8> {
    let mut pb = gunnar_store::conformance::pack::PackBuilder::new(kind);
    let mut state: u64 = 0x2545_F491_4F6C_DD1D;
    for _ in 0..blobs {
        let mut body = vec![0u8; size];
        for byte in body.iter_mut() {
            state = state
                .wrapping_mul(6364136223846793005)
                .wrapping_add(1442695040888963407);
            *byte = (state >> 33) as u8;
        }
        pb.object(gix_object::Kind::Blob, body);
    }
    pb.build().expect("a pack of whole objects is legal").bytes
}

/// A `BufRead` that records **what was actually taken out of it**.
///
/// This is the applied output both ceiling files turn on: not what the server
/// decided, but how much of the client's upload it asked for before it stopped.
pub struct Tracked {
    inner: std::io::Cursor<Vec<u8>>,
    /// Bytes the server took off the wire, command list included.
    pub taken: u64,
}

impl Tracked {
    pub fn new(bytes: Vec<u8>) -> Self {
        Tracked {
            inner: std::io::Cursor::new(bytes),
            taken: 0,
        }
    }
}

impl std::io::Read for Tracked {
    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
        let read = std::io::Read::read(&mut self.inner, buf)?;
        self.taken += read as u64;
        Ok(read)
    }
}

impl std::io::BufRead for Tracked {
    fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
        std::io::BufRead::fill_buf(&mut self.inner)
    }

    fn consume(&mut self, amount: usize) {
        std::io::BufRead::consume(&mut self.inner, amount);
        self.taken += amount as u64;
    }
}