use std::path::{Path, PathBuf};
use std::process::Command;
use crate::cli::init::hooks::hook_body;
const ZEROS: &str = "0000000000000000000000000000000000000000";
#[test]
fn pre_push_hook_invokes_drep_check_diff_with_the_remote_oid() {
let dir = tempfile::tempdir().expect("tempdir");
crate::test_support::git_init(dir.path());
let local_oid = "1111111111111111111111111111111111111111";
let remote_oid = "2222222222222222222222222222222222222222";
let stdin = format!("refs/heads/x {local_oid} refs/heads/x {remote_oid}\n");
let recorded = run_pre_push(dir.path(), &stdin, false);
assert_eq!(
recorded,
vec!["check", "--diff", remote_oid, "--tip", local_oid],
"the remote oid is the base, and the *pushed* oid is the tip - not HEAD"
);
}
fn run_pre_push(dir: &Path, stdin: &str, include_real_path: bool) -> Vec<String> {
let (status, recorded) = run_pre_push_status(dir, stdin, include_real_path, 0);
assert_eq!(
status,
Some(0),
"hook should succeed; recorded: {recorded:?}"
);
recorded
}
fn run_pre_push_status(
dir: &Path,
stdin: &str,
include_real_path: bool,
stub_exit: i32,
) -> (Option<i32>, Vec<String>) {
let bin_dir = dir.join("bin");
std::fs::create_dir_all(&bin_dir).expect("bin dir");
let args_log = dir.join("args.log");
let stub = format!(
"#!/bin/sh\nfor a in \"$@\"; do printf '%s\\n' \"$a\" >> {}; done\nexit {stub_exit}\n",
args_log.to_string_lossy()
);
let stub_path = bin_dir.join("drep");
crate::test_support::write_executable(&stub_path, stub);
let hook_path = dir.join("pre-push");
crate::test_support::write_executable(&hook_path, hook_body("pre-push").expect("known"));
let mut entries: Vec<PathBuf> = vec![bin_dir.clone()];
if include_real_path && let Some(existing) = std::env::var_os("PATH") {
entries.extend(std::env::split_paths(&existing));
}
let path = std::env::join_paths(entries).expect("join paths");
let mut child = Command::new("/bin/sh")
.arg(&hook_path)
.env("PATH", &path)
.current_dir(dir)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn hook");
use std::io::Write;
child
.stdin
.take()
.expect("stdin")
.write_all(stdin.as_bytes())
.expect("write stdin");
let result = child.wait_with_output().expect("wait hook");
let recorded: Vec<String> = std::fs::read_to_string(&args_log)
.unwrap_or_default()
.lines()
.map(str::to_owned)
.collect();
(result.status.code(), recorded)
}
#[test]
fn a_new_branch_falls_back_to_a_real_base_rather_than_the_zero_oid() {
let dir = tempfile::tempdir().expect("tempdir");
crate::test_support::git_init(dir.path());
std::fs::write(dir.path().join("seed.txt"), "seed\n").expect("seed");
for args in [
vec!["add", "seed.txt"],
vec!["commit", "--quiet", "-m", "root"],
] {
let status = crate::test_support::git(dir.path())
.args(&args)
.status()
.expect("git");
assert!(status.success(), "git {args:?} failed");
}
let head = String::from_utf8(
crate::test_support::git(dir.path())
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse")
.stdout,
)
.expect("utf8");
let head = head.trim().to_owned();
let stdin = format!("refs/heads/x {head} refs/heads/x {ZEROS}\n");
let recorded = run_pre_push(dir.path(), &stdin, true);
assert_eq!(
recorded.first().map(String::as_str),
Some("check"),
"recorded: {recorded:?}"
);
assert_eq!(recorded.get(1).map(String::as_str), Some("--diff"));
assert_eq!(
recorded.get(3).map(String::as_str),
Some("--tip"),
"recorded: {recorded:?}"
);
assert_eq!(
recorded.get(4).map(String::as_str),
Some(head.as_str()),
"the tip is the ref being pushed"
);
let base = recorded.get(2).expect("a base ref");
assert_ne!(
base, ZEROS,
"the all-zero oid must never be passed through as a git ref"
);
assert!(
!base.is_empty(),
"the fallback must produce a base, got an empty ref"
);
assert_eq!(base, &head, "recorded: {recorded:?}");
}
#[test]
fn a_branch_deletion_does_not_invoke_drep() {
let dir = tempfile::tempdir().expect("tempdir");
crate::test_support::git_init(dir.path());
let stdin = format!("(delete) {ZEROS} refs/heads/x 3333333333333333333333333333333333333333\n");
let recorded = run_pre_push(dir.path(), &stdin, true);
assert!(
recorded.is_empty(),
"a deletion has nothing to review; drep must not run. recorded: {recorded:?}"
);
}
#[test]
fn a_failing_check_aborts_the_push() {
for expected in [1, 2] {
let dir = tempfile::tempdir().expect("tempdir");
crate::test_support::git_init(dir.path());
let stdin = "refs/heads/x 1111111111111111111111111111111111111111 refs/heads/x \
2222222222222222222222222222222222222222\n";
let (status, recorded) = run_pre_push_status(dir.path(), stdin, false, expected);
assert_eq!(
status,
Some(expected),
"drep exited {expected}, so the hook must too; recorded: {recorded:?}"
);
}
}
#[test]
fn the_highest_exit_code_across_refs_wins() {
let dir = tempfile::tempdir().expect("tempdir");
crate::test_support::git_init(dir.path());
let bin_dir = dir.path().join("bin");
std::fs::create_dir_all(&bin_dir).expect("bin dir");
let counter = dir.path().join("count");
let stub = format!(
"#!/bin/sh\nif [ -f {c} ]; then exit 1; else : > {c}; exit 2; fi\n",
c = counter.to_string_lossy()
);
let stub_path = bin_dir.join("drep");
crate::test_support::write_executable(&stub_path, stub);
let hook_path = dir.path().join("pre-push");
crate::test_support::write_executable(&hook_path, hook_body("pre-push").expect("known"));
let stdin = "refs/heads/a 1111111111111111111111111111111111111111 refs/heads/a \
2222222222222222222222222222222222222222\n\
refs/heads/b 3333333333333333333333333333333333333333 refs/heads/b \
4444444444444444444444444444444444444444\n";
let path = std::env::join_paths([bin_dir.as_path()]).expect("join paths");
let mut child = Command::new("/bin/sh")
.arg(&hook_path)
.env("PATH", &path)
.current_dir(dir.path())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn hook");
use std::io::Write;
child
.stdin
.take()
.expect("stdin")
.write_all(stdin.as_bytes())
.expect("write stdin");
let result = child.wait_with_output().expect("wait");
assert!(
counter.exists(),
"both refs must be processed, so the loop must read every line"
);
assert_eq!(
result.status.code(),
Some(2),
"exit 2 (could not analyze) must not be downgraded to 1 by a later ref"
);
}
#[test]
fn a_missing_drep_binary_blocks_the_push_with_an_explanation() {
let dir = tempfile::tempdir().expect("tempdir");
crate::test_support::git_init(dir.path());
let hook_path = dir.path().join("pre-push");
crate::test_support::write_executable(&hook_path, hook_body("pre-push").expect("known"));
let empty = dir.path().join("empty-bin");
std::fs::create_dir_all(&empty).expect("empty bin");
let path = std::env::join_paths([empty.as_path()]).expect("join paths");
let output = Command::new("/bin/sh")
.arg(&hook_path)
.env("PATH", &path)
.current_dir(dir.path())
.stdin(std::process::Stdio::null())
.output()
.expect("run hook");
assert_ne!(
output.status.code(),
Some(0),
"a missing drep must not silently pass the push"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("not found on PATH"),
"and it must say why; stderr: {stderr}"
);
}
fn run_pre_commit(dir: &Path, stub_exit: i32) -> (Option<i32>, Vec<String>) {
let bin_dir = dir.join("bin");
std::fs::create_dir_all(&bin_dir).expect("bin dir");
let args_log = dir.join("args.log");
let stub = format!(
"#!/bin/sh\nfor a in \"$@\"; do printf '%s\\n' \"$a\" >> {}; done\nexit {stub_exit}\n",
args_log.to_string_lossy()
);
crate::test_support::write_executable(&bin_dir.join("drep"), stub);
let hook_path = dir.join("pre-commit");
crate::test_support::write_executable(&hook_path, hook_body("pre-commit").expect("known"));
let path = std::env::join_paths([bin_dir]).expect("join paths");
let output = Command::new("/bin/sh")
.arg(&hook_path)
.env("PATH", &path)
.current_dir(dir)
.output()
.expect("run hook");
let recorded: Vec<String> = std::fs::read_to_string(&args_log)
.unwrap_or_default()
.lines()
.map(str::to_owned)
.collect();
(output.status.code(), recorded)
}
#[test]
fn the_pre_commit_hook_lints_markdown_before_it_calls_the_llm() {
let dir = tempfile::tempdir().expect("tempdir");
let (status, args) = run_pre_commit(dir.path(), 0);
assert_eq!(status, Some(0), "a passing hook must exit 0");
let lint = args
.iter()
.position(|a| a == "lint-docs")
.unwrap_or_else(|| panic!("hook must run lint-docs, recorded {args:?}"));
let check = args
.iter()
.position(|a| a == "check")
.unwrap_or_else(|| panic!("hook must run check, recorded {args:?}"));
assert!(lint < check, "lint-docs must run first, recorded {args:?}");
assert!(
args.contains(&"--staged".to_owned()),
"both commands take --staged, recorded {args:?}"
);
}
#[test]
fn the_pre_commit_hook_blocks_only_on_error_severity_markdown() {
let dir = tempfile::tempdir().expect("tempdir");
let (_, args) = run_pre_commit(dir.path(), 0);
let fail_on = args
.iter()
.position(|a| a == "--fail-on")
.unwrap_or_else(|| panic!("expected --fail-on, recorded {args:?}"));
assert_eq!(args.get(fail_on + 1).map(String::as_str), Some("error"));
assert!(
!args.iter().any(|a| a == "--strict"),
"--strict blocks on info findings, recorded {args:?}"
);
}
#[test]
fn a_failed_markdown_lint_stops_the_hook_before_check_runs() {
let dir = tempfile::tempdir().expect("tempdir");
let (status, args) = run_pre_commit(dir.path(), 3);
assert_eq!(status, Some(3), "the hook must propagate the exit status");
assert!(
!args.iter().any(|a| a == "check"),
"check must not run after lint-docs failed, recorded {args:?}"
);
}