mod common;
use common::Repo;
use std::io::Write;
use std::process::{Command, Stdio};
#[cfg(unix)]
fn probe(r: &Repo, name: &str, exit: i32) {
let body = format!("#!/bin/sh\necho probe-{name} ran\nexit {exit}\n");
r.stage(name, &body);
let p = r.path(name);
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).expect("chmod");
r.git(&["add", name]);
}
#[cfg(not(unix))]
fn probe(r: &Repo, name: &str, exit: i32) {
let body = format!("@echo probe-{name} ran\r\n@exit /b {exit}\r\n");
r.stage(name, &body);
}
#[cfg(unix)]
const PROBE: &str = "probe.sh";
#[cfg(not(unix))]
const PROBE: &str = "probe.cmd";
fn manifest(r: &Repo, body: &str) {
r.stage("amont.conf", body);
trust(r);
}
fn trust(r: &Repo) {
let out = Command::new(env!("CARGO_BIN_EXE_amont"))
.arg("trust")
.current_dir(&r.dir)
.output()
.expect("amont trust");
assert!(out.status.success(), "could not trust the manifest");
}
fn pre_push(r: &Repo) -> (i32, String) {
let mut child = Command::new(env!("CARGO_BIN_EXE_amont"))
.arg("--hooks-dir")
.arg(r.path(".git/hooks"))
.arg("pre-push")
.current_dir(&r.dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn");
child
.stdin
.as_mut()
.expect("stdin")
.write_all(b"refs/heads/feat/x aaa refs/heads/feat/x bbb\n")
.expect("write");
let out = child.wait_with_output().expect("wait");
(
out.status.code().unwrap_or(-1),
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
),
)
}
fn push_range(r: &Repo, remote: &str, local: &str) -> (i32, String) {
let line = format!("refs/heads/feat/x {local} refs/heads/feat/x {remote}\n");
let mut child = Command::new(env!("CARGO_BIN_EXE_amont"))
.arg("--hooks-dir")
.arg(r.path(".git/hooks"))
.arg("pre-push")
.current_dir(&r.dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn");
child
.stdin
.as_mut()
.expect("stdin")
.write_all(line.as_bytes())
.expect("write");
let out = child.wait_with_output().expect("wait");
(
out.status.code().unwrap_or(-1),
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
),
)
}
#[test]
fn a_declared_check_runs_and_can_block_the_commit() {
let r = Repo::new();
probe(&r, PROBE, 1);
manifest(
&r,
&format!("pre-commit shellcheck * block ./{PROBE}\n"),
);
let run = r.hook("pre-commit", &[]);
assert!(
run.says("probe-"),
"the command never ran:\n{}",
run.output()
);
assert!(!run.passed(), "a blocking failure must stop the commit");
assert!(run.says("shellcheck"), "by name:\n{}", run.output());
}
#[test]
fn a_declared_check_that_passes_lets_the_commit_through() {
let r = Repo::new();
probe(&r, PROBE, 0);
manifest(
&r,
&format!("pre-commit shellcheck * block ./{PROBE}\n"),
);
let run = r.hook("pre-commit", &[]);
assert!(run.says("probe-"), "{}", run.output());
assert!(run.passed(), "{}", run.output());
}
#[test]
fn a_declared_check_can_choose_not_to_block() {
let r = Repo::new();
probe(&r, PROBE, 1);
manifest(&r, &format!("pre-commit smoke * warn ./{PROBE}\n"));
let run = r.hook("pre-commit", &[]);
assert!(run.says("probe-"), "it must still RUN:\n{}", run.output());
assert!(run.passed(), "warn must not block:\n{}", run.output());
assert!(run.says("set to warn"), "{}", run.output());
}
#[test]
fn scope_keeps_a_declared_check_out_of_unrelated_commits() {
let r = Repo::new();
probe(&r, PROBE, 1);
manifest(
&r,
&format!("pre-commit shellcheck *.rs block ./{PROBE}\n"),
);
let run = r.hook("pre-commit", &[]);
assert!(
!run.says("probe-"),
"it ran on a commit it does not apply to:\n{}",
run.output()
);
assert!(run.passed());
r.stage("src/main.rs", "fn main() {}\n");
let run = r.hook("pre-commit", &[]);
assert!(run.says("probe-"), "it must fire now:\n{}", run.output());
}
#[test]
fn hook_skip_and_severity_govern_a_declared_check() {
let r = Repo::new();
probe(&r, PROBE, 1);
manifest(
&r,
&format!("pre-commit shellcheck * block ./{PROBE}\n"),
);
assert!(!r.hook("pre-commit", &[]).passed(), "baseline");
r.git(&["config", "amont.severity.shellcheck", "warn"]);
let run = r.hook("pre-commit", &[]);
assert!(run.passed(), "severity override ignored:\n{}", run.output());
assert!(run.says("probe-"), "downgraded, not disabled");
r.git(&["config", "--unset", "amont.severity.shellcheck"]);
r.git(&["config", "hook.skip", "shellcheck"]);
let run = r.hook("pre-commit", &[]);
assert!(run.passed(), "{}", run.output());
assert!(
!run.says("probe-"),
"a skipped check must not run:\n{}",
run.output()
);
assert!(
run.says("skipped by"),
"and the skip must be announced:\n{}",
run.output()
);
}
#[test]
fn a_missing_command_is_a_gap_not_a_failure() {
let r = Repo::new();
manifest(
&r,
"pre-commit shellcheck * block ./no-such-binary-c8f2\n",
);
let run = r.hook("pre-commit", &[]);
assert!(
run.passed(),
"an absent tool must not block:\n{}",
run.output()
);
assert!(
run.says("could not run"),
"and must be reported as a gap:\n{}",
run.output()
);
}
#[test]
fn a_malformed_line_is_reported_on_every_commit() {
let r = Repo::new();
manifest(&r, "pre-commit shellcheck * LOUD ./probe.sh\n");
let run = r.hook("pre-commit", &[]);
assert!(run.passed(), "it must not block:\n{}", run.output());
assert!(run.says("shellcheck"), "{}", run.output());
assert!(
run.says("severity"),
"say what was wrong:\n{}",
run.output()
);
assert!(run.says("line 1"), "and where:\n{}", run.output());
}
#[test]
fn a_declared_pre_push_check_runs_after_the_built_ins() {
let r = Repo::new();
probe(&r, PROBE, 0);
manifest(&r, &format!("pre-push smoke * block ./{PROBE}\n"));
r.commit("init");
r.git(&["checkout", "-q", "-b", "feat/x"]);
let (code, out) = pre_push(&r);
assert_eq!(code, 0, "{out}");
assert!(out.contains("probe-"), "the declared check must run: {out}");
r.git(&["checkout", "-q", "-b", "nonsense-branch-name"]);
let mut child = Command::new(env!("CARGO_BIN_EXE_amont"))
.arg("--hooks-dir")
.arg(r.path(".git/hooks"))
.arg("pre-push")
.current_dir(&r.dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn");
child
.stdin
.as_mut()
.expect("stdin")
.write_all(
b"refs/heads/nonsense aaa refs/heads/nonsense-branch-name \
0000000000000000000000000000000000000000\n",
)
.expect("write");
let out = child.wait_with_output().expect("wait");
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert_ne!(out.status.code(), Some(0), "{text}");
assert!(
!text.contains("probe-"),
"a declared check ran despite a built-in blocking first: {text}"
);
}
#[test]
fn a_pre_push_scope_is_judged_against_the_pushed_range() {
let r = Repo::new();
probe(&r, PROBE, 0);
manifest(&r, &format!("pre-push smoke *.rs block ./{PROBE}\n"));
r.commit("init");
r.git(&["checkout", "-q", "-b", "feat/x"]);
let base = String::from_utf8_lossy(&r.git(&["rev-parse", "HEAD"]).stdout)
.trim()
.to_string();
r.stage("notes.md", "nothing to compile\n");
r.commit("docs only");
let docs = String::from_utf8_lossy(&r.git(&["rev-parse", "HEAD"]).stdout)
.trim()
.to_string();
let (code, out) = push_range(&r, &base, &docs);
assert_eq!(code, 0, "{out}");
assert!(
!out.contains("probe-"),
"a docs-only push must not fire a `*.rs` check: {out}"
);
r.stage("src/main.rs", "fn main() {}\n");
r.commit("some rust");
let rust = String::from_utf8_lossy(&r.git(&["rev-parse", "HEAD"]).stdout)
.trim()
.to_string();
let (code, out) = push_range(&r, &docs, &rust);
assert_eq!(code, 0, "{out}");
assert!(
out.contains("probe-"),
"a push containing Rust must fire it: {out}"
);
}
#[test]
fn a_reverted_change_within_the_pushed_range_still_counts() {
let r = Repo::new();
probe(&r, PROBE, 0);
manifest(&r, &format!("pre-push smoke *.rs block ./{PROBE}\n"));
r.commit("init");
r.git(&["checkout", "-q", "-b", "feat/z"]);
let base = String::from_utf8_lossy(&r.git(&["rev-parse", "HEAD"]).stdout)
.trim()
.to_string();
r.stage("src/temp.rs", "fn temp() {}\n");
r.commit("add a rust file");
r.git(&["rm", "-q", "src/temp.rs"]);
r.commit("remove it again");
let tip = String::from_utf8_lossy(&r.git(&["rev-parse", "HEAD"]).stdout)
.trim()
.to_string();
let (code, out) = push_range(&r, &base, &tip);
assert_eq!(code, 0, "{out}");
assert!(
out.contains("probe-"),
"an intermediate commit touched a .rs file even though the push \
nets to no .rs change between its endpoints: {out}"
);
}
#[test]
fn a_file_touched_only_by_a_merge_resolution_still_counts() {
let r = Repo::new();
probe(&r, PROBE, 0);
manifest(&r, &format!("pre-push smoke *.rs block ./{PROBE}\n"));
r.commit("init");
let base = String::from_utf8_lossy(&r.git(&["rev-parse", "HEAD"]).stdout)
.trim()
.to_string();
r.git(&["checkout", "-q", "-b", "feat/a"]);
r.stage("a.txt", "a\n");
r.commit("add a");
r.git(&["checkout", "-q", "-b", "feat/b", base.as_str()]);
r.stage("b.txt", "b\n");
r.commit("add b");
r.git(&["merge", "-q", "--no-ff", "--no-commit", "feat/a"]);
r.stage("src/resolution_only.rs", "fn resolved() {}\n");
r.commit("merge feat/a, plus a resolution-only rust file");
let tip = String::from_utf8_lossy(&r.git(&["rev-parse", "HEAD"]).stdout)
.trim()
.to_string();
let (code, out) = push_range(&r, &base, &tip);
assert_eq!(code, 0, "{out}");
assert!(
out.contains("probe-"),
"a file introduced only by the merge commit itself must still be seen: {out}"
);
}
#[test]
fn an_untrusted_manifest_does_not_run() {
let r = Repo::new();
probe(&r, PROBE, 1);
r.stage(
"amont.conf",
&format!("pre-commit audit * block ./{PROBE}\n"),
);
let run = r.hook("pre-commit", &[]);
assert!(
!run.says("probe-"),
"an untrusted manifest executed its command:\n{}",
run.output()
);
assert!(
run.passed(),
"and it must not block either:\n{}",
run.output()
);
assert!(run.says("audit"), "{}", run.output());
assert!(
run.says("trust"),
"must say how to accept it:\n{}",
run.output()
);
assert!(run.says("could not run"), "{}", run.output());
trust(&r);
let run = r.hook("pre-commit", &[]);
assert!(
run.says("probe-"),
"trusting did not enable it:\n{}",
run.output()
);
}
#[test]
fn editing_a_trusted_manifest_stops_it_running() {
let r = Repo::new();
probe(&r, PROBE, 0);
manifest(&r, &format!("pre-commit audit * block ./{PROBE}\n"));
assert!(r.hook("pre-commit", &[]).says("probe-"), "baseline");
r.stage(
"amont.conf",
&format!(
"pre-commit audit * block ./{PROBE}\npre-commit extra * block ./{PROBE}\n"
),
);
let run = r.hook("pre-commit", &[]);
assert!(
!run.says("probe-"),
"an edited manifest kept its trust:\n{}",
run.output()
);
assert!(
run.says("changed"),
"must say WHICH happened:\n{}",
run.output()
);
}
#[test]
fn a_repository_without_a_manifest_is_unaffected() {
let r = Repo::new();
r.stage("a.txt", "hello\n");
let run = r.hook("pre-commit", &[]);
assert!(run.passed(), "{}", run.output());
assert!(
!run.says("amont.conf"),
"nothing should mention a file that does not exist:\n{}",
run.output()
);
}