xtask/tests/roundtrip_harness.rs
//! `cargo xtask roundtrip`, the harness itself, against a stand-in `gunnar-cli` whose
//! "server" is a directory of bare repositories (stock git, local paths, no network).
//!
//! What is under test is the HARNESS: that a faithful server is reported identical
//! before and after the mutation pass, and that a server which loses a file on the way
//! back is reported as a failure naming that file. The stand-in is selected through
//! `$GUNNAR_CLI`, which is why this is its own test binary: the variable is the
//! process's, so one test sets it.
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use xtask::roundtrip::{self, Plan};
/// The stand-in: `control repo create|set-default-branch`, `push`, `clone`, with the
/// arguments `roundtrip::run` passes. `$ROUNDTRIP_LOSE` names a file the "server"
/// drops from every clone it hands back.
const FAKE_CLI: &str = r#"#!/bin/sh
set -e
case "$1" in
control)
case "$3" in
create) git init -q --bare "$8/$4" ;;
set-default-branch) git --git-dir="$7/$4" symbolic-ref HEAD "refs/heads/$5" ;;
*) exit 2 ;;
esac ;;
push)
url=$2; shift 2; specs=""
while [ $# -gt 0 ]; do
case "$1" in -i) shift 2 ;; *) specs="$specs $1"; shift ;; esac
done
git push -q "$url" $specs
echo "pushed to $url" ;;
clone)
git clone -q "$2" "$3"
if [ -n "$ROUNDTRIP_LOSE" ]; then rm -f "$3/$ROUNDTRIP_LOSE"; fi
echo "cloned into $3" ;;
*) exit 2 ;;
esac
"#;
struct Scratch(PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn git(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.current_dir(dir)
.args(["-c", "user.name=t", "-c", "user.email=t@mock.invalid"])
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
/// A donor with a plain file, an executable and a symlink, on branch `trunk`.
fn donor(at: &Path) {
std::fs::create_dir_all(at.join("src")).unwrap();
git(at, &["init", "-q", "-b", "trunk"]);
std::fs::write(at.join("README.md"), "# donor\n").unwrap();
std::fs::write(at.join("src/main.rs"), "fn main() {}\n").unwrap();
std::fs::write(at.join("run.sh"), "#!/bin/sh\necho hi\n").unwrap();
std::fs::set_permissions(at.join("run.sh"), std::fs::Permissions::from_mode(0o755)).unwrap();
std::os::unix::fs::symlink("src/main.rs", at.join("main-link")).unwrap();
git(at, &["add", "-A"]);
git(at, &["commit", "-q", "-m", "the donor"]);
}
fn plan(base: &Path, no_mutate: bool) -> Plan {
let srv = base.join("srv").display().to_string();
Plan {
control: srv.clone(),
ssh_base: srv,
account: "alice".into(),
identity: PathBuf::from("/dev/null"),
work: base.join("work"),
donors: vec![("tiny".into(), base.join("donor").display().to_string())],
seed: 7,
keep: false,
no_mutate,
}
}
#[test]
fn a_faithful_server_passes_and_a_lossy_one_is_named() {
let base =
std::env::temp_dir().join(format!("gunnar-roundtrip-harness-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("srv/alice")).unwrap();
let _s = Scratch(base.clone());
donor(&base.join("donor"));
let cli = base.join("gunnar-cli");
std::fs::write(&cli, FAKE_CLI).unwrap();
std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap();
std::env::set_var("GUNNAR_CLI", &cli);
std::env::remove_var("ROUNDTRIP_LOSE");
// ── a faithful server: identical before and after the mutation pass ──
let report = roundtrip::run(&base, &plan(&base, false)).expect("the round trip runs");
roundtrip::print(&report);
assert!(report.passed(), "a faithful server failed the harness");
let r = &report.repos[0];
assert_eq!(
(r.clean_first, r.clean_after_mutation),
(Some(true), Some(true))
);
let steps: Vec<&str> = r.rows.iter().map(|row| row.step.as_str()).collect();
assert_eq!(
steps[..6],
[
"donor clone (stock git)",
"gunnar repo create",
"push, unchanged",
"set default branch",
"clone back from gunnar",
"tree compare (bytes)"
]
);
let compare = &r.rows[5].detail;
assert!(compare.starts_with("IDENTICAL — 4 files"), "{compare}");
assert!(
r.rows[3].detail.starts_with("trunk"),
"the default branch follows the donor: {}",
r.rows[3].detail
);
let mutated = r
.rows
.iter()
.find(|row| row.step.starts_with("push, mutated"))
.expect("the mutation pass ran");
assert!(
mutated.step.contains("+120") && mutated.step.contains("-20 in 3 commits"),
"{}",
mutated.step
);
assert!(
!base.join("work").exists(),
"the scratch directory was kept without --keep"
);
// ── a server that loses a file on the way back ──
std::env::set_var("ROUNDTRIP_LOSE", "run.sh");
let mut p = plan(&base, true);
p.keep = true;
let _ = std::fs::remove_dir_all(base.join("srv/alice/rt-tiny"));
let report = roundtrip::run(&base, &p).expect("the round trip runs");
std::env::remove_var("ROUNDTRIP_LOSE");
roundtrip::print(&report);
assert!(!report.passed(), "a lost file passed the harness");
let r = &report.repos[0];
assert_eq!((r.clean_first, r.clean_after_mutation), (Some(false), None));
let compare = &r
.rows
.iter()
.find(|row| row.step == "tree compare (bytes)")
.unwrap()
.detail;
assert!(
compare.starts_with("1 DIFFERENCES")
&& compare.contains("missing on the round trip: run.sh"),
"{compare}"
);
assert!(
base.join("work/down1-tiny").is_dir(),
"--keep keeps the scratch directory"
);
// ── a donor that cannot be cloned stops the run by name ──
let mut p = plan(&base, true);
p.donors = vec![(
"ghost".into(),
base.join("no-such-donor").display().to_string(),
)];
let e = roundtrip::run(&base, &p)
.err()
.expect("an unclonable donor");
assert!(e.contains("cloning donor"), "{e}");
std::env::remove_var("GUNNAR_CLI");
}
/// The comparison sees every way a round trip can change an entry while keeping its
/// name: the executable bit, symlink-ness, a symlink's target, and the size.
#[test]
fn the_comparison_names_mode_link_and_size_changes() {
let base = std::env::temp_dir().join(format!("gunnar-roundtrip-cmp-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let _s = Scratch(base.clone());
let (l, r) = (base.join("l"), base.join("r"));
for d in [&l, &r] {
std::fs::create_dir_all(d.join("sub/.git")).unwrap();
std::fs::write(d.join("sub/.git/HEAD"), format!("{}\n", d.display())).unwrap();
std::fs::write(d.join("target"), "t\n").unwrap();
std::fs::write(d.join("other"), "o\n").unwrap();
}
std::fs::write(l.join("tool"), "#!/bin/sh\n").unwrap();
std::fs::write(r.join("tool"), "#!/bin/sh\n").unwrap();
std::fs::set_permissions(l.join("tool"), std::fs::Permissions::from_mode(0o755)).unwrap();
std::os::unix::fs::symlink("target", l.join("link")).unwrap();
std::fs::write(r.join("link"), "target").unwrap();
std::os::unix::fs::symlink("target", l.join("moved")).unwrap();
std::os::unix::fs::symlink("zarget", r.join("moved")).unwrap();
std::fs::write(l.join("sub/grown"), "a").unwrap();
std::fs::write(r.join("sub/grown"), "ab").unwrap();
let w = roundtrip::walk(&l).unwrap();
assert!(
!w.keys().any(|p| p.starts_with("sub/.git")),
"`.git` is not content: {:?}",
w.keys()
);
assert!(w[Path::new("tool")].exec && w[Path::new("link")].symlink);
let d = roundtrip::compare_trees(&l, &r).unwrap();
let mut got = d.differences.clone();
got.sort();
assert_eq!(
got,
[
"CONTENTS differ at the same size: moved",
"executable bit differs: tool (true → false)",
"size differs: sub/grown (1 → 2 bytes)",
"symlink-ness differs: link (true → false)",
]
);
assert_eq!((d.files_left, d.files_right), (6, 6));
assert!(roundtrip::compare_trees(&l, &base.join("absent")).is_err());
}
/// The mutation pass adds, modifies and deletes, in separate commits, and only ever
/// deletes what it added itself.
#[test]
fn the_mutation_pass_adds_modifies_and_deletes_only_its_own_files() {
let base = std::env::temp_dir().join(format!("gunnar-roundtrip-mutate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let _s = Scratch(base.clone());
donor(&base);
let m = roundtrip::mutate(&base, 1, 2).unwrap();
assert_eq!((m.added, m.deleted, m.commits), (80, 10, 2));
assert!(m.modified > 0 && m.modified <= 60, "{}", m.modified);
for f in ["README.md", "src/main.rs", "run.sh", "main-link"] {
assert!(
std::fs::symlink_metadata(base.join(f)).is_ok(),
"the donor's own {f} was deleted"
);
}
assert!(
!base.join("roundtrip-0/added-000.txt").exists()
&& base.join("roundtrip-0/added-010.txt").exists()
);
let log = Command::new("git")
.current_dir(&base)
.args(["log", "--format=%s"])
.output()
.unwrap();
let log = String::from_utf8_lossy(&log.stdout);
assert_eq!(
log.lines().collect::<Vec<_>>(),
[
"roundtrip mutation round 1",
"roundtrip mutation round 0",
"the donor"
]
);
let again = std::env::temp_dir().join(format!(
"gunnar-roundtrip-mutate-not-a-repo-{}",
std::process::id()
));
std::fs::create_dir_all(&again).unwrap();
let _t = Scratch(again.clone());
assert!(
roundtrip::mutate(&again, 1, 1).is_err(),
"a tree that is not a repository cannot be committed"
);
}