use std::path::Path;
use assert_cmd::Command;
use predicates::prelude::*;
const MANAGED_MARKER: &str = "# truth-mirror managed hook";
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();
}
fn count(haystack: &str, needle: &str) -> usize {
haystack.matches(needle).count()
}
#[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_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);
}
#[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));
}
#[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"));
assert!(!husky.path().join(".husky/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 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"));
}