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

nordisk/edda

crates/storage-git-gix/parked-gix-2/gunnar-wire-gix/git_harness/mod.rs

//! **The `ext::` harness that makes stock `git` talk to `gunnar-wire`.**
//!
//! Extracted from `real_git_interop.rs` so a second interop binary can drive
//! the same server without a second copy of it (LAW 5). Nothing here is a test;
//! it is the pipe, the fixtures, and the `git` invocation builder.
//!
//! # How git is made to talk to a library
//!
//! `git clone "ext::<command> %S <path>"` runs `<command>` and speaks the smart
//! protocol over its stdin and stdout. The command is **the test binary that
//! linked this module**, re-executed with `GUNNAR_WIRE_SERVE` set, which is why
//! every such target is `harness = false`: it needs a `main` it can dispatch on.
//! [`helper_exe`] is `current_exe()`, so each binary re-execs *itself* and the
//! two targets never cross.
//!
//! So the bytes on the wire are produced by `gunnar-wire` and consumed by
//! `/usr/bin/git`, over a real pipe, with git choosing every request.
//!
//! # The v0 fallback is exercised for free, and not by choice
//!
//! Measured 2026-08-03: over `ext::`, git 2.53.0 passes `GIT_EXT_SERVICE` and
//! `GIT_DIR` to the helper but **not `GIT_PROTOCOL`**, so a clone falls back to
//! protocol v0 unless the variable is supplied. [`Scene::client`] sets it for
//! the v2 cases exactly as an SSH `SendEnv` would, and every case asserts which
//! protocol the server actually spoke.

#![allow(dead_code)]

use std::collections::BTreeMap;
use std::io::{BufRead, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::process::Command;

use gix_hash::Kind;
use gunnar_pack::{index_thin_pack, IndexOptions, IndexedPack};
use gunnar_store::fs::{FsObjects, FsStore};
use gunnar_store::{GixObjects, ObjectStore};
use gunnar_wire::receive_pack::AllowAll;
use gunnar_wire::{ProtocolVersion, ReceivePack, UploadPack};

/// Run a table of named cases, reporting like libtest and exiting non-zero on
/// any failure.
///
/// `expected` is asserted against the table's length rather than reported.
/// **The empty-run trap:** "0 passed, 0 failed, out of 0" exits 0 and is shaped
/// exactly like a clean sweep, so a table that lost its entries would report
/// success having run nothing.
pub fn run_cases(suite: &str, cases: &[(&str, fn())], expected: usize) {
    assert_eq!(
        cases.len(),
        expected,
        "the {suite} case table changed size — update its expected count deliberately, \
         so a table that lost its entries cannot report a clean sweep"
    );

    // **A missing `git` is a failure, not a skip** (`S-026`). These targets are
    // `harness = false`, so the exit code *is* the result: an `eprintln! +
    // exit(0)` would make the whole driver report success having run nothing,
    // and `cargo test` prints no test names to give it away.
    assert!(
        git_available(),
        "this test requires `git` on PATH — §15 interop cannot be proven without it"
    );

    let mut failed = Vec::new();
    for (name, case) in cases {
        print!("test {name} ... ");
        std::io::stdout().flush().ok();
        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(case)) {
            Ok(()) => println!("ok"),
            Err(_) => {
                println!("FAILED");
                failed.push(*name);
            }
        }
    }

    println!(
        "\n{suite}: {} passed, {} failed, out of {}",
        cases.len() - failed.len(),
        failed.len(),
        cases.len()
    );
    if !failed.is_empty() {
        eprintln!("failing cases: {failed:?}");
        std::process::exit(1);
    }
}
/// Where [`Scene::dated`]'s timestamps start. A real epoch rather than 1970:
/// git accepts both, and a fixture dated before the tool existed invites a
/// future "surely that is the bug" that is not the bug.
pub const DATED_BASE: i64 = 1_700_000_000;

/// The `--shallow-since` cut used against [`Scene::dated`]: between `B` and
/// `C`, so it is not the same answer as any `--depth`.
pub const SHALLOW_SINCE: &str = "--shallow-since=@1700002500";

/// Commits of churn in [`Scene::churned`], and lines in the file each one
/// rewrites a couple of. Capped on purpose: this is ~52 KiB of raw revisions,
/// and a fixture whose size is a free parameter crossed with a large input is
/// how a test in this tree took a 499 GB box down.
pub const CHURN_COMMITS: usize = 32;
pub const CHURN_LINES: usize = 240;
// ── the server end of the pipe ──────────────────────────────────────────────

/// Re-executed by git's `ext::` transport. `argv[1]` is the service git wants
/// (`%S`), `argv[2]` the repository path this test told it to pass.
pub fn serve() -> Result<(), Box<dyn std::error::Error>> {
    let args: Vec<String> = std::env::args().collect();
    let service = args.get(1).cloned().unwrap_or_default();
    let git_dir = PathBuf::from(args.get(2).cloned().unwrap_or_default());
    let breakage = std::env::var("GUNNAR_WIRE_BREAK").unwrap_or_default();

    let hash_kind = detect_hash_kind(&git_dir);
    let store = FsStore::open_or_init(&git_dir, hash_kind)?;
    let objects = store.objects();
    let refs = store.refs();

    let stdin = std::io::stdin();
    let mut input = stdin.lock();
    let stdout = std::io::stdout();
    let mut raw = BufWriter::new(stdout.lock());

    match service.as_str() {
        "git-upload-pack" => {
            let version =
                ProtocolVersion::from_git_protocol(std::env::var("GIT_PROTOCOL").ok().as_deref());
            // A lying store is the documented spec §6 bug reproduced through
            // the real code path: `hash_kind()` is the *only* source of
            // `object-format`, so corrupting it there corrupts exactly what a
            // careless implementation would get wrong.
            let lying;
            let served: &dyn ObjectStore = if breakage == "hash-lie" {
                lying = WrongHashKind {
                    inner: objects.clone(),
                    claim: Kind::Sha1,
                };
                &lying
            } else {
                &objects
            };
            let upload = UploadPack::new(served, &refs);

            if breakage == "no-sideband" {
                // Strip the band byte off every packfile line on its way past.
                // This has to *stream*: buffering the whole session and
                // rewriting it at the end deadlocks, because git is waiting
                // for the capability advertisement that is still sitting in
                // the buffer. (Learned the hard way, 2026-08-03.)
                let mut sabotaged = StripSideband::new(&mut raw);
                let summary = upload.serve(version, &mut input, &mut sabotaged)?;
                sabotaged.flush()?;
                log(&summary_line(&service, version, &summary));
            } else {
                let summary = upload.serve(version, &mut input, &mut raw)?;
                log(&summary_line(&service, version, &summary));
            }
        }
        "git-receive-pack" => {
            let pack_dir = git_dir.join("objects").join("pack");
            std::fs::create_dir_all(&pack_dir)?;
            let ingest_objects = objects.clone();
            let ingest = move |pack: &mut dyn BufRead| -> gunnar_wire::Result<IndexedPack> {
                let indexed = index_thin_pack(
                    pack,
                    Some(&pack_dir),
                    GixObjects(ingest_objects.clone()),
                    &IndexOptions::new(hash_kind),
                    None,
                )?;
                // gunnar-pack hands the `.keep` to the caller to remove once
                // the references point at the pack. Nothing collects objects
                // in a test, so it goes now.
                if let Some(keep) = &indexed.keep_path {
                    let _ = std::fs::remove_file(keep);
                }
                Ok(indexed)
            };
            let receive = ReceivePack::new(&objects, &refs);
            // The gate seam is what `gunnar-policy` will occupy. Exercising a
            // *refusing* gate here is the only way to prove that gunnar's
            // `ng <ref> <reason>` report is understood by a real client:
            // stock git refuses a stale-old-value push on its own side, so a
            // well-behaved client never produces one.
            let allow = AllowAll;
            let refuse_all = RefuseThese(None);
            let refuse_side = RefuseThese(Some("refs/heads/side"));
            let gate: &dyn gunnar_wire::PushGate = match breakage.as_str() {
                "reject-push" => &refuse_all,
                "reject-side" => &refuse_side,
                _ => &allow,
            };
            let outcome = receive.serve(&mut input, &mut raw, &ingest, gate)?;
            let statuses = outcome
                .as_ref()
                .map(|outcome| {
                    outcome
                        .statuses
                        .iter()
                        .map(|status| match status {
                            gunnar_wire::RefStatus::Ok => "ok".to_owned(),
                            gunnar_wire::RefStatus::Rejected(reason) => format!("ng:{reason}"),
                        })
                        .collect::<Vec<_>>()
                        .join(",")
                })
                .unwrap_or_else(|| "none".to_owned());
            log(&format!(
                "service=git-receive-pack version=0 statuses={statuses}"
            ));
        }
        other => return Err(format!("gunnar serves no {other:?}").into()),
    }
    raw.flush()?;
    Ok(())
}

pub fn summary_line(
    service: &str,
    version: ProtocolVersion,
    summary: &gunnar_wire::UploadSummary,
) -> String {
    // `copied=`/`recompressed=` is `P-001`'s applied output carried out to the
    // client side of the test. "the clone succeeded" is true of a server that
    // shipped the client's own deltas back and of one that inflated and
    // re-deflated every byte to send 18× as many; only these two numbers tell
    // them apart, and only from inside the server.
    format!(
        "service={service} version={} rounds={} objects={} copied={} recompressed={}",
        version.number(),
        summary.fetch_rounds,
        summary.objects_sent,
        summary.objects_copied_from_pack,
        summary.objects_recompressed,
    )
}

pub fn log(line: &str) {
    if let Ok(path) = std::env::var("GUNNAR_WIRE_LOG") {
        if let Ok(mut file) = std::fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(path)
        {
            let _ = writeln!(file, "{line}");
        }
    }
}

/// The repository's hash algorithm, read from the repository rather than
/// assumed, the same discipline the server itself is held to.
pub fn detect_hash_kind(git_dir: &Path) -> Kind {
    let config = std::fs::read_to_string(git_dir.join("config")).unwrap_or_default();
    if config.to_ascii_lowercase().contains("sha256") {
        Kind::Sha256
    } else {
        Kind::Sha1
    }
}

/// A gate that refuses one named reference, or every reference when it names
/// none.
struct RefuseThese(Option<&'static str>);

impl gunnar_wire::PushGate for RefuseThese {
    fn check(
        &self,
        command: &gunnar_wire::RefCommand,
        _facts: &gunnar_wire::PushFacts<'_>,
    ) -> Result<(), String> {
        match self.0 {
            Some(only) if command.name.to_str_lossy() != only => Ok(()),
            _ => Err("the gunnar test gate says no".to_owned()),
        }
    }
}

/// An `ObjectStore` that misreports its hash algorithm. Nothing but the lie.
struct WrongHashKind {
    inner: FsObjects,
    claim: Kind,
}

impl ObjectStore for WrongHashKind {
    fn hash_kind(&self) -> Kind {
        self.claim
    }
    fn contains(&self, id: &gix_hash::oid) -> bool {
        self.inner.contains(id)
    }
    /// Forwarded. The trait default would route this back through `contains`
    /// — a correct answer down the *slow* path, so nothing observable would
    /// catch the omission. See `ObjectStore::contains_without_rescan`.
    fn contains_without_rescan(&self, id: &gix_hash::oid) -> bool {
        self.inner.contains_without_rescan(id)
    }
    fn read<'a>(
        &self,
        id: &gix_hash::oid,
        buf: &'a mut Vec<u8>,
    ) -> gunnar_store::Result<Option<gix_object::Data<'a>>> {
        self.inner.read(id, buf)
    }
    fn header(&self, id: &gix_hash::oid) -> gunnar_store::Result<Option<gix_object::Header>> {
        self.inner.header(id)
    }
    fn write(
        &self,
        kind: gix_object::Kind,
        bytes: &[u8],
    ) -> gunnar_store::Result<gix_hash::ObjectId> {
        self.inner.write(kind, bytes)
    }
}

/// A `Write` that re-frames the stream passing through it, dropping the
/// sideband band byte from every line after the `packfile` section header.
///
/// It forwards whole pkt-lines as soon as they are complete, so the peer is
/// never left waiting; the sabotage changes the bytes, not the timing.
struct StripSideband<'a, W: Write> {
    out: &'a mut W,
    pending: Vec<u8>,
    in_pack: bool,
}

impl<'a, W: Write> StripSideband<'a, W> {
    pub fn new(out: &'a mut W) -> Self {
        StripSideband {
            out,
            pending: Vec::new(),
            in_pack: false,
        }
    }

    fn drain(&mut self) -> std::io::Result<()> {
        use gunnar_pktline::{decode, Decoded, Packet};
        let mut consumed = 0;
        loop {
            match decode(&self.pending[consumed..]) {
                Ok(Decoded::Packet(packet, used)) => {
                    match &packet {
                        Packet::Data(payload) if self.in_pack && !payload.is_empty() => {
                            gunnar_pktline::write_data(&mut *self.out, &payload[1..])
                                .map_err(std::io::Error::other)?;
                        }
                        Packet::Data(payload) => {
                            if payload.strip_suffix(b"\n").unwrap_or(payload) == b"packfile" {
                                self.in_pack = true;
                            }
                            gunnar_pktline::write_data(&mut *self.out, payload)
                                .map_err(std::io::Error::other)?;
                        }
                        other => gunnar_pktline::write_packet(&mut *self.out, other)
                            .map_err(std::io::Error::other)?,
                    }
                    consumed += used;
                }
                Ok(Decoded::Incomplete) => break,
                Err(err) => return Err(std::io::Error::other(err.to_string())),
            }
        }
        self.pending.drain(..consumed);
        Ok(())
    }
}

impl<W: Write> Write for StripSideband<'_, W> {
    fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
        self.pending.extend_from_slice(data);
        self.drain()?;
        Ok(data.len())
    }

    fn flush(&mut self) -> std::io::Result<()> {
        self.drain()?;
        self.out.flush()
    }
}
// ── the client end: driving real git ────────────────────────────────────────

pub fn git_available() -> bool {
    Command::new("git")
        .arg("--version")
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

pub fn helper_exe() -> PathBuf {
    std::env::current_exe().expect("this test binary's own path")
}

/// A `git` invocation with the developer's own configuration removed, so
/// nothing here depends on `~/.gitconfig`.
pub struct Git {
    args: Vec<String>,
    env: BTreeMap<String, String>,
    cwd: Option<PathBuf>,
}

impl Git {
    pub fn new() -> Self {
        let mut env = BTreeMap::new();
        env.insert("GIT_CONFIG_GLOBAL".into(), "/dev/null".into());
        env.insert("GIT_CONFIG_SYSTEM".into(), "/dev/null".into());
        env.insert("GIT_TERMINAL_PROMPT".into(), "0".into());
        env.insert("GIT_AUTHOR_NAME".into(), "Gunnar Test".into());
        env.insert("GIT_AUTHOR_EMAIL".into(), "gunnar@example.invalid".into());
        env.insert("GIT_COMMITTER_NAME".into(), "Gunnar Test".into());
        env.insert(
            "GIT_COMMITTER_EMAIL".into(),
            "gunnar@example.invalid".into(),
        );
        Git {
            args: vec!["-c".into(), "protocol.ext.allow=always".into()],
            env,
            cwd: None,
        }
    }

    pub fn arg(mut self, value: impl Into<String>) -> Self {
        self.args.push(value.into());
        self
    }

    pub fn args<I: IntoIterator<Item = S>, S: Into<String>>(mut self, values: I) -> Self {
        self.args.extend(values.into_iter().map(Into::into));
        self
    }

    pub fn cwd(mut self, dir: impl AsRef<Path>) -> Self {
        self.cwd = Some(dir.as_ref().to_path_buf());
        self
    }

    pub fn env(mut self, key: &str, value: impl Into<String>) -> Self {
        self.env.insert(key.into(), value.into());
        self
    }

    pub fn run(self) -> (bool, String, String) {
        // Every invocation is capped. A protocol deadlock is a bug, and a bug
        // that presents as "the test suite never finishes" is a bug nobody
        // reads the message of.
        let mut command = Command::new("timeout");
        command.arg("--kill-after=5").arg("120").arg("git");
        command.args(&self.args);
        // Clearing the whole environment would lose PATH; removing the one
        // variable that decides the protocol version is what matters, and it
        // is put back explicitly by the v2 cases.
        command.env_remove("GIT_PROTOCOL");
        for (key, value) in &self.env {
            command.env(key, value);
        }
        if let Some(dir) = &self.cwd {
            command.current_dir(dir);
        }
        let output = command.output().expect("could not run git");
        (
            output.status.success(),
            String::from_utf8_lossy(&output.stdout)
                .trim_end()
                .to_owned(),
            String::from_utf8_lossy(&output.stderr)
                .trim_end()
                .to_owned(),
        )
    }

    pub fn ok(self) -> String {
        let args = self.args.clone();
        let (ok, stdout, stderr) = self.run();
        assert!(ok, "git {args:?} failed:\n{stderr}");
        stdout
    }
}

pub fn git_in(dir: &Path) -> Git {
    Git::new().cwd(dir)
}

/// A scratch area: a fixture bare repository plus the log the server writes.
pub struct Scene {
    _dir: tempfile::TempDir,
    pub root: PathBuf,
    pub origin: PathBuf,
    pub log: PathBuf,
}

impl Scene {
    /// A bare repository with a merge, a divergent branch, and an annotated
    /// tag: the shape spec §6 insists negotiation be tested against, rather
    /// than a three-commit line where the first `have` always hits.
    pub fn new(hash_kind: Kind) -> Self {
        let dir = tempfile::tempdir().expect("a temporary directory");
        let root = dir.path().to_path_buf();
        let work = root.join("seed");

        let mut init = Git::new().cwd(&root).args(["init", "-q", "-b", "main"]);
        if hash_kind == Kind::Sha256 {
            init = init.arg("--object-format=sha256");
        }
        init.arg(work.to_str().unwrap()).ok();

        commit(&work, "a.txt", "one", "first");
        commit(&work, "b.txt", "two", "second");
        git_in(&work)
            .args(["checkout", "-q", "-b", "side", "HEAD~1"])
            .ok();
        commit(&work, "c.txt", "three", "on the side");
        git_in(&work).args(["checkout", "-q", "main"]).ok();
        git_in(&work)
            .args(["merge", "-q", "--no-ff", "side", "-m", "merge the side"])
            .ok();
        git_in(&work)
            .args(["tag", "-a", "v1", "-m", "release one"])
            .ok();
        commit(&work, "d.txt", "four", "after the merge");

        let origin = root.join("origin.git");
        Git::new()
            .cwd(&root)
            .args(["clone", "-q", "--bare"])
            .arg(work.to_str().unwrap())
            .arg(origin.to_str().unwrap())
            .ok();

        let log = root.join("server.log");
        Scene {
            _dir: dir,
            root,
            origin,
            log,
        }
    }

    /// A bare repository whose **dates and shape make the three deepening
    /// variants disagree** (`S-031`).
    ///
    /// ```text
    ///   A(t=+1000) ─ B(t=+2000) ────── M(t=+4000) ─ D(t=+5000)  refs/heads/main
    ///            └─ C(t=+3000) ───────┘                        refs/heads/side
    /// ```
    ///
    /// The merge is the point. `graph.rs` names "a merge cut off from *one* of
    /// its parents" as a bug worth naming, and `--shallow-exclude=side` is
    /// exactly that request: it must drop `C` (and `A`, which only `C` reaches
    /// from below) while keeping `B`. A boundary cannot express that; a parent
    /// restriction can, and this fixture is what tells the two apart.
    ///
    /// The dates are what make `--shallow-since` a different question from
    /// `--depth`. **Measured against stock git 2.53.0 on 2026-08-03**, against
    /// this shape:
    ///
    /// | request | commits | `.git/shallow` |
    /// |---|---|---|
    /// | `--depth=2` | `D`, `M` | `M` |
    /// | `--shallow-since=+2500` | `D`, `M` | `C`, `M` |
    /// | `--shallow-exclude=side` | `D`, `M` | `B`, `M` |
    ///
    /// Three requests, three different answers — but the answer is the
    /// **boundary**, not the commit count, and this table used to say
    /// otherwise. git's shallow file can only say "this commit has no
    /// parents", so a merge that lost one parent is grafted whole; `C` (or
    /// `B`) would arrive unreachable from anything, and git does not send it.
    /// What git does do is name it as a cut point, and that is what tells the
    /// three requests apart. A server that parsed each argument and ignored it
    /// would produce one boundary for all three.
    pub fn dated(hash_kind: Kind) -> Self {
        let dir = tempfile::tempdir().expect("a temporary directory");
        let root = dir.path().to_path_buf();
        let work = root.join("seed");

        let mut init = Git::new().cwd(&root).args(["init", "-q", "-b", "main"]);
        if hash_kind == Kind::Sha256 {
            init = init.arg("--object-format=sha256");
        }
        init.arg(work.to_str().unwrap()).ok();

        commit_at(&work, "a.txt", "one", "A", Some(DATED_BASE + 1000));
        commit_at(&work, "b.txt", "two", "B", Some(DATED_BASE + 2000));
        git_in(&work)
            .args(["checkout", "-q", "-b", "side", "HEAD~1"])
            .ok();
        commit_at(&work, "c.txt", "three", "C", Some(DATED_BASE + 3000));
        git_in(&work).args(["checkout", "-q", "main"]).ok();
        git_in(&work)
            .args(["merge", "-q", "--no-ff", "side", "-m", "M"])
            .env("GIT_AUTHOR_DATE", format!("@{} +0000", DATED_BASE + 4000))
            .env(
                "GIT_COMMITTER_DATE",
                format!("@{} +0000", DATED_BASE + 4000),
            )
            .ok();
        commit_at(&work, "d.txt", "four", "D", Some(DATED_BASE + 5000));

        let origin = root.join("origin.git");
        Git::new()
            .cwd(&root)
            .args(["clone", "-q", "--bare"])
            .arg(work.to_str().unwrap())
            .arg(origin.to_str().unwrap())
            .ok();

        let log = root.join("server.log");
        Scene {
            _dir: dir,
            root,
            origin,
            log,
        }
    }

    /// A bare repository of **churn**, packed and delta-compressed by stock
    /// git. This is the fixture `P-001` needs and [`Scene::new`] cannot be.
    ///
    /// The six-object merge fixture is the right shape for negotiation and the
    /// wrong shape for delta reuse: there is nothing near-duplicate in it, so a
    /// working pack-copy and a broken one produce the same bytes. That is the
    /// identity-value trap `P-001` and `S-003` both name, and it is why this
    /// rewrites a couple of lines of one file per commit instead.
    ///
    /// gitoxide has no delta encoder at all (`P-009`), so these deltas can only
    /// come from `git repack`. That is not a shortcut, it is the situation:
    /// what gunnar reuses is what the pushing client computed.
    pub fn churned(hash_kind: Kind) -> Self {
        let dir = tempfile::tempdir().expect("a temporary directory");
        let root = dir.path().to_path_buf();
        let work = root.join("seed");

        let mut init = Git::new().cwd(&root).args(["init", "-q", "-b", "main"]);
        if hash_kind == Kind::Sha256 {
            init = init.arg("--object-format=sha256");
        }
        init.arg(work.to_str().unwrap()).ok();

        let mut lines: Vec<String> = (0..CHURN_LINES)
            .map(|i| format!("line {i:04} of the churned file, original text"))
            .collect();
        for revision in 0..CHURN_COMMITS {
            for step in 0..CHURN_LINES / 50 {
                let index = (revision * 7 + step * 53) % CHURN_LINES;
                lines[index] = format!("line {index:04} rewritten at revision {revision}");
            }
            std::fs::write(work.join("churn.txt"), lines.join("\n")).unwrap();
            git_in(&work).args(["add", "churn.txt"]).ok();
            git_in(&work)
                .args(["commit", "-q", "-m", &format!("revision {revision}")])
                .ok();
        }

        let origin = root.join("origin.git");
        Git::new()
            .cwd(&root)
            .args(["clone", "-q", "--bare"])
            .arg(work.to_str().unwrap())
            .arg(origin.to_str().unwrap())
            .ok();
        // `-a -d -f`: one pack, no loose objects, deltas recomputed from
        // scratch. This line is what puts the delta chains into the fixture.
        Git::new().cwd(&origin).args(["repack", "-adf", "-q"]).ok();

        let log = root.join("server.log");
        Scene {
            _dir: dir,
            root,
            origin,
            log,
        }
    }

    pub fn url(&self) -> String {
        format!(
            "ext::{} %S {}",
            helper_exe().display(),
            self.origin.display()
        )
    }

    /// The same repository addressed so that **stock `git-upload-pack`**
    /// serves it.
    ///
    /// `file://` rather than a bare path on purpose: a plain local path takes
    /// git's local-clone shortcut, which copies objects and never speaks the
    /// protocol at all, so the comparison would be against nothing.
    pub fn local_url(&self) -> String {
        format!("file://{}", self.origin.display())
    }

    /// A git invocation wired to talk to gunnar over `ext::`.
    pub fn client(&self, protocol: ProtocolVersion) -> Git {
        let mut git = Git::new()
            .cwd(&self.root)
            .env("GUNNAR_WIRE_SERVE", "1")
            .env("GUNNAR_WIRE_LOG", self.log.to_str().unwrap());
        if protocol == ProtocolVersion::V2 {
            git = git.env("GIT_PROTOCOL", "version=2");
        }
        git
    }

    pub fn server_log(&self) -> Vec<String> {
        std::fs::read_to_string(&self.log)
            .unwrap_or_default()
            .lines()
            .map(str::to_owned)
            .collect()
    }

    pub fn clear_log(&self) {
        let _ = std::fs::remove_file(&self.log);
    }

    pub fn origin_git(&self) -> Git {
        Git::new()
            .cwd(&self.root)
            .args(["--git-dir", self.origin.to_str().unwrap()])
    }

    pub fn origin_ref(&self, name: &str) -> String {
        self.origin_git().args(["rev-parse", name]).ok()
    }
}

pub fn commit(work: &Path, file: &str, content: &str, message: &str) {
    commit_at(work, file, content, message, None);
}

/// [`commit`], with the author and committer dates pinned.
///
/// `--shallow-since` cuts on the **committer** date, so a fixture whose commits
/// all carry "now" cannot tell a working date cut from one that ignores its
/// argument. Both dates are set because git derives neither from the other.
pub fn commit_at(work: &Path, file: &str, content: &str, message: &str, at: Option<i64>) {
    std::fs::write(work.join(file), format!("{content}\n")).unwrap();
    git_in(work).args(["add", file]).ok();
    let mut git = git_in(work).args(["commit", "-q", "-m", message]);
    if let Some(at) = at {
        // `@<epoch>` is git's raw-timestamp form; a bare number is rejected.
        let stamp = format!("@{at} +0000");
        git = git
            .env("GIT_AUTHOR_DATE", stamp.clone())
            .env("GIT_COMMITTER_DATE", stamp);
    }
    git.ok();
}

/// The largest `objects=` the server logged. That is how many objects it
/// actually packed, which is the only way to tell a negotiated fetch from a
/// server that ships the repository every time.
pub fn objects_sent(scene: &Scene) -> u32 {
    scene
        .server_log()
        .iter()
        .filter_map(|line| line.split("objects=").nth(1))
        .filter_map(|value| value.split_whitespace().next())
        .filter_map(|value| value.parse().ok())
        .max()
        .unwrap_or(0)
}

/// The largest value of a `<key>=<number>` field across the server's log.
///
/// `objects_sent` is the same shape and predates this; both take the maximum
/// because a v2 session logs one line per `serve()` and only the round that
/// carried the pack has a non-zero count.
pub fn log_field(log: &[String], key: &str) -> usize {
    log.iter()
        .filter_map(|line| line.split(key).nth(1))
        .filter_map(|value| value.split_whitespace().next())
        .filter_map(|value| value.parse().ok())
        .max()
        .unwrap_or(0)
}

pub fn fetch_rounds(scene: &Scene) -> usize {
    scene
        .server_log()
        .iter()
        .filter_map(|line| line.split("rounds=").nth(1))
        .filter_map(|value| value.split_whitespace().next())
        .filter_map(|value| value.parse().ok())
        .max()
        .unwrap_or(0)
}

/// Clone the fixture through gunnar and assert the result is a real, complete
/// repository; `fsck` walks every object it received.
pub fn clone_and_verify(scene: &Scene, protocol: ProtocolVersion, into: &str) -> PathBuf {
    let target = scene.root.join(into);
    scene.clear_log();
    scene
        .client(protocol)
        .args(["clone", "-q"])
        .arg(scene.url())
        .arg(target.to_str().unwrap())
        .ok();

    let log = scene.server_log();
    assert!(
        log.iter()
            .any(|line| line.contains(&format!("version={}", protocol.number()))),
        "the server did not speak protocol {}; it logged {log:?}",
        protocol.number()
    );

    git_in(&target).args(["fsck", "--no-progress"]).ok();
    assert_eq!(
        git_in(&target).args(["rev-parse", "HEAD"]).ok(),
        scene.origin_ref("refs/heads/main"),
        "the clone's HEAD is not the origin's main"
    );
    assert_eq!(
        git_in(&target).args(["rev-parse", "refs/tags/v1"]).ok(),
        scene.origin_ref("refs/tags/v1"),
        "the annotated tag did not come across"
    );
    // A merge means two parents. A server that served only the first-parent
    // line would still produce a clone that looks right at the tip.
    let cloned: usize = git_in(&target)
        .args(["rev-list", "--count", "HEAD"])
        .ok()
        .parse()
        .unwrap();
    let original: usize = scene
        .origin_git()
        .args(["rev-list", "--count", "refs/heads/main"])
        .ok()
        .parse()
        .unwrap();
    assert_eq!(
        cloned, original,
        "the clone has {cloned} commits where the origin has {original}"
    );
    target
}

/// The `ext::` helper entry point: if git re-executed this binary as the
/// server end of a pipe, serve and exit; otherwise return and let the caller
/// run its cases.
///
/// Every `harness = false` interop target calls this as the first line of its
/// `main`, so the two halves of the pipe are the same executable.
pub fn serve_if_asked() {
    if std::env::var_os("GUNNAR_WIRE_SERVE").is_none() {
        return;
    }
    match serve() {
        Ok(()) => std::process::exit(0),
        Err(err) => {
            eprintln!("gunnar-wire helper failed: {err}");
            std::process::exit(1);
        }
    }
}