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

nordisk/gunnar

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