mod common;
use common::{missing, Repo};
use std::io::Write;
use std::process::{Command, Stdio};
fn push_range(r: &Repo, from: &str, to: &str) -> i32 {
let line = format!("refs/heads/main {to} refs/heads/main {from}\n");
let mut child = Command::new(env!("CARGO_BIN_EXE_amont"))
.arg("--hooks-dir")
.arg(r.path(".git/hooks"))
.arg("pre-push-run-tests-js")
.current_dir(&r.dir)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn");
child
.stdin
.as_mut()
.unwrap()
.write_all(line.as_bytes())
.unwrap();
child.wait().expect("wait").code().unwrap_or(-1)
}
fn head(r: &Repo) -> String {
String::from_utf8_lossy(&r.git(&["rev-parse", "HEAD"]).stdout)
.trim()
.to_string()
}
#[test]
fn runs_a_packages_gate_and_passes_when_it_succeeds() {
if missing("npm") {
return;
}
let r = Repo::new();
r.stage(
"package.json",
r#"{"name":"t","scripts":{"test":"exit 0"}}"#,
);
r.commit("chore: base");
let base = head(&r);
r.stage("src/a.js", "const x = 1;\n");
r.commit("feat: a");
assert_eq!(push_range(&r, &base, &head(&r)), 0);
}
#[test]
fn fails_when_the_gate_fails() {
if missing("npm") {
return;
}
let r = Repo::new();
r.stage(
"package.json",
r#"{"name":"t","scripts":{"test":"exit 1"}}"#,
);
r.commit("chore: base");
let base = head(&r);
r.stage("src/a.js", "const x = 1;\n");
r.commit("feat: a");
assert_ne!(push_range(&r, &base, &head(&r)), 0);
}
#[test]
fn runs_the_gate_in_order_and_never_lint() {
if missing("npm") {
return;
}
let r = Repo::new();
r.stage(
"package.json",
r#"{"name":"t","scripts":{"typecheck":"echo typecheck>>ran.txt","lint":"echo lint>>ran.txt","test:unit":"echo unit>>ran.txt","test":"echo test>>ran.txt"}}"#,
);
r.commit("chore: base");
let base = head(&r);
r.stage("src/a.js", "const x = 1;\n");
r.commit("feat: a");
assert_eq!(push_range(&r, &base, &head(&r)), 0);
let ran = std::fs::read_to_string(r.path("ran.txt")).unwrap_or_default();
assert_eq!(
ran.lines().map(str::trim).collect::<Vec<_>>(),
vec!["typecheck", "unit", "test"]
);
assert!(
!ran.contains("lint"),
"lint belongs to pre-commit, not here"
);
}
#[test]
fn a_failure_skips_the_rest_of_the_gate() {
if missing("npm") {
return;
}
let r = Repo::new();
r.stage(
"package.json",
r#"{"name":"t","scripts":{"typecheck":"exit 1","test:unit":"echo unit>>ran.txt","test":"echo test>>ran.txt"}}"#,
);
r.commit("chore: base");
let base = head(&r);
r.stage("src/a.js", "const x = 1;\n");
r.commit("feat: a");
assert_ne!(push_range(&r, &base, &head(&r)), 0);
assert!(
!r.path("ran.txt").exists(),
"nothing after the failure should run"
);
}
fn push_new_branch(r: &Repo, branch: &str, tip: &str) -> i32 {
let zero = "0".repeat(tip.len());
let line = format!("refs/heads/{branch} {tip} refs/heads/{branch} {zero}\n");
let mut child = Command::new(env!("CARGO_BIN_EXE_amont"))
.arg("--hooks-dir")
.arg(r.path(".git/hooks"))
.arg("pre-push-run-tests-js")
.current_dir(&r.dir)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn");
child
.stdin
.as_mut()
.unwrap()
.write_all(line.as_bytes())
.unwrap();
child.wait().expect("wait").code().unwrap_or(-1)
}
fn with_origin(r: &Repo) {
let origin = r.path(".git/test-origin.git");
r.git(&["init", "-q", "--bare", origin.to_str().expect("utf8")]);
r.git(&["remote", "add", "origin", origin.to_str().expect("utf8")]);
r.git(&["push", "-q", "--no-verify", "origin", "main"]);
}
#[test]
fn a_new_branch_is_judged_by_every_commit_not_just_its_tip() {
if missing("npm") {
return;
}
let r = Repo::new();
r.stage(
"package.json",
r#"{"name":"t","scripts":{"test":"exit 1"}}"#,
);
r.commit("chore: base");
with_origin(&r);
r.git(&["checkout", "-q", "-b", "feat/x"]);
r.stage("src/a.js", "const x = 1;\n");
r.commit("feat: the js change");
r.stage("README.md", "docs\n");
r.commit("docs: on top of it");
assert_ne!(
push_new_branch(&r, "feat/x", &head(&r)),
0,
"the failing package was two commits back and the gate never ran"
);
}
#[test]
fn a_file_reverted_later_in_the_same_push_still_selects_its_package() {
if missing("npm") {
return;
}
let r = Repo::new();
r.stage(
"package.json",
r#"{"name":"t","scripts":{"test":"exit 1"}}"#,
);
r.stage("src/a.js", "const x = 1;\n");
r.commit("chore: base");
let base = head(&r);
r.stage("src/a.js", "const x = 2;\n");
r.commit("feat: touch the js");
r.stage("src/a.js", "const x = 1;\n");
r.commit("revert: put it back");
assert_ne!(
push_range(&r, &base, &head(&r)),
0,
"the endpoints' trees are identical, but a pushed commit touched .js"
);
}
#[test]
fn a_non_js_change_runs_no_gate() {
let r = Repo::new();
r.stage(
"package.json",
r#"{"name":"t","scripts":{"test":"exit 1"}}"#,
);
r.commit("chore: seed");
let base = head(&r);
r.stage("README.md", "docs\n");
r.commit("docs: readme");
assert_eq!(push_range(&r, &base, &head(&r)), 0);
}
fn push_range_out(r: &Repo, from: &str, to: &str) -> (i32, String) {
let line = format!("refs/heads/main {to} refs/heads/main {from}\n");
let mut child = Command::new(env!("CARGO_BIN_EXE_amont"))
.arg("--hooks-dir")
.arg(r.path(".git/hooks"))
.arg("pre-push-run-tests-js")
.current_dir(&r.dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn");
child
.stdin
.as_mut()
.unwrap()
.write_all(line.as_bytes())
.unwrap();
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 repo_with_failing_typecheck() -> (Repo, String) {
let r = Repo::new();
r.stage(
"package.json",
r#"{"name":"t","scripts":{"typecheck":"exit 1","test":"exit 0"}}"#,
);
r.commit("chore: base");
let base = head(&r);
r.stage("src/a.ts", "export const x = 1\n");
r.commit("feat: add a ts file");
(r, base)
}
fn trust_manifest(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: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn a_declared_pre_commit_check_removes_that_script_from_the_push_gate() {
if missing("npm") {
return;
}
let (r, base) = repo_with_failing_typecheck();
r.stage(
"amont.conf",
"pre-commit typecheck *.ts,*.tsx block npm run typecheck\n",
);
r.commit("chore: typecheck at commit time");
trust_manifest(&r);
let (code, out) = push_range_out(&r, &base, &head(&r));
assert_eq!(
code, 0,
"typecheck ran at push despite being gated at commit:\n{out}"
);
assert!(
out.contains("typecheck") && out.contains("gated at commit"),
"the push did not say typecheck had moved:\n{out}"
);
}
#[test]
fn an_untrusted_declaration_leaves_the_push_gate_alone() {
if missing("npm") {
return;
}
let (r, base) = repo_with_failing_typecheck();
r.stage(
"amont.conf",
"pre-commit typecheck *.ts,*.tsx block npm run typecheck\n",
);
r.commit("chore: declare typecheck, untrusted");
let (code, out) = push_range_out(&r, &base, &head(&r));
assert_ne!(
code, 0,
"an untrusted declaration removed the push gate — nothing typechecks now:\n{out}"
);
}
#[test]
fn a_skipped_declaration_leaves_the_push_gate_alone() {
if missing("npm") {
return;
}
let (r, base) = repo_with_failing_typecheck();
r.stage(
"amont.conf",
"pre-commit typecheck *.ts,*.tsx block npm run typecheck\n",
);
r.commit("chore: typecheck at commit time");
trust_manifest(&r);
r.git(&["config", "--add", "hook.skip", "pre-commit-typecheck"]);
let (code, out) = push_range_out(&r, &base, &head(&r));
assert_ne!(
code, 0,
"a skipped declaration removed the push gate — nothing typechecks now:\n{out}"
);
}
#[test]
fn a_pre_push_declaration_does_not_count_as_commit_time() {
if missing("npm") {
return;
}
let (r, base) = repo_with_failing_typecheck();
r.stage(
"amont.conf",
"pre-push typecheck *.ts,*.tsx block npm run typecheck\n",
);
r.commit("chore: declare on the wrong stage");
trust_manifest(&r);
let (code, out) = push_range_out(&r, &base, &head(&r));
assert_ne!(
code, 0,
"a pre-push declaration was treated as commit-time cover:\n{out}"
);
}
#[test]
fn with_no_declaration_the_gate_is_unchanged() {
if missing("npm") {
return;
}
let (r, base) = repo_with_failing_typecheck();
let (code, out) = push_range_out(&r, &base, &head(&r));
assert_ne!(code, 0, "typecheck did not run:\n{out}");
assert!(
!out.contains("gated at commit"),
"said something about commit-time gating with nothing declared:\n{out}"
);
}