use std::{
ffi::OsString,
path::{Path, PathBuf},
};
use assert_cmd::Command;
use predicates::prelude::*;
const MANAGED_MARKER: &str = "# truth-mirror managed hook";
const BRIDGE_MARKER: &str = "# Bridge for the husky+entire chain";
fn git_init(dir: &Path) {
git(dir, &["init"]);
}
fn git(dir: &Path, args: &[&str]) {
let status = std::process::Command::new("git")
.args(args)
.current_dir(dir)
.status()
.unwrap();
assert!(status.success(), "git {args:?} failed with {status}");
}
fn git_config_get(dir: &Path, key: &str) -> Option<String> {
let output = std::process::Command::new("git")
.args(["config", "--get", key])
.current_dir(dir)
.output()
.unwrap();
output
.status
.success()
.then(|| String::from_utf8(output.stdout).unwrap().trim().to_owned())
}
fn truth(dir: &Path) -> Command {
let mut command = Command::cargo_bin("truth-mirror").unwrap();
command.current_dir(dir);
command
}
fn read(path: impl AsRef<Path>) -> String {
std::fs::read_to_string(path).unwrap()
}
fn write(path: impl AsRef<Path>, content: &str) {
let path = path.as_ref();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, content).unwrap();
}
#[cfg(unix)]
fn make_executable(path: impl AsRef<Path>) {
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(path.as_ref()).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(path, permissions).unwrap();
}
#[cfg(not(unix))]
fn make_executable(_path: impl AsRef<Path>) {}
#[cfg(unix)]
fn is_executable(path: impl AsRef<Path>) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path.as_ref())
.unwrap()
.permissions()
.mode()
& 0o111
!= 0
}
#[cfg(not(unix))]
fn is_executable(_path: impl AsRef<Path>) -> bool {
true
}
fn count(haystack: &str, needle: &str) -> usize {
haystack.matches(needle).count()
}
fn bridge_path(repo: &Path, hook: &str) -> PathBuf {
repo.join(".husky").join(format!("{hook}.pre-entire"))
}
fn bridge_exec_line(hook: &str) -> String {
format!("exec \"$(dirname \"$0\")/{hook}\" \"$@\"")
}
fn field_bridge_content(hook: &str) -> String {
format!(
"#!/bin/sh\n# Bridge for the husky+entire chain: entire's .husky/_/{hook} execs its .pre-entire\n# backup (the husky bootstrap), whose $0-derived hook name becomes \"{hook}.pre-entire\" -\n# so husky's h dispatcher looks for THIS file. Without it, h exits 0 and the real\n# .husky/{hook} (truth-mirror gate) silently never runs.\n{}\n",
bridge_exec_line(hook)
)
}
fn truth_bin_path() -> PathBuf {
let command = Command::cargo_bin("truth-mirror").unwrap();
PathBuf::from(command.get_program())
}
fn path_with_truth_bin() -> OsString {
let bin_dir = truth_bin_path().parent().unwrap().to_path_buf();
let mut paths = vec![bin_dir];
if let Some(existing) = std::env::var_os("PATH") {
paths.extend(std::env::split_paths(&existing));
}
std::env::join_paths(paths).unwrap()
}
fn run_entire_wrapped_commit_msg(repo: &Path, message: &Path) -> std::process::Output {
std::process::Command::new(repo.join(".husky/_/commit-msg"))
.arg(message)
.current_dir(repo)
.env("PATH", path_with_truth_bin())
.output()
.unwrap()
}
fn setup_entire_wrapped_husky_commit_msg(repo: &Path) {
git(repo, &["config", "core.hooksPath", ".husky/_"]);
write(
repo.join(".husky/_/h"),
r#"#!/bin/sh
hook_name="$(basename "$0")"
content_dir="$(dirname "$(dirname "$0")")"
target="$content_dir/$hook_name"
[ -f "$target" ] || exit 0
exec "$target" "$@"
"#,
);
write(
repo.join(".husky/_/commit-msg.pre-entire"),
"#!/bin/sh\n. \"$(dirname \"$0\")/h\"\n",
);
write(
repo.join(".husky/_/commit-msg"),
"#!/bin/sh\n# Entire CLI hooks\nexec \"$(dirname \"$0\")/commit-msg.pre-entire\" \"$@\"\n",
);
make_executable(repo.join(".husky/_/h"));
make_executable(repo.join(".husky/_/commit-msg.pre-entire"));
make_executable(repo.join(".husky/_/commit-msg"));
}
#[test]
fn install_hooks_dry_run_prints_plan_without_writing_hooks() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
let output = truth(temp.path())
.args(["install-hooks", "--dry-run", "--claude", "--codex", "--pi"])
.assert()
.success()
.stdout(predicate::str::contains("truth-mirror hook plan"))
.stdout(predicate::str::contains("path="))
.stdout(predicate::str::contains("# truth-mirror managed hook"))
.stdout(predicate::str::contains(
"exec truth-mirror hook-dispatch commit-msg",
))
.stdout(predicate::str::contains(
"surface: claude -> .claude/settings.json",
))
.stdout(predicate::str::contains(
"surface: codex -> .codex/hooks.json",
))
.stdout(predicate::str::contains(
"surface: pi -> .pi/extensions/truth-mirror.js",
))
.get_output()
.stdout
.clone();
let output = String::from_utf8(output).unwrap();
assert!(output.contains(".git/hooks/commit-msg"));
assert!(!temp.path().join(".truth/hooks/commit-msg").exists());
assert!(!temp.path().join(".git/hooks/commit-msg").exists());
assert!(!temp.path().join(".claude/settings.json").exists());
assert!(!temp.path().join(".codex/hooks.json").exists());
assert!(!temp.path().join(".pi/extensions/truth-mirror.js").exists());
}
#[test]
fn plain_install_fresh_repo_writes_git_hook_shims_without_core_hooks_path() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
let hook = read(temp.path().join(".git/hooks/commit-msg"));
assert!(hook.contains(MANAGED_MARKER));
assert!(hook.contains("exec truth-mirror hook-dispatch commit-msg \"$@\""));
assert!(!temp.path().join(".truth/hooks/chained/commit-msg").exists());
assert_eq!(git_config_get(temp.path(), "core.hooksPath"), None);
let gitignore = read(temp.path().join(".truth/.gitignore"));
assert!(gitignore.contains("review-queue.jsonl"));
assert!(gitignore.contains("runs/"));
}
#[test]
fn plain_install_preserves_safe_existing_hook_in_backup_and_chained() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
let safe_hook = "#!/bin/sh\necho safe \"$@\"\n";
write(temp.path().join(".git/hooks/commit-msg"), safe_hook);
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
assert_eq!(
read(temp.path().join(".git/hooks/commit-msg.pre-truth-mirror")),
safe_hook
);
assert_eq!(
read(temp.path().join(".truth/hooks/chained/commit-msg")),
safe_hook
);
}
#[test]
fn plain_install_skips_entire_hook_in_chained_but_keeps_backup() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
let entire_hook = "#!/bin/sh\n# Entire CLI hooks\nentire hooks git commit-msg \"$@\"\n";
write(temp.path().join(".git/hooks/commit-msg"), entire_hook);
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
assert_eq!(
read(temp.path().join(".git/hooks/commit-msg.pre-truth-mirror")),
entire_hook
);
assert!(!temp.path().join(".truth/hooks/chained/commit-msg").exists());
}
#[test]
fn plain_reinstall_is_idempotent() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
let hook = read(temp.path().join(".git/hooks/commit-msg"));
assert_eq!(count(&hook, MANAGED_MARKER), 1);
assert_eq!(count(&hook, "exec truth-mirror"), 1);
}
#[test]
fn plain_uninstall_restores_backup_and_removes_state_hooks() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
let safe_hook = "#!/bin/sh\necho safe\n";
write(temp.path().join(".git/hooks/commit-msg"), safe_hook);
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
truth(temp.path())
.args(["install-hooks", "--uninstall"])
.assert()
.success();
assert_eq!(read(temp.path().join(".git/hooks/commit-msg")), safe_hook);
assert!(
!temp
.path()
.join(".git/hooks/commit-msg.pre-truth-mirror")
.exists()
);
assert!(!temp.path().join(".truth/hooks").exists());
assert!(temp.path().join(".truth/.gitignore").exists());
}
#[test]
fn husky_install_creates_content_hook_without_touching_husky_internal_dir() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
git(temp.path(), &["config", "core.hooksPath", ".husky/_"]);
std::fs::create_dir_all(temp.path().join(".husky/_")).unwrap();
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
let hook = read(temp.path().join(".husky/commit-msg"));
assert!(hook.contains(MANAGED_MARKER));
assert!(hook.contains("truth-mirror hook-dispatch commit-msg"));
assert!(!temp.path().join(".husky/_/commit-msg").exists());
assert!(!temp.path().join(".truth/hooks/chained/commit-msg").exists());
assert_eq!(
git_config_get(temp.path(), "core.hooksPath"),
Some(".husky/_".to_owned())
);
}
#[test]
fn husky_install_writes_entire_bridges_for_managed_hooks() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
git(temp.path(), &["config", "core.hooksPath", ".husky/_"]);
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
for hook in ["commit-msg", "post-commit", "pre-push"] {
let bridge = bridge_path(temp.path(), hook);
let content = read(&bridge);
assert!(content.contains(BRIDGE_MARKER));
assert!(content.contains(&bridge_exec_line(hook)));
assert!(is_executable(&bridge));
}
}
#[test]
fn husky_install_appends_existing_content_and_stays_idempotent() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
git(temp.path(), &["config", "core.hooksPath", ".husky/_"]);
let existing = "#!/bin/sh\necho husky-before\n";
write(temp.path().join(".husky/commit-msg"), existing);
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
let hook = read(temp.path().join(".husky/commit-msg"));
assert!(hook.contains("echo husky-before"));
assert_eq!(count(&hook, MANAGED_MARKER), 1);
let bridge = read(bridge_path(temp.path(), "commit-msg"));
assert_eq!(count(&bridge, BRIDGE_MARKER), 1);
assert_eq!(count(&bridge, &bridge_exec_line("commit-msg")), 1);
}
#[test]
fn husky_install_backs_up_unrelated_entire_bridge_and_uninstall_restores_it() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
git(temp.path(), &["config", "core.hooksPath", ".husky/_"]);
let original = "#!/bin/sh\necho unrelated bridge \"$@\"\n";
let bridge = bridge_path(temp.path(), "commit-msg");
write(&bridge, original);
make_executable(&bridge);
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
let backup = temp
.path()
.join(".husky/commit-msg.pre-entire.pre-truth-mirror");
assert_eq!(read(&backup), original);
assert!(read(&bridge).contains(BRIDGE_MARKER));
truth(temp.path())
.args(["install-hooks", "--uninstall"])
.assert()
.success();
assert_eq!(read(&bridge), original);
assert!(!backup.exists());
}
#[test]
fn husky_install_preserves_existing_marker_bridge_with_expected_exec_line() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
git(temp.path(), &["config", "core.hooksPath", ".husky/_"]);
let bridge = bridge_path(temp.path(), "commit-msg");
let original = field_bridge_content("commit-msg");
write(&bridge, &original);
make_executable(&bridge);
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
assert_eq!(read(&bridge), original);
assert!(
!temp
.path()
.join(".husky/commit-msg.pre-entire.pre-truth-mirror")
.exists()
);
}
#[test]
fn husky_install_repairs_marker_bridge_with_wrong_exec_line() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
git(temp.path(), &["config", "core.hooksPath", ".husky/_"]);
let bridge = bridge_path(temp.path(), "commit-msg");
write(
&bridge,
"#!/bin/sh\n# Bridge for the husky+entire chain\nexec \"$(dirname \"$0\")/wrong\" \"$@\"\n",
);
make_executable(&bridge);
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
let repaired = read(&bridge);
assert!(repaired.contains(BRIDGE_MARKER));
assert!(repaired.contains(&bridge_exec_line("commit-msg")));
assert!(!repaired.contains("/wrong"));
}
#[test]
fn husky_uninstall_removes_block_and_preserves_existing_content() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
git(temp.path(), &["config", "core.hooksPath", ".husky/_"]);
let existing = "#!/bin/sh\necho husky-before\n";
write(temp.path().join(".husky/commit-msg"), existing);
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
truth(temp.path())
.args(["install-hooks", "--uninstall"])
.assert()
.success();
let hook = read(temp.path().join(".husky/commit-msg"));
assert!(hook.contains("echo husky-before"));
assert!(!hook.contains(MANAGED_MARKER));
assert!(!bridge_path(temp.path(), "commit-msg").exists());
assert!(!bridge_path(temp.path(), "post-commit").exists());
assert!(!bridge_path(temp.path(), "pre-push").exists());
}
#[test]
fn custom_hooks_path_without_forwarder_fails_before_writing_state() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
git(temp.path(), &["config", "core.hooksPath", ".githooks"]);
truth(temp.path())
.args(["install-hooks"])
.assert()
.failure()
.stderr(predicate::str::contains("core.hooksPath is set"))
.stderr(predicate::str::contains("--inject-forwarder"));
assert!(!temp.path().join(".truth").exists());
assert_eq!(
git_config_get(temp.path(), "core.hooksPath"),
Some(".githooks".to_owned())
);
}
#[test]
fn custom_hooks_path_with_inject_forwarder_keeps_core_hooks_path_and_writes_local_shims() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
git(temp.path(), &["config", "core.hooksPath", ".githooks"]);
truth(temp.path())
.args(["install-hooks", "--inject-forwarder"])
.assert()
.success();
assert!(temp.path().join(".githooks/_forward-local.sh").exists());
assert!(read(temp.path().join(".githooks/commit-msg")).contains("_forward-local.sh"));
assert!(read(temp.path().join(".git/hooks/commit-msg")).contains(MANAGED_MARKER));
assert_eq!(
git_config_get(temp.path(), "core.hooksPath"),
Some(".githooks".to_owned())
);
}
#[test]
fn dry_run_in_husky_and_custom_modes_writes_nothing() {
let husky = tempfile::tempdir().unwrap();
git_init(husky.path());
git(husky.path(), &["config", "core.hooksPath", ".husky/_"]);
truth(husky.path())
.args(["install-hooks", "--dry-run"])
.assert()
.success()
.stdout(predicate::str::contains("mode=husky"))
.stdout(predicate::str::contains(".husky/commit-msg.pre-entire"));
assert!(!husky.path().join(".husky/commit-msg").exists());
assert!(!bridge_path(husky.path(), "commit-msg").exists());
assert!(!husky.path().join(".truth").exists());
let custom = tempfile::tempdir().unwrap();
git_init(custom.path());
git(custom.path(), &["config", "core.hooksPath", ".githooks"]);
truth(custom.path())
.args(["install-hooks", "--dry-run"])
.assert()
.success()
.stdout(predicate::str::contains("mode=custom-committed"));
assert!(!custom.path().join(".githooks/commit-msg").exists());
assert!(!custom.path().join(".truth").exists());
}
#[test]
fn entire_wrapped_husky_commit_msg_reaches_truth_mirror_gate_through_bridge() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
setup_entire_wrapped_husky_commit_msg(temp.path());
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
let message = temp.path().join("COMMIT_EDITMSG");
write(&message, "feat: no claim\n");
std::fs::remove_file(bridge_path(temp.path(), "commit-msg")).unwrap();
let bypass = run_entire_wrapped_commit_msg(temp.path(), &message);
assert!(
bypass.status.success(),
"without the bridge, Husky should silently bypass the content hook; stderr={}",
String::from_utf8_lossy(&bypass.stderr)
);
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
let rejected = run_entire_wrapped_commit_msg(temp.path(), &message);
assert!(!rejected.status.success());
assert!(String::from_utf8_lossy(&rejected.stderr).contains("missing CLAIM"));
write(
&message,
"feat: bridge\n\nCLAIM: bridge reaches gate | verified: cargo test | evidence: tests:husky-entire\n",
);
let accepted = run_entire_wrapped_commit_msg(temp.path(), &message);
assert!(
accepted.status.success(),
"valid CLAIM should pass; stderr={}",
String::from_utf8_lossy(&accepted.stderr)
);
}
#[test]
fn state_gitignore_preserves_user_lines_and_appends_missing_runtime_entries() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
write(temp.path().join(".truth/.gitignore"), "custom.log\nruns/\n");
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
let gitignore = read(temp.path().join(".truth/.gitignore"));
assert!(gitignore.contains("custom.log"));
assert_eq!(count(&gitignore, "runs/"), 1);
assert!(gitignore.contains("review-queue.jsonl"));
assert!(gitignore.contains("tmp/"));
assert!(gitignore.contains("logs/"));
assert!(gitignore.contains("*.local.*"));
}
#[test]
fn state_gitignore_warns_when_runtime_file_is_already_tracked() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
write(temp.path().join(".truth/review-queue.jsonl"), "tracked\n");
git(temp.path(), &["add", ".truth/review-queue.jsonl"]);
truth(temp.path())
.args(["install-hooks"])
.assert()
.success()
.stderr(predicate::str::contains(
"hint: run: git rm --cached .truth/review-queue.jsonl",
));
}
#[test]
fn install_hooks_writes_selected_agent_surfaces() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
truth(temp.path())
.args(["install-hooks", "--claude", "--codex", "--pi"])
.assert()
.success();
let claude = read(temp.path().join(".claude/settings.json"));
assert!(claude.contains("UserPromptSubmit"));
assert!(claude.contains("truth-mirror reinject --agent claude"));
let codex: serde_json::Value =
serde_json::from_str(&read(temp.path().join(".codex/hooks.json"))).unwrap();
assert_eq!(
codex
.pointer("/hooks/UserPromptSubmit/0/hooks/0/command")
.and_then(serde_json::Value::as_str),
Some("truth-mirror reinject --agent codex")
);
assert_eq!(
codex
.pointer("/hooks/UserPromptSubmit/0/hooks/0/type")
.and_then(serde_json::Value::as_str),
Some("command")
);
assert!(!temp.path().join(".pi/hooks.json").exists());
let pi = read(temp.path().join(".pi/extensions/truth-mirror.js"));
assert!(pi.contains("truth-mirror"));
assert!(pi.contains("reinject"));
assert!(pi.contains("pi.on(\"context\""));
}
#[test]
fn install_hooks_only_touches_selected_agents() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
truth(temp.path())
.args(["install-hooks", "--claude"])
.assert()
.success();
assert!(temp.path().join(".claude/settings.json").exists());
assert!(!temp.path().join(".codex/hooks.json").exists());
assert!(!temp.path().join(".pi/hooks.json").exists());
}
#[test]
fn install_is_non_clobbering_and_uninstall_reverses_only_our_entries() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
write(
temp.path().join(".claude/settings.json"),
r#"{"model":"sonnet","hooks":{"PreToolUse":[{"matcher":"Bash"}]}}"#,
);
truth(temp.path())
.args(["install-hooks", "--claude"])
.assert()
.success();
let after_install = read(temp.path().join(".claude/settings.json"));
assert!(after_install.contains("sonnet"));
assert!(after_install.contains("PreToolUse"));
assert!(after_install.contains("truth-mirror reinject --agent claude"));
truth(temp.path())
.args(["install-hooks", "--uninstall"])
.assert()
.success();
let after_uninstall = read(temp.path().join(".claude/settings.json"));
assert!(after_uninstall.contains("sonnet"));
assert!(after_uninstall.contains("PreToolUse"));
assert!(!after_uninstall.contains("truth-mirror reinject"));
}