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

nordisk/gunnar

xtask/src/roundtrip.rs

//! **The round trip: a real repository leaves stock git, lives on gunnar, and comes back
//! byte for byte.**
//!
//! # What this measures that nothing else does
//!
//! gunnar's own suite proves its parts. `installtest` proves it starts. The bench arms
//! prove throughput against synthetic corpora. None of them takes a repository that
//! somebody else wrote — with its real history, its merges, its symlinks, its executable
//! bits and its awkward filenames — pushes it to a running gunnar, pulls it back into an
//! empty directory, and then **compares the two working trees byte for byte**.
//!
//! That comparison is the point. Every count on the way (objects, packed bytes,
//! `refs_ok=1`) can read perfectly healthy while one file comes back with the wrong mode
//! or one byte short, and a git server that loses a byte is not slow — it is wrong. So the
//! verdict here is not a duration. It is *the trees are identical, and here is what it
//! cost*.
//!
//! # The rule this harness is built around
//!
//! **It never pushes to the repository it cloned from.** Recorded 2026-08-06: no change is
//! ever pushed to a real upstream repository, only to the temporary repository on gunnar.
//! The public source is
//! a READ-ONLY donor. The only remote this code ever writes to is one it created itself on
//! gunnar under [`GUNNAR_PREFIX`], and the only disk it writes to is its own scratch
//! directory. [`Plan::push_url`] is the single place a push target is formed, so that rule
//! is enforceable by reading one function rather than by trusting every call site.
//!
//! # Why `std` only
//!
//! `xtask` has an empty `[dependencies]` table and keeps it that way. Comparing files does
//! not need a hash crate — two files are equal if their bytes are equal, and reading both
//! is the same work as hashing both without the collision caveat. Randomness for the
//! mutation pass comes from a small deterministic generator, which is better than a real
//! one here: a failure has to be reproducible from the seed printed in the report.
//!
//! The zero-shell rule governs the **server** — gunnar spawns nothing on any transport.
//! Driving `git` and `gunnar-cli` as child processes from a repository task is what
//! `appliance.rs` and `worktree.rs` already do, and is how the donor clone stays honest:
//! stock git makes it, so the corpus is not gunnar's own idea of a repository.
//!
//! # What this is NOT
//!
//! It is not a chaos test. Everything here goes right and the question is whether the data
//! survives and what it cost. Injecting faults — cutting a connection mid-pack, killing the
//! client between `receiving` and `updating`, two pushes racing one ref — is a separate arm
//! against the ordering gunnar already logs (`checking that the push closes, before any
//! reference moves`). Keeping the two apart matters: a harness that does both reports a
//! failure without saying which kind it was.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};

/// Every repository this harness creates is named `<GUNNAR_PREFIX><donor>`, and it will
/// refuse to touch a remote path that does not start with it. The prefix is the mechanical
/// half of the never-touch-the-donor law: a typo cannot land on somebody's real repository
/// because the name would not carry it.
pub const GUNNAR_PREFIX: &str = "rt-";

/// The account the scratch repositories live under, unless `--account` says
/// otherwise. gunnar names repositories `<account>/<name>`, and the bootstrapped
/// principal owns its own account.
pub const DEFAULT_ACCOUNT: &str = "alice";

/// The control plane, unless `--at` / `$GUNNAR_CONTROL` says otherwise.
///
/// **Loopback, not a site.** The address of somebody's live server is not a
/// property of gunnar and does not belong in this source tree, so the default is
/// the box the harness runs on and every other target is named on the command
/// line.
///
/// **50061, not 50051** — that one is a co-resident service on the same host in
/// the deployment this was written against, and dialling it gets a confusing
/// protocol error rather than a refusal.
pub const DEFAULT_CONTROL: &str = "http://127.0.0.1:50061";

/// The git transport, unless `--ssh` / `$GUNNAR_SSH` says otherwise. Loopback,
/// for the reason above.
pub const DEFAULT_SSH: &str = "ssh://gunnar@127.0.0.1:2222";

/// Donor repositories: PUBLIC, cloned read-only, never written back to.
///
/// Two shapes on purpose. `foot` is C with a long linear history and a lot of small files;
/// `ripgrep` is Rust with a deeper tree, tags and release history. A harness that only ever
/// sees one shape of repository proves one shape of repository.
pub const DEFAULT_DONORS: &[(&str, &str)] = &[
    ("foot", "https://codeberg.org/dnkl/foot.git"),
    ("ripgrep", "https://github.com/BurntSushi/ripgrep.git"),
];

/// How the run was configured — parsed once, so no step re-reads argv and drifts.
#[derive(Debug)]
pub struct Plan {
    /// gRPC control plane URL.
    pub control: String,
    /// SSH git transport base, without the `/account/name` path.
    pub ssh_base: String,
    /// The account scratch repositories are created under.
    pub account: String,
    /// Private key for both transports.
    pub identity: PathBuf,
    /// Scratch root; everything this harness writes lives under it.
    pub work: PathBuf,
    /// `(short name, public clone URL)` pairs.
    pub donors: Vec<(String, String)>,
    /// Seed for the mutation pass, printed so a failure reproduces.
    pub seed: u64,
    /// Keep the scratch directory instead of removing it.
    pub keep: bool,
    /// Skip the mutation + second round trip (the fast smoke shape).
    pub no_mutate: bool,
}

impl Plan {
    /// **The ONE place a push target is formed.** Every write in this file goes through
    /// here, so the never-touch-the-donor law is checkable by reading one function.
    ///
    /// The name is forced to carry [`GUNNAR_PREFIX`] rather than merely checked for it: a
    /// caller cannot pass a bare donor name and reach a real repository.
    pub fn push_url(&self, donor: &str) -> String {
        format!(
            "{}/{}/{}",
            self.ssh_base.trim_end_matches('/'),
            self.account,
            self.repo_name(donor)
        )
    }

    /// `rt-<donor>` — the scratch repository's name on gunnar.
    pub fn repo_name(&self, donor: &str) -> String {
        format!("{GUNNAR_PREFIX}{donor}")
    }

    /// `<account>/rt-<donor>` — what `control repo create` takes.
    pub fn repo_path(&self, donor: &str) -> String {
        format!("{}/{}", self.account, self.repo_name(donor))
    }
}

/// Parse `cargo xtask roundtrip` arguments. Unknown flags are an ERROR, not a shrug: a
/// mistyped `--no-mutate` that silently ran the long shape would misreport what was tested.
pub fn parse_args(args: &[String]) -> Result<Plan, String> {
    let mut plan = Plan {
        control: std::env::var("GUNNAR_CONTROL").unwrap_or_else(|_| DEFAULT_CONTROL.to_string()),
        ssh_base: std::env::var("GUNNAR_SSH").unwrap_or_else(|_| DEFAULT_SSH.to_string()),
        account: DEFAULT_ACCOUNT.to_string(),
        identity: default_identity(),
        work: std::env::temp_dir().join("gunnar-roundtrip"),
        donors: DEFAULT_DONORS
            .iter()
            .map(|(n, u)| ((*n).to_string(), (*u).to_string()))
            .collect(),
        seed: 0x5EED_1234_ABCD_0001,
        keep: false,
        no_mutate: false,
    };
    let mut i = 0;
    while i < args.len() {
        let a = args[i].as_str();
        let mut next = |what: &str| -> Result<String, String> {
            i += 1;
            args.get(i)
                .cloned()
                .ok_or_else(|| format!("roundtrip: {what} needs a value"))
        };
        match a {
            "--at" => plan.control = next("--at")?,
            "--ssh" => plan.ssh_base = next("--ssh")?,
            "--account" => plan.account = next("--account")?,
            "-i" | "--identity" => plan.identity = PathBuf::from(next("--identity")?),
            "--work" => plan.work = PathBuf::from(next("--work")?),
            "--seed" => {
                let v = next("--seed")?;
                plan.seed = v
                    .parse()
                    .map_err(|_| format!("roundtrip: --seed `{v}` is not a number"))?;
            }
            "--donor" => {
                // `--donor <name>=<url>`, repeatable. The first one replaces the defaults
                // rather than adding to them, so a caller who names donors gets exactly
                // those and not those plus two surprises.
                let spec = next("--donor")?;
                let (n, u) = spec.split_once('=').ok_or_else(|| {
                    format!("roundtrip: --donor wants `<name>=<url>`, got `{spec}`")
                })?;
                if plan.donors.len() == DEFAULT_DONORS.len()
                    && plan
                        .donors
                        .iter()
                        .zip(DEFAULT_DONORS)
                        .all(|((a, _), (b, _))| a == b)
                {
                    plan.donors.clear();
                }
                plan.donors.push((n.to_string(), u.to_string()));
            }
            "--keep" => plan.keep = true,
            "--no-mutate" => plan.no_mutate = true,
            other => return Err(format!("roundtrip: unknown flag `{other}`")),
        }
        i += 1;
    }
    if plan.donors.is_empty() {
        return Err("roundtrip: no donor repositories".to_string());
    }
    Ok(plan)
}

/// `$HOME/.ssh/id_ed25519`, or a bare relative path if `HOME` is unset (which then fails
/// loudly at first use rather than silently authenticating as somebody else).
fn default_identity() -> PathBuf {
    match std::env::var("HOME") {
        Ok(h) => PathBuf::from(h).join(".ssh").join("id_ed25519"),
        Err(_) => PathBuf::from(".ssh/id_ed25519"),
    }
}

/// One measured command: what ran, how long the caller waited, and what it said.
pub struct Timed {
    pub wall: Duration,
    pub stdout: String,
    pub stderr: String,
    pub ok: bool,
}

/// Run a child process to completion, timing the WALL clock the caller experiences.
///
/// Wall time, not CPU: the number an operator lives with includes the ssh handshake and the
/// network, and separating those out is the report's job, not the measurement's.
fn timed(prog: &str, args: &[&str], cwd: Option<&Path>) -> Result<Timed, String> {
    let mut c = Command::new(prog);
    c.args(args);
    if let Some(d) = cwd {
        c.current_dir(d);
    }
    let t0 = Instant::now();
    let out = c
        .output()
        .map_err(|e| format!("roundtrip: cannot run `{prog}`: {e}"))?;
    let wall = t0.elapsed();
    Ok(Timed {
        wall,
        stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
        stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
        ok: out.status.success(),
    })
}

/// One entry of a working tree, as the comparison sees it.
///
/// Mode is reduced to the one bit git actually tracks (**is it executable**) plus whether
/// it is a symlink, because that is the whole of what a round trip can lose here — and
/// losing the executable bit is a defect that no byte comparison of contents would catch.
#[derive(PartialEq, Eq, Debug, Clone)]
pub struct Entry {
    pub exec: bool,
    pub symlink: bool,
    pub len: u64,
}

/// Walk a working tree into `relative path -> entry`, skipping `.git`.
///
/// `.git` is skipped deliberately: two clones of the same commit legitimately differ there
/// (pack names, reflogs, `FETCH_HEAD`), and comparing it would fail every honest run. What
/// must survive a round trip is the CONTENT, and that is the working tree.
pub fn walk(root: &Path) -> Result<BTreeMap<PathBuf, Entry>, String> {
    let mut out = BTreeMap::new();
    let mut stack = vec![root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let rd = std::fs::read_dir(&dir)
            .map_err(|e| format!("roundtrip: read_dir {}: {e}", dir.display()))?;
        for ent in rd {
            let ent =
                ent.map_err(|e| format!("roundtrip: dir entry under {}: {e}", dir.display()))?;
            let path = ent.path();
            let name = ent.file_name();
            if name == ".git" {
                continue;
            }
            let md = std::fs::symlink_metadata(&path)
                .map_err(|e| format!("roundtrip: stat {}: {e}", path.display()))?;
            if md.is_dir() {
                stack.push(path);
                continue;
            }
            let rel = path
                .strip_prefix(root)
                .map_err(|_| {
                    format!(
                        "roundtrip: {} is not under {}",
                        path.display(),
                        root.display()
                    )
                })?
                .to_path_buf();
            out.insert(
                rel,
                Entry {
                    exec: is_exec(&md),
                    symlink: md.is_symlink(),
                    len: md.len(),
                },
            );
        }
    }
    Ok(out)
}

/// The executable bit, on the platforms that have one.
#[cfg(unix)]
fn is_exec(md: &std::fs::Metadata) -> bool {
    use std::os::unix::fs::PermissionsExt;
    md.permissions().mode() & 0o111 != 0
}

/// Non-unix has no executable bit to lose, so the comparison cannot report one.
#[cfg(not(unix))]
fn is_exec(_md: &std::fs::Metadata) -> bool {
    false
}

/// What a tree comparison found. `differences` is empty exactly when the trees are equal.
pub struct TreeDiff {
    pub files_left: usize,
    pub files_right: usize,
    pub bytes_left: u64,
    pub bytes_right: u64,
    /// Human-readable, one per differing path. Bounded when printed, never when counted —
    /// "3 differences" and "3 000" are not the same news.
    pub differences: Vec<String>,
}

impl TreeDiff {
    pub fn identical(&self) -> bool {
        self.differences.is_empty()
    }
}

/// **Compare two working trees: names, modes, sizes, and CONTENTS byte for byte.**
///
/// The content read is what makes this a fidelity test rather than a manifest check. Sizes
/// matching is not contents matching, and the failure this exists to catch — a byte lost in
/// a pack, a CRLF introduced, a symlink materialised as a regular file holding its target —
/// keeps the size and changes the meaning.
pub fn compare_trees(left: &Path, right: &Path) -> Result<TreeDiff, String> {
    let l = walk(left)?;
    let r = walk(right)?;
    let mut diffs = Vec::new();
    for (path, le) in &l {
        match r.get(path) {
            None => diffs.push(format!("missing on the round trip: {}", path.display())),
            Some(re) => {
                if le.symlink != re.symlink {
                    diffs.push(format!(
                        "symlink-ness differs: {} ({} → {})",
                        path.display(),
                        le.symlink,
                        re.symlink
                    ));
                } else if le.exec != re.exec {
                    diffs.push(format!(
                        "executable bit differs: {} ({} → {})",
                        path.display(),
                        le.exec,
                        re.exec
                    ));
                } else if le.len != re.len {
                    diffs.push(format!(
                        "size differs: {} ({} → {} bytes)",
                        path.display(),
                        le.len,
                        re.len
                    ));
                } else if !same_contents(&left.join(path), &right.join(path), le.symlink)? {
                    diffs.push(format!(
                        "CONTENTS differ at the same size: {}",
                        path.display()
                    ));
                }
            }
        }
    }
    for path in r.keys() {
        if !l.contains_key(path) {
            diffs.push(format!("appeared out of nowhere: {}", path.display()));
        }
    }
    Ok(TreeDiff {
        files_left: l.len(),
        files_right: r.len(),
        bytes_left: l.values().map(|e| e.len).sum(),
        bytes_right: r.values().map(|e| e.len).sum(),
        differences: diffs,
    })
}

/// Byte equality. Symlinks compare by TARGET, not by following them — following would
/// compare the pointee twice and miss a link that now points somewhere else.
fn same_contents(a: &Path, b: &Path, symlink: bool) -> Result<bool, String> {
    if symlink {
        let ta = std::fs::read_link(a)
            .map_err(|e| format!("roundtrip: readlink {}: {e}", a.display()))?;
        let tb = std::fs::read_link(b)
            .map_err(|e| format!("roundtrip: readlink {}: {e}", b.display()))?;
        return Ok(ta == tb);
    }
    let ba = std::fs::read(a).map_err(|e| format!("roundtrip: read {}: {e}", a.display()))?;
    let bb = std::fs::read(b).map_err(|e| format!("roundtrip: read {}: {e}", b.display()))?;
    Ok(ba == bb)
}

/// A deterministic generator, so a failing run reproduces from its printed seed.
///
/// A real RNG would make the mutation pass unrepeatable, which is the wrong trade for a
/// harness: the value of "it broke" is mostly in being able to make it break again.
struct Lcg(u64);

impl Lcg {
    fn next(&mut self) -> u64 {
        // Numerical Recipes' constants; good enough to scatter file choices, and the point
        // is reproducibility, not statistical quality.
        self.0 = self
            .0
            .wrapping_mul(6364136223846793005)
            .wrapping_add(1442695040888963407);
        self.0
    }

    fn below(&mut self, n: usize) -> usize {
        if n == 0 {
            0
        } else {
            (self.next() >> 33) as usize % n
        }
    }
}

/// What the mutation pass did, so the report can say it rather than imply it.
pub struct Mutation {
    pub added: usize,
    pub modified: usize,
    pub deleted: usize,
    pub commits: usize,
}

/// **Change the tree the way a working day changes it**, then commit — several times.
///
/// Additions, modifications AND deletions, in separate commits, because the three take
/// different paths through a push: a delete produces no object at all, a modification
/// produces a candidate for delta encoding against a base the server already has, and an
/// addition produces neither. A mutation pass that only appends measures one third of it.
pub fn mutate(tree: &Path, seed: u64, commits: usize) -> Result<Mutation, String> {
    let mut rng = Lcg(seed);
    let mut m = Mutation {
        added: 0,
        modified: 0,
        deleted: 0,
        commits: 0,
    };
    for round in 0..commits {
        let existing: Vec<PathBuf> = walk(tree)?.into_keys().collect();

        // Additions: a fresh directory per round, so they cannot collide with the donor.
        let dir = tree.join(format!("roundtrip-{round}"));
        std::fs::create_dir_all(&dir)
            .map_err(|e| format!("roundtrip: mkdir {}: {e}", dir.display()))?;
        for f in 0..40 {
            let n = 200 + rng.below(6000);
            let body: String = std::iter::repeat_n('x', n).collect();
            let p = dir.join(format!("added-{f:03}.txt"));
            std::fs::write(&p, format!("round {round} file {f}\n{body}\n"))
                .map_err(|e| format!("roundtrip: write {}: {e}", p.display()))?;
            m.added += 1;
        }

        // Modifications: append to files that were already there, which is what makes them
        // delta candidates against a base the server holds.
        for _ in 0..30 {
            if existing.is_empty() {
                break;
            }
            let p = tree.join(&existing[rng.below(existing.len())]);
            if std::fs::symlink_metadata(&p)
                .map(|md| md.is_symlink())
                .unwrap_or(true)
            {
                continue;
            }
            let mut b =
                std::fs::read(&p).map_err(|e| format!("roundtrip: read {}: {e}", p.display()))?;
            b.extend_from_slice(format!("\n// roundtrip round {round}\n").as_bytes());
            std::fs::write(&p, &b).map_err(|e| format!("roundtrip: write {}: {e}", p.display()))?;
            m.modified += 1;
        }

        // Deletions: only from what this harness added in an earlier round, never from the
        // donor's own files. Deleting somebody's source to measure a push is vandalism even
        // in a scratch tree, and it would make the final comparison meaningless.
        if round > 0 {
            let victim_dir = tree.join(format!("roundtrip-{}", round - 1));
            for f in 0..10 {
                let p = victim_dir.join(format!("added-{f:03}.txt"));
                if p.exists() {
                    std::fs::remove_file(&p)
                        .map_err(|e| format!("roundtrip: rm {}: {e}", p.display()))?;
                    m.deleted += 1;
                }
            }
        }

        git(tree, &["add", "-A"])?;
        git(
            tree,
            &[
                "-c",
                "user.email=roundtrip@nordisk.invalid",
                "-c",
                "user.name=roundtrip",
                "commit",
                "-q",
                "-m",
                &format!("roundtrip mutation round {round}"),
            ],
        )?;
        m.commits += 1;
    }
    Ok(m)
}

/// **Every local branch as an EXPLICIT refspec**, because the wildcard form does not work.
///
/// `gunnar-cli push --help` offers `+refs/heads/*:refs/heads/*` as one of its two examples.
/// It is rejected — MEASURED 2026-08-06 against `gunnar e295850`:
///
/// ```text
/// repository: resolve "refs/heads/*": couldn't parse revision: "refs/heads/*"
///   at gix-revision-0.48.0/src/spec/parse/function.rs:491
/// ```
///
/// The client hands the refspec's left side to gix's *revision* parser, which resolves a
/// single object and has no notion of a glob. So the documented form cannot ever have
/// worked, and a harness that used it would report gunnar as broken when the defect is in
/// the client's own help text. Enumerating the branches here keeps this arm measuring the
/// SERVER, and leaves the client defect to be reported rather than worked around silently.
fn branch_refspecs(tree: &Path) -> Result<Vec<String>, String> {
    let out = git(
        tree,
        &["for-each-ref", "--format=%(refname)", "refs/heads/"],
    )?;
    let specs: Vec<String> = out
        .lines()
        .map(str::trim)
        .filter(|l| !l.is_empty())
        .map(|r| format!("{r}:{r}"))
        .collect();
    if specs.is_empty() {
        return Err(format!(
            "roundtrip: {} has no local branches to push",
            tree.display()
        ));
    }
    Ok(specs)
}

/// Run `git` in `cwd`, failing loudly. Stock git, deliberately: the donor corpus must not
/// be gunnar's own idea of what a repository looks like.
fn git(cwd: &Path, args: &[&str]) -> Result<String, String> {
    let t = timed("git", args, Some(cwd))?;
    if !t.ok {
        return Err(format!(
            "roundtrip: git {:?} failed: {}{}",
            args, t.stdout, t.stderr
        ));
    }
    Ok(t.stdout)
}

/// One line of the report — a step, what the client waited, and what the step said.
pub struct Row {
    pub step: String,
    pub wall: Duration,
    pub detail: String,
}

/// Everything one donor produced.
pub struct RepoReport {
    pub donor: String,
    pub rows: Vec<Row>,
    /// The verdict. `Some(false)` anywhere means the run failed regardless of timings.
    pub clean_first: Option<bool>,
    pub clean_after_mutation: Option<bool>,
}

/// The whole run.
pub struct Report {
    pub repos: Vec<RepoReport>,
    pub seed: u64,
}

impl Report {
    /// A run passes only if every tree comparison it performed came back identical. Timings
    /// never enter the verdict — a fast wrong answer is still wrong.
    pub fn passed(&self) -> bool {
        self.repos
            .iter()
            .all(|r| r.clean_first.unwrap_or(false) && r.clean_after_mutation.unwrap_or(true))
    }
}

/// Locate `gunnar-cli`. The workspace target directory is configurable (this machine points
/// `target-dir` at `~/cargo-target`), so guessing `target/release` finds nothing and reports
/// it as a missing binary rather than a misconfiguration.
fn gunnar_cli(root: &Path) -> Result<PathBuf, String> {
    let mut tried = Vec::new();
    let mut candidates: Vec<PathBuf> = Vec::new();
    if let Ok(p) = std::env::var("GUNNAR_CLI") {
        candidates.push(PathBuf::from(p));
    }
    if let Ok(t) = std::env::var("CARGO_TARGET_DIR") {
        candidates.push(PathBuf::from(t).join("release").join("gunnar-cli"));
    }
    candidates.push(root.join("target").join("release").join("gunnar-cli"));
    if let Ok(h) = std::env::var("HOME") {
        candidates.push(
            PathBuf::from(h)
                .join("cargo-target")
                .join("release")
                .join("gunnar-cli"),
        );
    }
    for c in candidates {
        if c.is_file() {
            return Ok(c);
        }
        tried.push(c.display().to_string());
    }
    Err(format!(
        "roundtrip: gunnar-cli not found. Build it with\n  \
         cargo build --release --no-default-features --features client,control --bin gunnar-cli\n\
         or set $GUNNAR_CLI. Tried: {}",
        tried.join(", ")
    ))
}

/// Execute the plan. Returns the report; the caller decides how loudly to print it.
pub fn run(root: &Path, plan: &Plan) -> Result<Report, String> {
    let cli = gunnar_cli(root)?;
    let cli = cli.to_string_lossy().into_owned();
    let ident = plan.identity.to_string_lossy().into_owned();

    if plan.work.exists() {
        std::fs::remove_dir_all(&plan.work)
            .map_err(|e| format!("roundtrip: clearing {}: {e}", plan.work.display()))?;
    }
    std::fs::create_dir_all(&plan.work)
        .map_err(|e| format!("roundtrip: mkdir {}: {e}", plan.work.display()))?;

    let mut report = Report {
        repos: Vec::new(),
        seed: plan.seed,
    };

    for (donor, url) in &plan.donors {
        let mut rr = RepoReport {
            donor: donor.clone(),
            rows: Vec::new(),
            clean_first: None,
            clean_after_mutation: None,
        };
        let src = plan.work.join(format!("src-{donor}"));
        let down1 = plan.work.join(format!("down1-{donor}"));
        let down2 = plan.work.join(format!("down2-{donor}"));

        // ── 1. the donor clone, by STOCK git, read-only ─────────────────────────────
        let t = timed(
            "git",
            &["clone", "--quiet", url, &src.to_string_lossy()],
            Some(&plan.work),
        )?;
        if !t.ok {
            return Err(format!(
                "roundtrip: cloning donor {url}: {}{}",
                t.stdout, t.stderr
            ));
        }
        let objects = git(&src, &["rev-list", "--all", "--count"])?
            .trim()
            .to_string();
        rr.rows.push(Row {
            step: "donor clone (stock git)".into(),
            wall: t.wall,
            detail: format!("{objects} commits, {} on disk", human(dir_bytes(&src)?)),
        });

        // ── 2. the scratch repository on gunnar ─────────────────────────────────────
        // sha1: the donor was made by stock git, and a sha256 repository would refuse it.
        let path = plan.repo_path(donor);
        let t = timed(
            "git",
            &["config", "--get", "extensions.objectformat"],
            Some(&src),
        )
        .unwrap_or(Timed {
            wall: Duration::ZERO,
            stdout: String::new(),
            stderr: String::new(),
            ok: false,
        });
        let hash = if t.stdout.trim() == "sha256" {
            "sha256"
        } else {
            "sha1"
        };
        let t = timed(
            &cli,
            &[
                "control",
                "repo",
                "create",
                &path,
                "--hash",
                hash,
                "--at",
                &plan.control,
            ],
            None,
        )?;
        // An existing scratch repository is not a failure — a re-run must be possible.
        rr.rows.push(Row {
            step: "gunnar repo create".into(),
            wall: t.wall,
            detail: if t.ok {
                format!("{path} ({hash})")
            } else {
                format!("{path} already present")
            },
        });

        // ── 3. push the UNCHANGED donor ─────────────────────────────────────────────
        let push_url = plan.push_url(donor);
        let specs = branch_refspecs(&src)?;
        let mut argv: Vec<&str> = vec!["push", &push_url];
        argv.extend(specs.iter().map(String::as_str));
        argv.extend(["-i", &ident]);
        let t = timed(&cli, &argv, Some(&src))?;
        if !t.ok {
            return Err(format!(
                "roundtrip: pushing {donor} to {push_url}: {}{}",
                t.stdout, t.stderr
            ));
        }
        rr.rows.push(Row {
            step: "push, unchanged".into(),
            wall: t.wall,
            detail: last_line(&t.stdout),
        });

        // ── 3b. point the server's HEAD at a branch that EXISTS ─────────────────────
        //
        // `control repo create` sets the default branch to `main`, always. A donor whose
        // default is `master` therefore pushes CLEANLY — `refs_ok=1`, `unpack ok`,
        // `ls-remote` lists the ref — into a repository nobody can clone, because HEAD
        // names a ref that was never created. MEASURED 2026-08-06 on `alice/rt-foot`:
        // push succeeded, `ls-remote` returned `refs/heads/master` and **no HEAD line at
        // all**, and the clone failed.
        //
        // So this is not harness bookkeeping, it is the repair for a defect the harness
        // found: nothing between `repo create` and the failed clone says the default
        // branch does not exist. Doing it here keeps the arm measuring transfers, and the
        // defect is reported rather than silently absorbed.
        let head_branch = git(&src, &["symbolic-ref", "--short", "HEAD"])?
            .trim()
            .to_string();
        let t = timed(
            &cli,
            &[
                "control",
                "repo",
                "set-default-branch",
                &path,
                &head_branch,
                "--at",
                &plan.control,
            ],
            None,
        )?;
        rr.rows.push(Row {
            step: "set default branch".into(),
            wall: t.wall,
            detail: if t.ok {
                format!("{head_branch} (create had left it at `main`)")
            } else {
                format!("FAILED for `{head_branch}`: {}", last_line(&t.stderr))
            },
        });

        // ── 4. clone it back DOWN from gunnar ───────────────────────────────────────
        let t = timed(
            &cli,
            &["clone", &push_url, &down1.to_string_lossy(), "-i", &ident],
            Some(&plan.work),
        )?;
        if !t.ok {
            return Err(format!(
                "roundtrip: cloning {donor} back from gunnar: {}{}",
                t.stdout, t.stderr
            ));
        }
        rr.rows.push(Row {
            step: "clone back from gunnar".into(),
            wall: t.wall,
            detail: last_line(&t.stdout),
        });

        // ── 5. THE VERDICT: are the two working trees the same? ─────────────────────
        let t0 = Instant::now();
        let diff = compare_trees(&src, &down1)?;
        rr.clean_first = Some(diff.identical());
        rr.rows.push(Row {
            step: "tree compare (bytes)".into(),
            wall: t0.elapsed(),
            detail: describe(&diff),
        });

        // ── 6. mutate, push again, come back, compare again ─────────────────────────
        if !plan.no_mutate {
            let m = mutate(&src, plan.seed, 3)?;
            let specs = branch_refspecs(&src)?;
            let mut argv: Vec<&str> = vec!["push", &push_url];
            argv.extend(specs.iter().map(String::as_str));
            argv.extend(["-i", &ident]);
            let t = timed(&cli, &argv, Some(&src))?;
            if !t.ok {
                return Err(format!(
                    "roundtrip: pushing mutated {donor}: {}{}",
                    t.stdout, t.stderr
                ));
            }
            rr.rows.push(Row {
                step: format!(
                    "push, mutated (+{} ~{} -{} in {} commits)",
                    m.added, m.modified, m.deleted, m.commits
                ),
                wall: t.wall,
                detail: last_line(&t.stdout),
            });

            let t = timed(
                &cli,
                &["clone", &push_url, &down2.to_string_lossy(), "-i", &ident],
                Some(&plan.work),
            )?;
            if !t.ok {
                return Err(format!(
                    "roundtrip: second clone of {donor}: {}{}",
                    t.stdout, t.stderr
                ));
            }
            rr.rows.push(Row {
                step: "clone back, mutated".into(),
                wall: t.wall,
                detail: last_line(&t.stdout),
            });

            let t0 = Instant::now();
            let diff = compare_trees(&src, &down2)?;
            rr.clean_after_mutation = Some(diff.identical());
            rr.rows.push(Row {
                step: "tree compare, mutated".into(),
                wall: t0.elapsed(),
                detail: describe(&diff),
            });
        }

        report.repos.push(rr);
    }

    if !plan.keep {
        let _ = std::fs::remove_dir_all(&plan.work);
    }
    Ok(report)
}

/// One line summarising a comparison — and it names the failures, because "not identical"
/// without saying which file is a verdict nobody can act on.
fn describe(d: &TreeDiff) -> String {
    if d.identical() {
        return format!(
            "IDENTICAL — {} files, {}",
            d.files_left,
            human(d.bytes_left)
        );
    }
    let shown: Vec<&str> = d.differences.iter().take(5).map(String::as_str).collect();
    format!(
        "{} DIFFERENCES ({} files/{} vs {} files/{}): {}{}",
        d.differences.len(),
        d.files_left,
        human(d.bytes_left),
        d.files_right,
        human(d.bytes_right),
        shown.join("; "),
        if d.differences.len() > shown.len() {
            " …"
        } else {
            ""
        }
    )
}

/// The last non-empty line of a child's stdout — where both `push` and `clone` put their
/// summary.
fn last_line(s: &str) -> String {
    s.lines()
        .rev()
        .map(str::trim)
        .find(|l| !l.is_empty())
        .unwrap_or("")
        .to_string()
}

/// Bytes on disk under `dir`, following nothing.
fn dir_bytes(dir: &Path) -> Result<u64, String> {
    let mut total = 0u64;
    let mut stack = vec![dir.to_path_buf()];
    while let Some(d) = stack.pop() {
        let rd = match std::fs::read_dir(&d) {
            Ok(r) => r,
            Err(_) => continue,
        };
        for e in rd.flatten() {
            let md = match std::fs::symlink_metadata(e.path()) {
                Ok(m) => m,
                Err(_) => continue,
            };
            if md.is_dir() {
                stack.push(e.path());
            } else {
                total += md.len();
            }
        }
    }
    Ok(total)
}

/// Human-readable bytes, one decimal, so a report column stays narrow.
fn human(b: u64) -> String {
    const K: f64 = 1024.0;
    let b = b as f64;
    if b < K {
        format!("{b:.0} B")
    } else if b < K * K {
        format!("{:.1} KiB", b / K)
    } else if b < K * K * K {
        format!("{:.1} MiB", b / (K * K))
    } else {
        format!("{:.1} GiB", b / (K * K * K))
    }
}

/// Print the report. Timings are context; the identical/DIFFERENCES verdict is the answer.
pub fn print(report: &Report) {
    for r in &report.repos {
        println!("\n── {} ─────────────────────────────────────────", r.donor);
        for row in &r.rows {
            println!(
                "  {:<44} {:>8.2} s   {}",
                row.step,
                row.wall.as_secs_f64(),
                row.detail
            );
        }
    }
    println!(
        "\nseed {} (pass --seed {} to reproduce)",
        report.seed, report.seed
    );
    println!(
        "{}",
        if report.passed() {
            "roundtrip: PASS — every tree came back identical"
        } else {
            "roundtrip: FAIL — a tree did not survive the round trip"
        }
    );
}

#[cfg(test)]
mod tests {
    use super::*;

    /// **The never-touch-the-donor law, asserted rather than trusted.** Every write target
    /// this harness can form must carry the scratch prefix and the scratch account, so a
    /// donor URL can never appear as a push URL.
    ///
    /// RED if someone "simplifies" `push_url` to take a caller-supplied name.
    #[test]
    fn a_push_target_can_only_ever_be_a_scratch_repository() {
        let plan = Plan {
            control: DEFAULT_CONTROL.into(),
            ssh_base: DEFAULT_SSH.into(),
            account: DEFAULT_ACCOUNT.into(),
            identity: PathBuf::from("/dev/null"),
            work: PathBuf::from("/tmp/x"),
            donors: vec![("foot".into(), "https://codeberg.org/dnkl/foot.git".into())],
            seed: 1,
            keep: false,
            no_mutate: false,
        };
        let url = plan.push_url("foot");
        assert!(
            url.contains(&format!("/{DEFAULT_ACCOUNT}/{GUNNAR_PREFIX}foot")),
            "push target is scratch: {url}"
        );
        assert!(
            !url.contains("codeberg.org"),
            "a donor host can never be a push target: {url}"
        );
        assert!(
            !url.contains("github.com"),
            "a donor host can never be a push target: {url}"
        );
        assert_eq!(plan.repo_path("foot"), "alice/rt-foot");
    }

    /// Naming a donor REPLACES the defaults. Adding to them would silently clone two
    /// repositories nobody asked for and report them as part of the run.
    #[test]
    fn naming_a_donor_replaces_the_defaults_rather_than_adding_to_them() {
        let args: Vec<String> = ["--donor", "tiny=https://example.invalid/tiny.git"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let p = parse_args(&args).expect("parses");
        assert_eq!(p.donors.len(), 1, "exactly the named donor: {:?}", p.donors);
        assert_eq!(p.donors[0].0, "tiny");
    }

    /// A mistyped flag must STOP the run. Ignoring it would run a different shape of test
    /// than the operator asked for and report it under their command line.
    #[test]
    fn an_unknown_flag_is_an_error_not_a_shrug() {
        let args = vec!["--no-mutat".to_string()];
        let e = parse_args(&args).expect_err("unknown flags must fail");
        assert!(
            e.contains("--no-mutat"),
            "the message names the bad flag: {e}"
        );
    }

    /// The comparison must catch a change that keeps the size. That is the failure mode a
    /// manifest check misses and the one a git server can actually have.
    #[test]
    fn contents_are_compared_not_just_sizes() {
        let base = std::env::temp_dir().join(format!("rt-cmp-{}", std::process::id()));
        let (l, r) = (base.join("l"), base.join("r"));
        std::fs::create_dir_all(&l).unwrap();
        std::fs::create_dir_all(&r).unwrap();
        std::fs::write(l.join("f.txt"), b"aaaa").unwrap();
        std::fs::write(r.join("f.txt"), b"aaab").unwrap();

        let d = compare_trees(&l, &r).expect("compares");
        assert!(!d.identical(), "same size, different bytes must NOT pass");
        assert_eq!(
            d.bytes_left, d.bytes_right,
            "the sizes really are equal, so only content caught it"
        );
        assert!(
            d.differences[0].contains("CONTENTS differ"),
            "{:?}",
            d.differences
        );

        std::fs::write(r.join("f.txt"), b"aaaa").unwrap();
        assert!(
            compare_trees(&l, &r).expect("compares").identical(),
            "equal trees compare equal"
        );
        let _ = std::fs::remove_dir_all(&base);
    }

    /// A missing file and an extra file are different news and must read differently.
    #[test]
    fn a_lost_file_and_an_extra_file_are_reported_apart() {
        let base = std::env::temp_dir().join(format!("rt-miss-{}", std::process::id()));
        let (l, r) = (base.join("l"), base.join("r"));
        std::fs::create_dir_all(&l).unwrap();
        std::fs::create_dir_all(&r).unwrap();
        std::fs::write(l.join("gone.txt"), b"x").unwrap();
        std::fs::write(r.join("extra.txt"), b"y").unwrap();

        let d = compare_trees(&l, &r).expect("compares");
        assert_eq!(d.differences.len(), 2, "{:?}", d.differences);
        assert!(
            d.differences
                .iter()
                .any(|s| s.contains("missing on the round trip")),
            "{:?}",
            d.differences
        );
        assert!(
            d.differences
                .iter()
                .any(|s| s.contains("appeared out of nowhere")),
            "{:?}",
            d.differences
        );
        let _ = std::fs::remove_dir_all(&base);
    }

    /// The verdict is the comparison, never the clock. A report with a failed comparison
    /// must not pass however fast it was.
    #[test]
    fn a_failed_comparison_fails_the_run_regardless_of_timings() {
        let fast = Row {
            step: "x".into(),
            wall: Duration::from_millis(1),
            detail: String::new(),
        };
        let r = Report {
            seed: 1,
            repos: vec![RepoReport {
                donor: "foot".into(),
                rows: vec![fast],
                clean_first: Some(false),
                clean_after_mutation: Some(true),
            }],
        };
        assert!(!r.passed(), "a lost byte is a failure at any speed");
    }

    /// The generator must be reproducible from its seed — the whole reason it is not a real
    /// RNG.
    #[test]
    fn the_mutation_generator_reproduces_from_its_seed() {
        let a: Vec<usize> = (0..8).scan(Lcg(42), |g, _| Some(g.below(1000))).collect();
        let b: Vec<usize> = (0..8).scan(Lcg(42), |g, _| Some(g.below(1000))).collect();
        assert_eq!(a, b, "same seed, same sequence");
        let c: Vec<usize> = (0..8).scan(Lcg(43), |g, _| Some(g.below(1000))).collect();
        assert_ne!(a, c, "a different seed must actually differ");
    }
}