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 git_check_ignore(dir: &Path, path: &str) -> bool {
let status = std::process::Command::new("git")
.args(["check-ignore", "--quiet", path])
.current_dir(dir)
.status()
.unwrap();
match status.code() {
Some(0) => true,
Some(1) => false,
_ => panic!("git check-ignore {path:?} failed with {status}"),
}
}
fn truth(dir: &Path) -> Command {
let mut command = Command::cargo_bin("truth-mirror").unwrap();
command.current_dir(dir);
command
}
fn truth_alias(dir: &Path) -> Command {
let mut command = Command::cargo_bin("truth").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>) {}
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",
"--grok",
])
.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",
))
.stdout(predicate::str::contains(
"surface: grok -> .grok/skills/truth-mirror/SKILL.md",
))
.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());
assert!(
!temp
.path()
.join(".grok/skills/truth-mirror/SKILL.md")
.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 install_hooks_normalizes_managed_hooks_path() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
git(
temp.path(),
&["config", "core.hooksPath", "./.truth/./hooks"],
);
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
assert_eq!(git_config_get(temp.path(), "core.hooksPath"), None);
assert!(temp.path().join(".git/hooks/commit-msg").is_file());
}
#[test]
fn install_hooks_prints_async_review_drain_guidance() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
truth(temp.path())
.args(["install-hooks"])
.assert()
.success()
.stderr(predicate::str::contains(
"post-commit hooks queue async reviews",
))
.stderr(predicate::str::contains("truth-mirror watch --once"));
}
#[test]
fn install_hooks_drain_guidance_preserves_state_dir() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
let state = temp.path().join("custom-state");
truth(temp.path())
.args(["--state-dir", state.to_str().unwrap(), "install-hooks"])
.assert()
.success()
.stderr(predicate::str::contains("truth-mirror --state-dir"))
.stderr(predicate::str::contains(state.to_string_lossy().as_ref()))
.stderr(predicate::str::contains("watch --once"));
}
#[test]
fn truth_alias_install_hooks_guidance_uses_alias_name() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
truth_alias(temp.path())
.args(["install-hooks"])
.assert()
.success()
.stderr(predicate::str::contains(
"truth: post-commit hooks queue async reviews",
))
.stderr(predicate::str::contains("truth watch --once"))
.stderr(predicate::str::contains("truth watch"))
.stderr(predicate::str::contains("truth-mirror: post-commit").not());
}
#[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_does_not_write_entire_bridges() {
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"] {
assert!(
!temp
.path()
.join(".husky")
.join(format!("{hook}.pre-entire"))
.exists()
);
}
}
#[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 truth_alias_custom_hooks_path_warning_uses_alias_name() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
git(temp.path(), &["config", "core.hooksPath", ".githooks"]);
let state = temp.path().join("custom-state");
truth_alias(temp.path())
.args(["--state-dir", state.to_str().unwrap(), "install-hooks"])
.assert()
.failure()
.stderr(predicate::str::contains("truth: core.hooksPath is set"))
.stderr(predicate::str::contains("truth --state-dir"))
.stderr(predicate::str::contains(state.to_string_lossy().as_ref()))
.stderr(predicate::str::contains("install-hooks --inject-forwarder"))
.stderr(predicate::str::contains("truth-mirror: core.hooksPath").not());
}
#[test]
fn custom_hooks_path_accepts_existing_truth_alias_forwarders() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
git(temp.path(), &["config", "core.hooksPath", ".githooks"]);
for hook in ["commit-msg", "post-commit", "pre-push"] {
let path = temp.path().join(".githooks").join(hook);
write(
&path,
&format!("#!/bin/sh\nexec truth hook-dispatch {hook} \"$@\"\n"),
);
make_executable(&path);
}
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
assert!(!temp.path().join(".githooks/_forward-local.sh").exists());
assert!(read(temp.path().join(".git/hooks/commit-msg")).contains(MANAGED_MARKER));
assert!(
read(temp.path().join(".githooks/commit-msg"))
.contains("exec truth hook-dispatch commit-msg \"$@\"")
);
}
#[test]
fn custom_hooks_path_rejects_commented_truth_alias_forwarders() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
git(temp.path(), &["config", "core.hooksPath", ".githooks"]);
for hook in ["commit-msg", "post-commit", "pre-push"] {
let path = temp.path().join(".githooks").join(hook);
write(
&path,
&format!("#!/bin/sh\n# exec truth hook-dispatch {hook} \"$@\"\nexit 0\n"),
);
make_executable(&path);
}
truth(temp.path())
.args(["install-hooks"])
.assert()
.failure()
.stderr(predicate::str::contains("core.hooksPath is set"))
.stderr(predicate::str::contains("install-hooks --inject-forwarder"));
}
#[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").not());
assert!(!husky.path().join(".husky/commit-msg").exists());
assert!(!husky.path().join(".husky/commit-msg.pre-entire").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\n!keep.txt\nruns/\n",
);
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
let gitignore = read(temp.path().join(".truth/.gitignore"));
assert!(gitignore.starts_with("# truth-mirror runtime state - machine-generated\n"));
assert!(gitignore.contains("*\n"));
assert!(gitignore.contains("!.gitignore\n"));
assert!(gitignore.contains("!config.toml\n"));
assert!(gitignore.contains("custom.log"));
assert!(gitignore.contains("!keep.txt"));
assert_eq!(count(&gitignore, "runs/"), 1);
assert!(gitignore.contains("review-queue.jsonl"));
assert!(gitignore.contains("ledger.jsonl"));
assert!(gitignore.contains("ledger.md"));
assert!(gitignore.contains("tmp/"));
assert!(gitignore.contains("logs/"));
assert!(gitignore.contains("*.local.*"));
write(temp.path().join(".truth/ledger.jsonl"), "{}\n");
write(temp.path().join(".truth/ledger.md"), "# ledger\n");
write(temp.path().join(".truth/review-queue.jsonl"), "{}\n");
write(temp.path().join(".truth/runs/run.json"), "{}\n");
write(temp.path().join(".truth/config.toml"), "[gates]\n");
write(
temp.path().join(".truth/keep.txt"),
"custom tracked artifact\n",
);
assert!(git_check_ignore(temp.path(), ".truth/ledger.jsonl"));
assert!(git_check_ignore(temp.path(), ".truth/ledger.md"));
assert!(git_check_ignore(temp.path(), ".truth/review-queue.jsonl"));
assert!(git_check_ignore(temp.path(), ".truth/runs/run.json"));
assert!(!git_check_ignore(temp.path(), ".truth/.gitignore"));
assert!(!git_check_ignore(temp.path(), ".truth/config.toml"));
assert!(!git_check_ignore(temp.path(), ".truth/keep.txt"));
}
#[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", "--grok"])
.assert()
.success()
.stdout(predicate::str::contains(
"grok: installed .grok/skills/truth-mirror/SKILL.md",
));
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\""));
let grok_skill = read(temp.path().join(".grok/skills/truth-mirror/SKILL.md"));
assert!(grok_skill.contains("truth-mirror reinject --agent grok"));
assert!(!temp.path().join(".grok/hooks/truth-mirror.json").exists());
}
#[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());
assert!(
!temp
.path()
.join(".grok/skills/truth-mirror/SKILL.md")
.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"));
}
#[test]
fn uninstall_cleans_entire_bridges_and_redundant_backups_while_preserving_ledger() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
truth(temp.path())
.args(["install-hooks", "--claude"])
.assert()
.success();
let tm_shim = read(temp.path().join(".git/hooks/commit-msg"));
let entire_hook = "#!/bin/sh\n# Entire CLI hooks\nentire hooks git commit-msg \"$@\"\n";
for hook in &["commit-msg", "post-commit", "pre-push"] {
let hooks_dir = temp.path().join(".git/hooks");
write(hooks_dir.join(hook), entire_hook);
make_executable(hooks_dir.join(hook));
write(hooks_dir.join(format!("{hook}.pre-entire")), &tm_shim);
write(
hooks_dir.join(format!("{hook}.pre-truth-mirror")),
entire_hook,
);
}
write(
temp.path().join(".truth-mirror/hooks/chained/commit-msg"),
"#!/bin/sh\necho chained\n",
);
write(temp.path().join(".truth/ledger.jsonl"), "{}\n");
truth(temp.path()).args(["uninstall"]).assert().success();
for hook in &["commit-msg", "post-commit", "pre-push"] {
let hooks_dir = temp.path().join(".git/hooks");
assert!(
!hooks_dir.join(format!("{hook}.pre-entire")).exists(),
".pre-entire bridge must be removed for {hook}"
);
assert!(
!hooks_dir.join(format!("{hook}.pre-truth-mirror")).exists(),
"redundant .pre-truth-mirror backup must be removed for {hook}"
);
assert_eq!(
read(hooks_dir.join(hook)),
entire_hook,
"Entire active hook must be untouched for {hook}"
);
}
assert!(!temp.path().join(".truth-mirror/hooks").exists());
assert!(
temp.path().join(".truth/ledger.jsonl").exists(),
"ledger.jsonl must be preserved on non-purge uninstall"
);
}
#[test]
fn uninstall_purge_removes_both_state_dirs() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
write(temp.path().join(".truth-mirror/ledger.jsonl"), "{}\n");
write(temp.path().join(".truth/ledger.jsonl"), "{}\n");
truth(temp.path())
.args(["uninstall", "--purge"])
.assert()
.success();
assert!(
!temp.path().join(".truth").exists(),
".truth/ must be removed with --purge"
);
assert!(
!temp.path().join(".truth-mirror").exists(),
".truth-mirror/ must be removed with --purge"
);
}
#[test]
fn uninstall_preserves_backup_with_differing_content_and_warns() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
let active_content = "#!/bin/sh\necho other-hook\n";
let backup_content = "#!/bin/sh\necho completely-different\n";
for hook in &["commit-msg", "post-commit", "pre-push"] {
let hooks_dir = temp.path().join(".git/hooks");
write(hooks_dir.join(hook), active_content);
make_executable(hooks_dir.join(hook));
write(
hooks_dir.join(format!("{hook}.pre-truth-mirror")),
backup_content,
);
}
truth(temp.path())
.args(["uninstall"])
.assert()
.success()
.stderr(predicates::str::contains("preserved"));
for hook in &["commit-msg", "post-commit", "pre-push"] {
assert!(
temp.path()
.join(".git/hooks")
.join(format!("{hook}.pre-truth-mirror"))
.exists(),
"backup with differing content must be preserved for {hook}"
);
}
}
#[test]
fn uninstall_corrupted_claude_settings_does_not_prevent_codex_cleanup() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
truth(temp.path())
.args(["install-hooks", "--codex"])
.assert()
.success();
let codex_before = read(temp.path().join(".codex/hooks.json"));
assert!(codex_before.contains("truth-mirror reinject --agent codex"));
write(
temp.path().join(".claude/settings.json"),
"not-valid-json{{{{",
);
let _ = truth(temp.path()).args(["uninstall"]).assert();
let codex_path = temp.path().join(".codex/hooks.json");
if codex_path.exists() {
let codex_after = read(&codex_path);
assert!(
!codex_after.contains("truth-mirror reinject --agent codex"),
"codex reinject entry should have been removed despite claude surface error"
);
}
}
#[test]
fn uninstall_dry_run_touches_nothing() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
truth(temp.path())
.args(["install-hooks", "--claude"])
.assert()
.success();
let hook_before = read(temp.path().join(".git/hooks/commit-msg"));
let claude_before = read(temp.path().join(".claude/settings.json"));
truth(temp.path())
.args(["uninstall", "--dry-run"])
.assert()
.success()
.stdout(predicates::str::contains("uninstall"));
assert_eq!(
read(temp.path().join(".git/hooks/commit-msg")),
hook_before,
"dry-run must not touch git hooks"
);
assert_eq!(
read(temp.path().join(".claude/settings.json")),
claude_before,
"dry-run must not touch agent surfaces"
);
}
#[test]
fn uninstall_removes_pristine_state_gitignore_but_keeps_user_modified() {
let pristine = tempfile::tempdir().unwrap();
git_init(pristine.path());
truth(pristine.path())
.args(["install-hooks"])
.assert()
.success();
assert!(pristine.path().join(".truth/.gitignore").exists());
truth(pristine.path())
.args(["uninstall"])
.assert()
.success();
assert!(
!pristine.path().join(".truth/.gitignore").exists(),
"pristine machine-generated .gitignore should be removed"
);
let user_mod = tempfile::tempdir().unwrap();
git_init(user_mod.path());
truth(user_mod.path())
.args(["install-hooks"])
.assert()
.success();
write(
user_mod.path().join(".truth/.gitignore"),
"# my custom rules\nruns/\n",
);
truth(user_mod.path())
.args(["uninstall"])
.assert()
.success()
.stderr(
predicates::str::contains("user-modified").or(predicates::str::contains("leaving it")),
);
assert!(
user_mod.path().join(".truth/.gitignore").exists(),
"user-modified .gitignore must be preserved"
);
}
#[test]
fn uninstall_plain_then_husky_clears_stale_git_hooks_shims() {
let temp = tempfile::tempdir().unwrap();
git_init(temp.path());
truth(temp.path())
.args(["install-hooks"])
.assert()
.success();
for hook in ["commit-msg", "post-commit", "pre-push"] {
let content = read(temp.path().join(".git/hooks").join(hook));
assert!(
content.contains(MANAGED_MARKER),
".git/hooks/{hook} should be truth-mirror-managed after plain install"
);
}
let non_managed_hook = temp.path().join(".git/hooks/prepare-commit-msg");
write(&non_managed_hook, "#!/bin/sh\necho user-hook\n");
std::fs::create_dir_all(temp.path().join(".husky/_")).unwrap();
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 husky_content = read(temp.path().join(".husky").join(hook));
assert!(
husky_content.contains(MANAGED_MARKER),
".husky/{hook} should have truth-mirror block"
);
}
truth(temp.path()).args(["uninstall"]).assert().success();
for hook in ["commit-msg", "post-commit", "pre-push"] {
let husky_path = temp.path().join(".husky").join(hook);
if husky_path.exists() {
let content = read(&husky_path);
assert!(
!content.contains(MANAGED_MARKER),
".husky/{hook} should have truth-mirror block stripped after uninstall"
);
}
}
for hook in ["commit-msg", "post-commit", "pre-push"] {
let git_hook = temp.path().join(".git/hooks").join(hook);
if git_hook.exists() {
let content = read(&git_hook);
assert!(
!content.contains(MANAGED_MARKER),
".git/hooks/{hook} should not have truth-mirror managed shim after uninstall"
);
}
}
assert!(
non_managed_hook.exists(),
"non-managed .git/hooks/prepare-commit-msg must survive uninstall"
);
assert_eq!(
read(&non_managed_hook),
"#!/bin/sh\necho user-hook\n",
"non-managed hook content must be unchanged"
);
}