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()
);
}
#[cfg(unix)]
#[test]
fn the_files_marker_hands_the_command_its_matched_paths() {
let r = Repo::new();
let body = "#!/bin/sh\nprintf '%s\\n' \"$@\" > argv.txt\nexit 0\n";
r.stage("probe.sh", body);
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(r.path("probe.sh"), std::fs::Permissions::from_mode(0o755))
.expect("chmod");
r.git(&["add", "probe.sh"]);
r.stage("a.sh", "echo a\n");
r.stage("b.rs", "fn main() {}\n");
manifest(
&r,
"pre-commit shellcheck *.sh block files ./probe.sh\n",
);
let run = r.hook("pre-commit", &[]);
assert!(run.passed(), "{}", run.output());
let argv = std::fs::read_to_string(r.path("argv.txt")).expect("argv.txt");
assert!(
argv.lines().any(|l| l == "a.sh"),
"matched path missing: {argv}"
);
assert!(
!argv.lines().any(|l| l == "b.rs"),
"a path outside the scope was handed over: {argv}"
);
}
#[cfg(unix)]
#[test]
fn amont_files_carries_the_matched_set_without_the_marker() {
let r = Repo::new();
let body = "#!/bin/sh\nprintf '%s' \"$AMONT_FILES\" > envfiles.txt\nexit 0\n";
r.stage("probe.sh", body);
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(r.path("probe.sh"), std::fs::Permissions::from_mode(0o755))
.expect("chmod");
r.git(&["add", "probe.sh"]);
r.stage("a.sh", "echo a\n");
r.stage("b.rs", "fn main() {}\n");
manifest(&r, "pre-commit shellcheck *.sh block ./probe.sh\n");
let run = r.hook("pre-commit", &[]);
assert!(run.passed(), "{}", run.output());
let env = std::fs::read_to_string(r.path("envfiles.txt")).expect("envfiles.txt");
assert!(env.lines().any(|l| l == "a.sh"), "{env}");
assert!(!env.lines().any(|l| l == "b.rs"), "{env}");
}
#[cfg(unix)]
#[test]
fn a_files_command_with_nothing_matched_does_not_run() {
let r = Repo::new();
probe(&r, PROBE, 1); manifest(
&r,
&format!("pre-commit smoke * block files ./{PROBE}\n"),
);
r.commit("chore: park the fixture files");
let run = r.hook("pre-commit", &[]);
assert!(run.passed(), "{}", run.output());
assert!(
!run.says("probe-"),
"ran with an empty file list:\n{}",
run.output()
);
}
#[cfg(unix)]
#[test]
fn a_check_that_outlives_the_budget_is_killed_and_fails() {
let r = Repo::new();
let body = "#!/bin/sh\nexec sleep 300\n";
r.stage("slow.sh", body);
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(r.path("slow.sh"), std::fs::Permissions::from_mode(0o755))
.expect("chmod");
r.git(&["add", "slow.sh"]);
manifest(&r, "pre-commit slowpoke * block ./slow.sh\n");
r.git(&["config", "amont.timeout", "1"]);
let started = std::time::Instant::now();
let run = r.hook("pre-commit", &[]);
assert!(
started.elapsed() < std::time::Duration::from_secs(60),
"the deadline never fired"
);
assert!(!run.passed(), "a killed check must fail:\n{}", run.output());
assert!(
run.says("timed out") && run.says("amont.timeout"),
"must say what happened and how to change the budget:\n{}",
run.output()
);
}
#[test]
fn a_mismatched_tool_pin_warns_and_never_blocks() {
let r = Repo::new();
r.stage("a.txt", "x\n");
manifest(&r, "tool git 999.999.\n");
let run = r.hook("pre-commit", &[]);
assert!(run.passed(), "skew must never block:\n{}", run.output());
assert!(
run.says("999.999.") && run.says("disagree with CI"),
"the skew must be named:\n{}",
run.output()
);
}
#[test]
fn a_matching_tool_pin_is_silent() {
let r = Repo::new();
r.stage("a.txt", "x\n");
manifest(&r, "tool git version\n"); let run = r.hook("pre-commit", &[]);
assert!(run.passed(), "{}", run.output());
assert!(
!run.says("disagree with CI") && !run.says("would not run") && !run.says("tool <program>"),
"a satisfied pin must cost no output:\n{}",
run.output()
);
}
#[test]
fn an_untrusted_manifest_s_pins_run_nothing() {
let r = Repo::new();
r.stage("a.txt", "x\n");
r.stage("amont.conf", "tool definitely-absent-tool-xyz 1.\n");
let run = r.hook("pre-commit", &[]);
assert!(run.passed(), "{}", run.output());
assert!(
!run.says("definitely-absent-tool-xyz 1."),
"an untrusted pin must not be verified:\n{}",
run.output()
);
}
#[test]
fn a_pin_on_a_missing_tool_says_so() {
let r = Repo::new();
r.stage("a.txt", "x\n");
manifest(&r, "tool definitely-absent-tool-xyz 1.\n");
let run = r.hook("pre-commit", &[]);
assert!(run.passed(), "{}", run.output());
assert!(
run.says("would not run"),
"a missing pinned tool must be named:\n{}",
run.output()
);
}
#[test]
fn a_malformed_tool_pin_nags() {
let r = Repo::new();
r.stage("a.txt", "x\n");
manifest(&r, "tool git\n");
let run = r.hook("pre-commit", &[]);
assert!(
run.passed(),
"a broken line warns, never blocks:\n{}",
run.output()
);
assert!(
run.says("tool <program> <version-substring>"),
"the shape must be prescribed:\n{}",
run.output()
);
}
#[cfg(unix)]
#[test]
fn a_filename_scope_fires_on_the_file_and_not_a_look_alike() {
let r = Repo::new();
probe(&r, PROBE, 1); manifest(
&r,
&format!("pre-commit lockcheck package.json block ./{PROBE}\n"),
);
r.stage("not-package.json", "{}\n");
let run = r.hook("pre-commit", &[]);
assert!(
!run.says("probe-") && run.passed(),
"a look-alike woke a filename-scoped check:\n{}",
run.output()
);
r.stage("apps/web/package.json", "{}\n");
let run = r.hook("pre-commit", &[]);
assert!(
run.says("probe-") && !run.passed(),
"the named file did not wake the check:\n{}",
run.output()
);
}
#[cfg(unix)]
#[test]
fn concurrent_checks_emit_contiguous_blocks() {
use std::os::unix::fs::PermissionsExt;
let r = Repo::new();
for name in ["alpha", "beta"] {
let file = format!("{name}.sh");
r.stage(
&file,
&format!("#!/bin/sh\necho {name}-first\nsleep 1\necho {name}-second\nexit 0\n"),
);
std::fs::set_permissions(r.path(&file), std::fs::Permissions::from_mode(0o755))
.expect("chmod");
r.git(&["add", &file]);
}
manifest(
&r,
"pre-commit alpha * warn ./alpha.sh\n\
pre-commit beta * warn ./beta.sh\n",
);
let run = r.hook("pre-commit", &[]);
assert!(run.passed(), "{}", run.output());
let out = run.output();
for (name, other) in [("alpha", "beta"), ("beta", "alpha")] {
let first = out
.find(&format!("{name}-first"))
.unwrap_or_else(|| panic!("{name}-first missing:\n{out}"));
let second = out
.find(&format!("{name}-second"))
.unwrap_or_else(|| panic!("{name}-second missing:\n{out}"));
assert!(first < second, "{name}'s lines arrived reversed:\n{out}");
assert!(
!out[first..second].contains(other),
"{name}'s block was interleaved with {other}'s:\n{out}"
);
}
}
#[cfg(unix)]
#[test]
fn progress_off_restores_raw_streaming() {
use std::os::unix::fs::PermissionsExt;
let r = Repo::new();
r.stage(
"noisy.sh",
"#!/bin/sh\necho noisy-one\necho noisy-two\nexit 0\n",
);
std::fs::set_permissions(r.path("noisy.sh"), std::fs::Permissions::from_mode(0o755))
.expect("chmod");
r.git(&["add", "noisy.sh"]);
manifest(&r, "pre-commit noisy * warn ./noisy.sh\n");
r.git(&["config", "amont.progress", "false"]);
let run = r.hook("pre-commit", &[]);
assert!(run.passed(), "{}", run.output());
assert!(
run.says("noisy-one") && run.says("noisy-two"),
"raw streaming lost the output:\n{}",
run.output()
);
}