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