Skip to main content

truth_mirror/
hooks.rs

1//! Hook installation, uninstallation, and dry-run planning.
2
3use std::{
4    fs,
5    io::{self, Read},
6    path::{Component, Path, PathBuf},
7    process::{Command, ExitCode},
8};
9
10use anyhow::{Context, Result, bail};
11
12use crate::{
13    claim,
14    cli::{self, Agent, HookName},
15    gate,
16    reviewer::ReviewQueue,
17    surface::{self, SurfacePlan},
18};
19
20const INSTALLED_HOOKS: &[HookName] =
21    &[HookName::CommitMsg, HookName::PostCommit, HookName::PrePush];
22pub(crate) const MANAGED_MARKER: &str = "# truth-mirror managed hook";
23const MANAGED_END_MARKER: &str = "# truth-mirror hook end";
24pub(crate) const FORWARDER_NAME: &str = "_forward-local.sh";
25const OUTER_HOOK_MARKERS: &[&str] = &[
26    "# Entire CLI hooks",
27    "# truth-mirror managed hook",
28    "entire hooks git",
29];
30const STATE_GITIGNORE_HEADER: &str = "# truth-mirror runtime state - machine-generated";
31const STATE_GITIGNORE_LINES: &[&str] = &[
32    "*",
33    "!.gitignore",
34    "!config.toml",
35    "runs/",
36    "review-queue.jsonl",
37    "ledger.jsonl",
38    "ledger.md",
39    "tmp/",
40    "logs/",
41    "*.local.*",
42];
43pub(crate) const FORWARDER_SOURCE: &str = r#"#!/bin/sh
44# _forward-local.sh - delegate to .git/hooks from a committed core.hooksPath.
45# Uses --git-common-dir (resolves to .git/) not core.hooksPath to prevent an infinite loop.
46name="$1"; shift
47git_dir="$(git rev-parse --git-common-dir 2>/dev/null)"
48[ -n "$git_dir" ] || exit 0
49local_hook="$git_dir/hooks/$name"
50if [ -x "$local_hook" ]; then exec "$local_hook" "$@"; fi
51exit 0
52"#;
53
54/// Printed after `--pi` install: Pi only executes project extensions once the
55/// folder is trusted (verified against Pi 0.80.3 `core/project-trust.js`).
56const PI_TRUST_NOTE: &str = "pi: installed .pi/extensions/truth-mirror.js — Pi loads it once you trust this project folder in Pi.";
57
58/// Printed after `--grok` install: skill-based reinjection (Grok ignores passive hook stdout).
59const GROK_SKILL_NOTE: &str = "grok: installed .grok/skills/truth-mirror/SKILL.md — Grok does not inject passive hook stdout; run `truth-mirror reinject --agent grok` (skill reminds the agent). Project hooks need `/hooks-trust` if enforcement hooks are installed.";
60
61pub fn run(
62    args: cli::InstallHooksArgs,
63    state_dir: &Path,
64    config_path: Option<&Path>,
65    config: &crate::config::TruthMirrorConfig,
66) -> Result<ExitCode> {
67    let repo_root = git_root()?;
68    let hooks_path = repo_root.join(state_dir).join("hooks");
69    let plan = HookInstallPlan::new(&repo_root, &hooks_path, args.uninstall);
70    // Preserve non-default global flags so the INSTALLED hooks use the same config
71    // and state dir at runtime instead of silently reloading defaults.
72    let global_args = hook_global_args(config_path, &repo_root, state_dir);
73    let plan = HookInstallPlan {
74        global_args,
75        mode: detect_hook_mode(&repo_root, state_dir)?,
76        ..plan
77    };
78    let agents = file_surface_agents(&args);
79    let pi = pi_targeted(&args);
80    let grok = grok_targeted(&args);
81
82    if args.dry_run {
83        println!("{}", plan.render());
84        for agent in &agents {
85            println!(
86                "surface: {} -> {}",
87                surface::agent_slug(*agent),
88                surface::surface_relative_path(*agent)
89            );
90        }
91        if pi {
92            println!("surface: pi -> {}", surface::PI_EXTENSION_RELATIVE);
93        }
94        if grok {
95            println!("surface: grok -> {}", surface::GROK_SKILL_RELATIVE);
96            if config.enforcement.is_enabled() {
97                println!(
98                    "surface: grok-enforcement -> {}",
99                    surface::surface_relative_path(Agent::Grok)
100                );
101            }
102        }
103        return Ok(ExitCode::SUCCESS);
104    }
105
106    let enforcement_enabled = config.enforcement.is_enabled();
107
108    if args.uninstall {
109        uninstall(&plan)?;
110        for agent in &agents {
111            surface::uninstall_enforcement(&repo_root, *agent)?;
112            SurfacePlan::for_agent(&repo_root, *agent).uninstall()?;
113        }
114        if pi {
115            surface::uninstall_pi_extension(&repo_root)?;
116            remove_legacy_pi_hooks(&repo_root)?;
117        }
118        if grok {
119            // Best-effort enforcement uninstall first so a corrupt hooks JSON cannot
120            // block skill removal (full uninstall is also resilient elsewhere).
121            if let Err(error) = surface::uninstall_enforcement(&repo_root, Agent::Grok) {
122                eprintln!(
123                    "{} grok enforcement uninstall failed (continuing skill cleanup): {error}",
124                    crate::messages::diagnostic_prefix()
125                );
126            }
127            surface::uninstall_grok_skill(&repo_root)?;
128            surface::cleanup_empty_grok_dirs(&repo_root);
129        }
130        uninstall_legacy_hook_references(&repo_root)?;
131    } else {
132        install(&plan, args.inject_forwarder)?;
133        print_async_review_guidance(&plan.global_args);
134        for agent in &agents {
135            SurfacePlan::for_agent(&repo_root, *agent).install()?;
136            // Enforcement is opt-in: only install the tool-blocking hook when the
137            // repo config enables it. Preserve global flags so it uses this config.
138            if enforcement_enabled {
139                surface::install_enforcement(&repo_root, *agent, &plan.global_args)?;
140            }
141        }
142        if args.pi {
143            // Clean any bogus .pi/hooks.json a prior truth-mirror wrote, install the
144            // real project-local extension, and note the one-time trust step.
145            remove_legacy_pi_hooks(&repo_root)?;
146            surface::install_pi_extension(&repo_root)?;
147            println!("{PI_TRUST_NOTE}");
148        }
149        if args.grok {
150            // Grok cannot reinject via UserPromptSubmit stdout (passive hooks).
151            // Install a project skill; optional PreToolUse enforcement uses hooks JSON.
152            surface::install_grok_skill(&repo_root)?;
153            if enforcement_enabled {
154                surface::install_enforcement(&repo_root, Agent::Grok, &plan.global_args)?;
155            }
156            println!("{GROK_SKILL_NOTE}");
157        }
158    }
159
160    Ok(ExitCode::SUCCESS)
161}
162
163fn print_async_review_guidance(global_args: &str) {
164    eprintln!(
165        "{} post-commit hooks queue async reviews; {}.",
166        crate::messages::diagnostic_prefix(),
167        crate::messages::async_review_drain_hint(global_args)
168    );
169}
170
171/// Standalone `uninstall` subcommand: full teardown of all truth-mirror hooks,
172/// surfaces, legacy artefacts, and (with `--purge`) state directories.
173///
174/// Factoring note: the shared hook-uninstall logic reused here lives in
175/// [`uninstall`] (hook-mode dispatch) and the per-surface helpers in
176/// [`crate::surface`]. Agent-surface resilience (A7) means a malformed
177/// `settings.json` must not abort the remaining surfaces; errors are collected
178/// and reported together.
179pub fn run_uninstall(
180    args: cli::UninstallArgs,
181    state_dir: &Path,
182    config_path: Option<&Path>,
183) -> Result<std::process::ExitCode> {
184    let repo_root = git_root()?;
185    let hooks_path = repo_root.join(state_dir).join("hooks");
186    let global_args = hook_global_args(config_path, &repo_root, state_dir);
187    let plan = HookInstallPlan {
188        repo_root: repo_root.clone(),
189        hooks_path: hooks_path.clone(),
190        uninstall: true,
191        mode: detect_hook_mode(&repo_root, state_dir)?,
192        global_args,
193    };
194
195    if args.dry_run {
196        println!("{}", plan.render());
197        let agent_list = surface::FILE_SURFACE_AGENTS
198            .iter()
199            .map(|agent| surface::agent_slug(*agent))
200            .chain(["pi", "grok"])
201            .collect::<Vec<_>>()
202            .join(", ");
203        println!("uninstall: would remove all agent surfaces ({agent_list})");
204        println!(
205            "uninstall: would clean .pre-entire bridges and orphaned .pre-truth-mirror backups"
206        );
207        println!("uninstall: would remove legacy .truth-mirror/hooks/ install artifacts");
208        println!(
209            "uninstall: would scrub legacy truth-mirror hook references without managed markers"
210        );
211        if args.purge {
212            println!(
213                "uninstall: --purge: would delete {} and .truth-mirror/ entirely",
214                state_dir.display()
215            );
216        } else {
217            println!(
218                "uninstall: ledger data in {} is preserved (use --purge to delete)",
219                state_dir.display()
220            );
221        }
222        return Ok(std::process::ExitCode::SUCCESS);
223    }
224
225    // ── Step 1: uninstall git hooks (mode-specific) ─────────────────────────
226    uninstall(&plan)?;
227    // R1: Also clean up stale .git/hooks managed shims from a prior plain-mode
228    // install, regardless of the current hook mode (e.g. a Husky-mode repo that
229    // previously had a plain install). uninstall_plain is guarded by
230    // is_truth_mirror_managed so double-running it in Plain mode is a no-op.
231    if !matches!(plan.mode, HookInstallMode::Plain) {
232        uninstall_plain(&plan)?;
233    }
234
235    // ── Step 2: remove all agent surfaces (full teardown, resilient) ─────────
236    let mut surface_errors: Vec<String> = Vec::new();
237    for &agent in surface::FILE_SURFACE_AGENTS.iter() {
238        if let Err(error) = surface::uninstall_enforcement(&repo_root, agent) {
239            surface_errors.push(format!(
240                "enforcement/{}: {error}",
241                surface::agent_slug(agent)
242            ));
243        }
244        if let Err(error) = surface::SurfacePlan::for_agent(&repo_root, agent).uninstall() {
245            surface_errors.push(format!("surface/{}: {error}", surface::agent_slug(agent)));
246        }
247    }
248    // Pi extension
249    if let Err(error) = surface::uninstall_pi_extension(&repo_root) {
250        surface_errors.push(format!("surface/pi-extension: {error}"));
251    } else {
252        // A6: attempt non-recursive rmdir on .pi/extensions/ then .pi/
253        let pi_extensions = repo_root.join(".pi/extensions");
254        let pi_dir = repo_root.join(".pi");
255        // Ignore ENOTEMPTY (still has other files) — only remove if empty
256        let _ = fs::remove_dir(&pi_extensions);
257        let _ = fs::remove_dir(&pi_dir);
258    }
259    // Grok skill + enforcement hook (not in FILE_SURFACE_AGENTS)
260    if let Err(error) = surface::uninstall_enforcement(&repo_root, Agent::Grok) {
261        surface_errors.push(format!("enforcement/grok: {error}"));
262    }
263    if let Err(error) = surface::uninstall_grok_skill(&repo_root) {
264        surface_errors.push(format!("surface/grok-skill: {error}"));
265    } else {
266        surface::cleanup_empty_grok_dirs(&repo_root);
267    }
268    // Legacy .pi/hooks.json
269    if let Err(error) = remove_legacy_pi_hooks(&repo_root) {
270        surface_errors.push(format!("surface/pi-legacy: {error}"));
271    }
272
273    // ── Step 3: .pre-entire bridge cleanup (A2) ───────────────────────────────
274    // When Entire takes ownership of a hook it saves the previous hook
275    // (truth-mirror's managed shim) as `<hook>.pre-entire`. Remove those when
276    // they are still truth-mirror-managed so truth-mirror is fully gone.
277    if let Err(e) = uninstall_pre_entire_bridges(&plan) {
278        surface_errors.push(format!("bridges: {e}"));
279    }
280
281    // ── Step 4: orphaned .pre-truth-mirror backup cleanup (A3) ───────────────
282    let git_hooks = repo_root.join(".git/hooks");
283    if let Err(e) = cleanup_orphaned_backups(&git_hooks) {
284        surface_errors.push(format!("backups/.git/hooks: {e}"));
285    }
286    if let HookInstallMode::CustomCommitted { ref hooks_path } = plan.mode.clone()
287        && let Err(e) = cleanup_orphaned_backups(hooks_path)
288    {
289        surface_errors.push(format!("backups/{}: {e}", hooks_path.display()));
290    }
291
292    // ── Step 5: legacy .truth-mirror/hooks/ install artifact cleanup (A4) ───
293    let legacy_state = repo_root.join(crate::config::LEGACY_STATE_DIR);
294    let legacy_hooks_dir = legacy_state.join("hooks");
295    if legacy_hooks_dir.is_dir()
296        && let Err(e) = fs::remove_dir_all(&legacy_hooks_dir)
297    {
298        surface_errors.push(format!("legacy-hooks-dir: {e}"));
299    }
300
301    // ── Step 6: state .gitignore removal (A5) ────────────────────────────────
302    let real_state_dir = absolutize(&repo_root, state_dir);
303    remove_state_gitignore_if_pristine(&real_state_dir);
304
305    // ── Step 7: purge state directories (A4 --purge) ─────────────────────────
306    if args.purge {
307        if real_state_dir.is_dir()
308            && let Err(e) = fs::remove_dir_all(&real_state_dir)
309        {
310            surface_errors.push(format!("purge {}: {e}", real_state_dir.display()));
311        }
312        if legacy_state.is_dir()
313            && let Err(e) = fs::remove_dir_all(&legacy_state)
314        {
315            surface_errors.push(format!("purge {}: {e}", legacy_state.display()));
316        }
317    }
318
319    // ── Step 8: legacy truth-mirror references without managed markers ───────
320    // Older installs and hand-rolled husky/Entire bridges may invoke truth-mirror
321    // without the current `# truth-mirror managed hook` / `# truth-mirror hook end`
322    // delimiters. Strip or remove those references so the uninstall is complete.
323    if let Err(e) = uninstall_legacy_hook_references(&repo_root) {
324        surface_errors.push(format!("legacy-hook-refs: {e}"));
325    }
326
327    // ── Report all collected errors (A7 + R3) ────────────────────────────────
328    // surface_errors accumulates both surface/agent failures (Step 2) and
329    // hook-cleanup failures (Steps 3-7) so that a failure in any later step
330    // never silently drops earlier diagnostics.
331    if !surface_errors.is_empty() {
332        for error in &surface_errors {
333            eprintln!(
334                "{} uninstall error: {error}",
335                crate::messages::diagnostic_prefix()
336            );
337        }
338        return Ok(std::process::ExitCode::FAILURE);
339    }
340
341    Ok(std::process::ExitCode::SUCCESS)
342}
343
344/// Remove `.pre-entire` bridge files from a hooks directory when their content
345/// is truth-mirror-managed (A2). These are created when Entire takes ownership
346/// of a hook that truth-mirror previously managed.
347fn uninstall_pre_entire_bridges(plan: &HookInstallPlan) -> Result<()> {
348    let dirs: Vec<PathBuf> = match &plan.mode {
349        HookInstallMode::Husky { content_dir } => {
350            vec![plan.repo_root.join(".git/hooks"), content_dir.clone()]
351        }
352        _ => vec![plan.repo_root.join(".git/hooks")],
353    };
354    for dir in dirs {
355        for hook in INSTALLED_HOOKS {
356            let bridge = dir.join(format!("{}.pre-entire", hook.as_str()));
357            if read_to_string_if_file(&bridge)?
358                .as_deref()
359                .is_some_and(is_truth_mirror_managed)
360            {
361                remove_file_if_exists(&bridge)?;
362            }
363        }
364    }
365    Ok(())
366}
367
368/// Clean up orphaned `.pre-truth-mirror` backup files in `hooks_dir` (A3).
369///
370/// Safe rules:
371/// - Active hook absent → restore backup to active, remove backup.
372/// - Active exists and backup bytes == backup bytes → redundant copy, remove.
373/// - Active is a directory, or either read fails (non-UTF-8, permissions) →
374///   preserve the backup and warn; continue to the next hook.
375/// - Otherwise → content differs; preserve the backup and warn.
376///
377/// Only called when the active hook is NOT truth-mirror-managed (the case where
378/// truth-mirror-managed active hooks restoring the backup is already handled by
379/// [`uninstall_plain`]).
380///
381/// Uses raw byte comparison (R4) so non-UTF-8 hook content never aborts the run.
382fn cleanup_orphaned_backups(hooks_dir: &Path) -> Result<()> {
383    if !hooks_dir.is_dir() {
384        return Ok(());
385    }
386    for hook in INSTALLED_HOOKS {
387        let active = hooks_dir.join(hook.as_str());
388        let backup = backup_path(&active, "pre-truth-mirror");
389        if !backup.is_file() {
390            continue;
391        }
392
393        // Read backup bytes; a read failure is unexpected — skip and warn.
394        let backup_bytes = match fs::read(&backup) {
395            Ok(b) => b,
396            Err(e) => {
397                eprintln!(
398                    "{} warning: could not read backup {}; leaving it in place: {e}",
399                    crate::messages::diagnostic_prefix(),
400                    backup.display(),
401                );
402                continue;
403            }
404        };
405
406        // If the active path is a directory we cannot safely proceed.
407        if active.is_dir() {
408            eprintln!(
409                "{} warning: active hook path {} is a directory, not a file; \
410                 leaving backup {} in place.",
411                crate::messages::diagnostic_prefix(),
412                active.display(),
413                backup.display(),
414            );
415            continue;
416        }
417
418        // Read the active hook as raw bytes; treat missing as None.
419        let active_bytes = match read_bytes_if_file(&active) {
420            Ok(b) => b,
421            Err(e) => {
422                eprintln!(
423                    "{} warning: could not read active hook {}; leaving backup {} in place: {e}",
424                    crate::messages::diagnostic_prefix(),
425                    active.display(),
426                    backup.display(),
427                );
428                continue;
429            }
430        };
431
432        // Only process backups when the active hook is NOT truth-mirror-managed.
433        // (truth-mirror-managed active hook + backup is already handled in uninstall_plain.)
434        // Convert to str for the marker check; non-UTF-8 → treat as not managed.
435        let active_str = active_bytes
436            .as_deref()
437            .and_then(|b| std::str::from_utf8(b).ok());
438        if active_str.is_some_and(is_truth_mirror_managed) {
439            continue;
440        }
441
442        match active_bytes {
443            None => {
444                // Active absent — restore backup (recovery path).
445                fs::write(&active, &backup_bytes)?;
446                make_executable(&active)?;
447                fs::remove_file(&backup)?;
448            }
449            Some(ref ab) if ab == &backup_bytes => {
450                // Backup is a redundant copy — remove it.
451                fs::remove_file(&backup)?;
452            }
453            Some(_) => {
454                // Content differs — preserve the backup and warn.
455                eprintln!(
456                    "{} warning: preserved {} because its content differs from the active \
457                     hook {}; inspect and remove manually.",
458                    crate::messages::diagnostic_prefix(),
459                    backup.display(),
460                    active.display(),
461                );
462            }
463        }
464    }
465    Ok(())
466}
467
468/// Remove the machine-generated state `.gitignore` iff its content starts with
469/// the managed header. If the file has been user-modified, leave it and warn (A5).
470fn remove_state_gitignore_if_pristine(state_dir: &Path) {
471    let gitignore = state_dir.join(".gitignore");
472    match read_to_string_if_file(&gitignore) {
473        Ok(Some(content)) if content.starts_with(STATE_GITIGNORE_HEADER) => {
474            if let Err(error) = remove_file_if_exists(&gitignore) {
475                eprintln!(
476                    "{} warning: could not remove state .gitignore {}: {error}",
477                    crate::messages::diagnostic_prefix(),
478                    gitignore.display(),
479                );
480            }
481        }
482        Ok(Some(_)) => {
483            eprintln!(
484                "{} hint: {} appears user-modified; leaving it in place.",
485                crate::messages::diagnostic_prefix(),
486                gitignore.display(),
487            );
488        }
489        _ => {}
490    }
491}
492
493/// File-surface agents (Claude, Codex) touched by this invocation. Install acts
494/// only on explicitly selected agents; a bare `install-hooks --uninstall` clears
495/// all file surfaces so it fully reverses a prior per-agent install.
496fn file_surface_agents(args: &cli::InstallHooksArgs) -> Vec<Agent> {
497    let mut agents = Vec::new();
498    if args.claude {
499        agents.push(Agent::Claude);
500    }
501    if args.codex {
502        agents.push(Agent::Codex);
503    }
504
505    if agents.is_empty() && args.uninstall && !args.pi && !args.grok {
506        return surface::FILE_SURFACE_AGENTS.to_vec();
507    }
508    agents
509}
510
511/// Whether this invocation should act on Pi: explicit `--pi`, or a bare
512/// `--uninstall` (no agent flags) that clears everything including legacy Pi files.
513fn pi_targeted(args: &cli::InstallHooksArgs) -> bool {
514    args.pi || (args.uninstall && !args.claude && !args.codex && !args.grok)
515}
516
517/// Whether this invocation should act on Grok: explicit `--grok`, or bare uninstall.
518fn grok_targeted(args: &cli::InstallHooksArgs) -> bool {
519    args.grok || (args.uninstall && !args.claude && !args.codex && !args.pi)
520}
521
522/// Remove a `.pi/hooks.json` left by an earlier (incorrect) truth-mirror version.
523fn remove_legacy_pi_hooks(repo_root: &Path) -> Result<()> {
524    let path = repo_root.join(".pi/hooks.json");
525    if path.is_file() {
526        fs::remove_file(&path)?;
527    }
528    Ok(())
529}
530
531pub fn dispatch(
532    args: cli::HookDispatchArgs,
533    state_dir: &Path,
534    config_path: Option<&Path>,
535    config: &crate::config::TruthMirrorConfig,
536) -> Result<ExitCode> {
537    run_chained_hook(state_dir, args.hook, &args.args)?;
538
539    match args.hook {
540        HookName::CommitMsg => dispatch_commit_msg(state_dir, &args.args, config)?,
541        HookName::PostCommit => dispatch_post_commit(state_dir)?,
542        HookName::PrePush => dispatch_pre_push(state_dir, config_path, config, &args.args)?,
543    }
544
545    Ok(ExitCode::SUCCESS)
546}
547
548#[derive(Clone, Debug, Default, Eq, PartialEq)]
549pub struct HookInstallPlan {
550    pub repo_root: PathBuf,
551    pub hooks_path: PathBuf,
552    pub uninstall: bool,
553    pub mode: HookInstallMode,
554    /// Global CLI flags (`--config`, `--state-dir`) preserved into the shims so a
555    /// custom-config install keeps using that config at hook runtime. Empty for a
556    /// default install (the trailing space is included when non-empty).
557    pub global_args: String,
558}
559
560#[derive(Clone, Debug, Default, Eq, PartialEq)]
561pub enum HookInstallMode {
562    #[default]
563    Plain,
564    LegacyTruthMirrorHooksPath {
565        configured_path: String,
566    },
567    Husky {
568        content_dir: PathBuf,
569    },
570    CustomCommitted {
571        hooks_path: PathBuf,
572    },
573}
574
575impl HookInstallPlan {
576    pub fn new(repo_root: &Path, hooks_path: &Path, uninstall: bool) -> Self {
577        Self {
578            repo_root: repo_root.to_path_buf(),
579            hooks_path: hooks_path.to_path_buf(),
580            uninstall,
581            mode: HookInstallMode::Plain,
582            global_args: String::new(),
583        }
584    }
585
586    pub fn render(&self) -> String {
587        let action = if self.uninstall {
588            "uninstall"
589        } else {
590            "install"
591        };
592        let mut output = format!(
593            "truth-mirror hook plan\nrepo={}\naction={action}\nmode={}\nstateHooks={}\n",
594            self.repo_root.display(),
595            self.mode.as_str(),
596            self.hooks_path.display()
597        );
598        for hook in INSTALLED_HOOKS {
599            output.push_str(&format!(
600                "\nhook={}\npath={}\nbody:\n{}",
601                hook.as_str(),
602                self.active_hook_path(*hook).display(),
603                self.render_hook_body(*hook)
604            ));
605        }
606        output
607    }
608
609    fn active_hook_path(&self, hook: HookName) -> PathBuf {
610        match &self.mode {
611            HookInstallMode::Husky { content_dir } => content_dir.join(hook.as_str()),
612            HookInstallMode::Plain | HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
613                self.repo_root.join(".git/hooks").join(hook.as_str())
614            }
615            HookInstallMode::CustomCommitted { hooks_path } => hooks_path.join(hook.as_str()),
616        }
617    }
618
619    fn render_hook_body(&self, hook: HookName) -> String {
620        match self.mode {
621            HookInstallMode::Husky { .. } => render_husky_block(hook, &self.global_args),
622            HookInstallMode::CustomCommitted { .. } => render_custom_forwarder_stub(hook),
623            HookInstallMode::Plain | HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
624                render_shim(hook, &self.global_args)
625            }
626        }
627    }
628}
629
630impl HookInstallMode {
631    fn as_str(&self) -> &'static str {
632        match self {
633            HookInstallMode::Plain => "plain",
634            HookInstallMode::LegacyTruthMirrorHooksPath { .. } => "plain-legacy-truth-mirror",
635            HookInstallMode::Husky { .. } => "husky",
636            HookInstallMode::CustomCommitted { .. } => "custom-committed",
637        }
638    }
639}
640
641/// Build the preserved global-flag prefix (with a trailing space when non-empty).
642fn hook_global_args(config_path: Option<&Path>, repo_root: &Path, state_dir: &Path) -> String {
643    let mut parts = Vec::new();
644    if let Some(config) = config_path {
645        parts.push(format!(
646            "--config {}",
647            quote_git_arg(&absolutize(repo_root, config))
648        ));
649    }
650    if state_dir != Path::new(crate::config::DEFAULT_STATE_DIR) {
651        parts.push(format!(
652            "--state-dir {}",
653            quote_git_arg(&absolutize(repo_root, state_dir))
654        ));
655    }
656    if parts.is_empty() {
657        String::new()
658    } else {
659        format!("{} ", parts.join(" "))
660    }
661}
662
663fn absolutize(repo_root: &Path, path: &Path) -> PathBuf {
664    if path.is_absolute() {
665        path.to_path_buf()
666    } else {
667        repo_root.join(path)
668    }
669}
670
671/// POSIX single-quote escaping for a path embedded in the generated `/bin/sh`
672/// shim: wrap in single quotes and replace any embedded single quote with `'\''`
673/// so no metacharacter (`;`, `$()`, `'`, spaces, …) can break or inject shell.
674fn quote_git_arg(path: &Path) -> String {
675    let value = path.to_string_lossy();
676    format!("'{}'", value.replace('\'', "'\\''"))
677}
678
679pub fn render_shim(hook: HookName, global_args: &str) -> String {
680    format!(
681        "#!/bin/sh\n{MANAGED_MARKER}\nexec truth-mirror {global_args}hook-dispatch {} \"$@\"\n",
682        hook.as_str()
683    )
684}
685
686fn render_husky_block(hook: HookName, global_args: &str) -> String {
687    format!(
688        "{MANAGED_MARKER}\nif command -v truth-mirror >/dev/null 2>&1; then truth-mirror {global_args}hook-dispatch {} \"$@\"; else :; fi\n{MANAGED_END_MARKER}\n",
689        hook.as_str()
690    )
691}
692
693fn render_custom_forwarder_stub(hook: HookName) -> String {
694    format!(
695        "#!/bin/sh\n{MANAGED_MARKER}\nexec \"$(dirname \"$0\")/{FORWARDER_NAME}\" {} \"$@\"\n",
696        hook.as_str()
697    )
698}
699
700fn detect_hook_mode(repo_root: &Path, state_dir: &Path) -> Result<HookInstallMode> {
701    let Some(configured) = git_config_get(repo_root, "core.hooksPath")? else {
702        return Ok(HookInstallMode::Plain);
703    };
704    let configured = configured.trim().to_owned();
705    if configured.is_empty() {
706        return Ok(HookInstallMode::Plain);
707    }
708    if is_managed_hooks_path(repo_root, state_dir, &configured) {
709        return Ok(HookInstallMode::LegacyTruthMirrorHooksPath {
710            configured_path: configured,
711        });
712    }
713    if configured.contains(".husky") {
714        let hooks_path = repo_relative_path(repo_root, Path::new(&configured));
715        let content_dir = if hooks_path.file_name().and_then(|name| name.to_str()) == Some("_") {
716            hooks_path
717                .parent()
718                .map_or_else(|| repo_root.join(".husky"), Path::to_path_buf)
719        } else {
720            hooks_path
721        };
722        return Ok(HookInstallMode::Husky { content_dir });
723    }
724    Ok(HookInstallMode::CustomCommitted {
725        hooks_path: repo_relative_path(repo_root, Path::new(&configured)),
726    })
727}
728
729fn repo_relative_path(repo_root: &Path, path: &Path) -> PathBuf {
730    if path.is_absolute() {
731        path.to_path_buf()
732    } else {
733        repo_root.join(path)
734    }
735}
736
737fn is_managed_hooks_path(repo_root: &Path, state_dir: &Path, configured: &str) -> bool {
738    let configured_path = normalize_path(&repo_relative_path(repo_root, Path::new(configured)));
739    [
740        state_dir.join("hooks"),
741        PathBuf::from(crate::config::DEFAULT_STATE_DIR).join("hooks"),
742        PathBuf::from(crate::config::LEGACY_STATE_DIR).join("hooks"),
743    ]
744    .iter()
745    .any(|candidate| normalize_path(&repo_relative_path(repo_root, candidate)) == configured_path)
746}
747
748fn normalize_path(path: &Path) -> PathBuf {
749    let mut normalized = PathBuf::new();
750    for component in path.components() {
751        match component {
752            Component::CurDir => {}
753            Component::ParentDir => {
754                if normalized.as_os_str().is_empty()
755                    || (!normalized.has_root() && normalized.ends_with(".."))
756                {
757                    normalized.push("..");
758                } else {
759                    normalized.pop();
760                }
761            }
762            Component::Normal(part) => normalized.push(part),
763            Component::RootDir | Component::Prefix(_) => normalized.push(component.as_os_str()),
764        }
765    }
766    normalized
767}
768
769fn install(plan: &HookInstallPlan, inject_forwarder: bool) -> Result<()> {
770    let state_dir = state_dir_from_hooks_path(&plan.hooks_path);
771    match &plan.mode {
772        HookInstallMode::Plain => {
773            prepare_state_hooks(plan)?;
774            migrate_truth_md(&plan.repo_root, state_dir)?;
775            install_plain(plan)
776        }
777        HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
778            prepare_state_hooks(plan)?;
779            migrate_truth_md(&plan.repo_root, state_dir)?;
780            unset_core_hooks_path(&plan.repo_root)?;
781            install_plain(plan)
782        }
783        HookInstallMode::Husky { content_dir } => {
784            prepare_state_hooks(plan)?;
785            migrate_truth_md(&plan.repo_root, state_dir)?;
786            install_husky(plan, content_dir)
787        }
788        HookInstallMode::CustomCommitted { hooks_path } => {
789            install_custom(plan, hooks_path, inject_forwarder)
790        }
791    }
792}
793
794fn uninstall(plan: &HookInstallPlan) -> Result<()> {
795    match &plan.mode {
796        HookInstallMode::Plain => uninstall_plain(plan)?,
797        HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
798            unset_core_hooks_path(&plan.repo_root)?;
799            uninstall_plain(plan)?;
800        }
801        HookInstallMode::Husky { content_dir } => uninstall_husky(plan, content_dir)?,
802        HookInstallMode::CustomCommitted { hooks_path } => {
803            uninstall_plain(plan)?;
804            uninstall_custom_forwarders(hooks_path)?;
805        }
806    }
807
808    if plan.hooks_path.exists() {
809        fs::remove_dir_all(&plan.hooks_path)?;
810    }
811    Ok(())
812}
813
814fn state_dir_from_hooks_path(hooks_path: &Path) -> &Path {
815    hooks_path.parent().unwrap_or(hooks_path)
816}
817
818fn prepare_state_hooks(plan: &HookInstallPlan) -> Result<()> {
819    ensure_state_gitignore(&plan.repo_root, state_dir_from_hooks_path(&plan.hooks_path))?;
820    fs::create_dir_all(&plan.hooks_path)?;
821    fs::create_dir_all(plan.hooks_path.join("chained"))?;
822    clear_chained_hooks(plan)
823}
824
825fn install_plain(plan: &HookInstallPlan) -> Result<()> {
826    let git_hooks = plan.repo_root.join(".git/hooks");
827    fs::create_dir_all(&git_hooks)?;
828    for hook in INSTALLED_HOOKS {
829        install_local_hook(plan, &git_hooks, *hook)?;
830    }
831    Ok(())
832}
833
834fn install_local_hook(plan: &HookInstallPlan, git_hooks: &Path, hook: HookName) -> Result<()> {
835    let active = git_hooks.join(hook.as_str());
836    let backup = backup_path(&active, "pre-truth-mirror");
837    let chained = plan.hooks_path.join("chained").join(hook.as_str());
838    remove_file_if_exists(&chained)?;
839
840    let source = local_hook_source(&active, &backup)?;
841    if let Some(source) = source {
842        if source.path != backup {
843            fs::copy(&source.path, &backup)?;
844            make_executable(&backup)?;
845        }
846        if !has_outer_hook_marker(&source.content) {
847            fs::copy(&source.path, &chained)?;
848            make_executable(&chained)?;
849        }
850    }
851
852    fs::write(&active, render_shim(hook, &plan.global_args))?;
853    make_executable(&active)?;
854    Ok(())
855}
856
857fn uninstall_plain(plan: &HookInstallPlan) -> Result<()> {
858    let git_hooks = plan.repo_root.join(".git/hooks");
859    for hook in INSTALLED_HOOKS {
860        let active = git_hooks.join(hook.as_str());
861        let backup = backup_path(&active, "pre-truth-mirror");
862        let active_content = read_to_string_if_file(&active)?;
863        if active_content
864            .as_deref()
865            .is_some_and(is_truth_mirror_managed)
866        {
867            if backup.is_file() {
868                fs::copy(&backup, &active)?;
869                make_executable(&active)?;
870                fs::remove_file(&backup)?;
871            } else {
872                remove_file_if_exists(&active)?;
873            }
874        }
875        remove_file_if_exists(&plan.hooks_path.join("chained").join(hook.as_str()))?;
876    }
877    Ok(())
878}
879
880fn install_husky(plan: &HookInstallPlan, content_dir: &Path) -> Result<()> {
881    fs::create_dir_all(content_dir)?;
882    for hook in INSTALLED_HOOKS {
883        let target = content_dir.join(hook.as_str());
884        let block = render_husky_block(*hook, &plan.global_args);
885        let next = match read_to_string_if_file(&target)? {
886            Some(content) => append_managed_block(&content, &block),
887            None => format!("#!/bin/sh\n{block}"),
888        };
889        fs::write(&target, next)?;
890        make_executable(&target)?;
891    }
892    Ok(())
893}
894
895fn uninstall_husky(_plan: &HookInstallPlan, content_dir: &Path) -> Result<()> {
896    for hook in INSTALLED_HOOKS {
897        let target = content_dir.join(hook.as_str());
898        if let Some(content) = read_to_string_if_file(&target)? {
899            let next = remove_managed_block(&content);
900            if is_empty_or_shebang_only(&next) {
901                fs::remove_file(&target)?;
902            } else {
903                fs::write(&target, next)?;
904                make_executable(&target)?;
905            }
906        }
907    }
908    Ok(())
909}
910
911fn install_custom(plan: &HookInstallPlan, hooks_path: &Path, inject_forwarder: bool) -> Result<()> {
912    let missing_forwarder = INSTALLED_HOOKS
913        .iter()
914        .any(|hook| !custom_forwarder_present(hooks_path, *hook));
915    if missing_forwarder && !inject_forwarder {
916        bail!(
917            "{} core.hooksPath is set to '{}' (a non-husky committed directory).\nTo install, either:\n  (a) run `{}` to write a _forward-local.sh forwarder into that directory (a committed repo change), or\n  (b) unset core.hooksPath and re-run install-hooks",
918            crate::messages::diagnostic_prefix(),
919            hooks_path.display(),
920            crate::messages::command_for_cli(&plan.global_args, "install-hooks --inject-forwarder"),
921        );
922    }
923
924    if inject_forwarder {
925        inject_custom_forwarders(hooks_path)?;
926    }
927
928    prepare_state_hooks(plan)?;
929    let state_dir = state_dir_from_hooks_path(&plan.hooks_path);
930    migrate_truth_md(&plan.repo_root, state_dir)?;
931    let git_hooks = plan.repo_root.join(".git/hooks");
932    fs::create_dir_all(&git_hooks)?;
933    for hook in INSTALLED_HOOKS {
934        install_local_hook(plan, &git_hooks, *hook)?;
935    }
936    Ok(())
937}
938
939fn inject_custom_forwarders(hooks_path: &Path) -> Result<()> {
940    fs::create_dir_all(hooks_path)?;
941    let forwarder = hooks_path.join(FORWARDER_NAME);
942    fs::write(&forwarder, FORWARDER_SOURCE)?;
943    make_executable(&forwarder)?;
944
945    for hook in INSTALLED_HOOKS {
946        let target = hooks_path.join(hook.as_str());
947        if custom_forwarder_present(hooks_path, *hook) {
948            continue;
949        }
950        if target.is_file() {
951            fs::copy(&target, backup_path(&target, "pre-truth-mirror"))?;
952        }
953        fs::write(&target, render_custom_forwarder_stub(*hook))?;
954        make_executable(&target)?;
955    }
956    eprintln!(
957        "hint: commit the truth-mirror forwarder changes in {}",
958        hooks_path.display()
959    );
960    Ok(())
961}
962
963fn uninstall_custom_forwarders(hooks_path: &Path) -> Result<()> {
964    for hook in INSTALLED_HOOKS {
965        let target = hooks_path.join(hook.as_str());
966        let backup = backup_path(&target, "pre-truth-mirror");
967        let content = read_to_string_if_file(&target)?;
968        if content.as_deref().is_some_and(is_truth_mirror_managed) {
969            if backup.is_file() {
970                fs::copy(&backup, &target)?;
971                make_executable(&target)?;
972                fs::remove_file(&backup)?;
973            } else {
974                remove_file_if_exists(&target)?;
975            }
976        }
977    }
978    let forwarder = hooks_path.join(FORWARDER_NAME);
979    if read_to_string_if_file(&forwarder)?.as_deref() == Some(FORWARDER_SOURCE) {
980        fs::remove_file(forwarder)?;
981    }
982    Ok(())
983}
984
985fn append_managed_block(content: &str, block: &str) -> String {
986    let mut next = remove_managed_block(content);
987    if !next.ends_with('\n') {
988        next.push('\n');
989    }
990    next.push_str(block);
991    next
992}
993
994fn remove_managed_block(content: &str) -> String {
995    let mut out = Vec::new();
996    let mut skipping = false;
997    for line in content.lines() {
998        if line == MANAGED_MARKER {
999            skipping = true;
1000            continue;
1001        }
1002        if skipping {
1003            if line == MANAGED_END_MARKER {
1004                skipping = false;
1005            }
1006            continue;
1007        }
1008        out.push(line);
1009    }
1010    if out.is_empty() {
1011        String::new()
1012    } else {
1013        format!("{}\n", out.join("\n"))
1014    }
1015}
1016
1017fn is_empty_or_shebang_only(content: &str) -> bool {
1018    let lines = content
1019        .lines()
1020        .map(str::trim)
1021        .filter(|line| !line.is_empty())
1022        .collect::<Vec<_>>();
1023    lines.is_empty() || lines == ["#!/bin/sh"]
1024}
1025
1026/// Check whether a shell script parses successfully with `sh -n`.
1027fn shell_syntax_ok(path: &Path) -> bool {
1028    std::process::Command::new("sh")
1029        .args(["-n"])
1030        .arg(path)
1031        .status()
1032        .map(|status| status.success())
1033        .unwrap_or(false)
1034}
1035
1036/// True when the only non-comment content left is a shebang and shell options.
1037/// Used after stripping legacy truth-mirror lines so that bare `set -e` lines
1038/// do not keep an otherwise-empty husky hook file alive.
1039fn is_effectively_empty_shell(content: &str) -> bool {
1040    content
1041        .lines()
1042        .map(str::trim)
1043        .filter(|line| !line.is_empty() && !line.starts_with('#'))
1044        .all(|line| line == "#!/bin/sh" || line == "#!/usr/bin/env sh" || line.starts_with("set "))
1045}
1046
1047#[derive(Debug)]
1048struct LocalHookSource {
1049    path: PathBuf,
1050    content: String,
1051}
1052
1053fn local_hook_source(active: &Path, backup: &Path) -> Result<Option<LocalHookSource>> {
1054    let Some(active_content) = read_to_string_if_file(active)? else {
1055        return Ok(None);
1056    };
1057    if is_truth_mirror_managed(&active_content) {
1058        return read_to_string_if_file(backup)?.map_or(Ok(None), |content| {
1059            Ok(Some(LocalHookSource {
1060                path: backup.to_path_buf(),
1061                content,
1062            }))
1063        });
1064    }
1065    Ok(Some(LocalHookSource {
1066        path: active.to_path_buf(),
1067        content: active_content,
1068    }))
1069}
1070
1071fn custom_forwarder_present(hooks_path: &Path, hook: HookName) -> bool {
1072    read_to_string_if_file(&hooks_path.join(hook.as_str()))
1073        .ok()
1074        .flatten()
1075        .is_some_and(|content| {
1076            active_hook_lines(&content).any(|line| line.contains(FORWARDER_NAME))
1077                || content_forwards_to_local_git_hook(&content, hook)
1078                || is_truth_mirror_managed(&content)
1079        })
1080}
1081
1082fn ensure_state_gitignore(repo_root: &Path, state_dir: &Path) -> Result<()> {
1083    let state_dir = absolutize(repo_root, state_dir);
1084    fs::create_dir_all(&state_dir)?;
1085    let gitignore = state_dir.join(".gitignore");
1086    let existing = read_to_string_if_file(&gitignore)?.unwrap_or_default();
1087    let content = render_state_gitignore(&existing);
1088    fs::write(&gitignore, content)?;
1089    print_tracked_runtime_hints(repo_root, &state_dir)?;
1090    Ok(())
1091}
1092
1093fn render_state_gitignore(existing: &str) -> String {
1094    let mut content = String::new();
1095    content.push_str(STATE_GITIGNORE_HEADER);
1096    content.push('\n');
1097    for line in STATE_GITIGNORE_LINES {
1098        content.push_str(line);
1099        content.push('\n');
1100    }
1101
1102    let standard = |line: &str| {
1103        let trimmed = line.trim();
1104        trimmed == STATE_GITIGNORE_HEADER || STATE_GITIGNORE_LINES.contains(&trimmed)
1105    };
1106
1107    let mut wrote_separator = false;
1108    for line in existing.lines().filter(|line| !standard(line)) {
1109        if !wrote_separator {
1110            content.push('\n');
1111            wrote_separator = true;
1112        }
1113        content.push_str(line);
1114        content.push('\n');
1115    }
1116
1117    content
1118}
1119
1120fn print_tracked_runtime_hints(repo_root: &Path, state_dir: &Path) -> Result<()> {
1121    let rel_state = state_dir.strip_prefix(repo_root).unwrap_or(state_dir);
1122    for path in [
1123        rel_state.join("ledger.jsonl"),
1124        rel_state.join("ledger.md"),
1125        rel_state.join("review-queue.jsonl"),
1126        rel_state.join("runs"),
1127        rel_state.join("tmp"),
1128        rel_state.join("logs"),
1129    ] {
1130        let output = Command::new("git")
1131            .args(["ls-files", "--"])
1132            .arg(&path)
1133            .current_dir(repo_root)
1134            .output()?;
1135        if output.status.success() {
1136            for tracked in String::from_utf8_lossy(&output.stdout)
1137                .lines()
1138                .filter(|line| !line.trim().is_empty())
1139            {
1140                eprintln!("hint: run: git rm --cached {tracked}");
1141            }
1142        }
1143    }
1144    Ok(())
1145}
1146
1147fn clear_chained_hooks(plan: &HookInstallPlan) -> Result<()> {
1148    for hook in INSTALLED_HOOKS {
1149        remove_file_if_exists(&plan.hooks_path.join("chained").join(hook.as_str()))?;
1150    }
1151    Ok(())
1152}
1153
1154fn has_outer_hook_marker(content: &str) -> bool {
1155    content.lines().take(20).any(|line| {
1156        OUTER_HOOK_MARKERS
1157            .iter()
1158            .any(|marker| line.contains(marker))
1159            || (!line.trim_start().starts_with('#') && line_invokes_truth_hook_dispatch(line, None))
1160    })
1161}
1162
1163fn is_truth_mirror_managed(content: &str) -> bool {
1164    content.contains(MANAGED_MARKER)
1165        || active_hook_lines(content).any(|line| line_invokes_truth_hook_dispatch(line, None))
1166}
1167
1168/// Markers that indicate a hook file also contains Entire CLI integration.
1169/// When removing legacy truth-mirror references we must preserve Entire.
1170const ENTIRE_MARKERS: &[&str] = &[
1171    "# Entire CLI hooks",
1172    "entire hooks git",
1173    "command -v entire",
1174];
1175
1176/// Detect content that references truth-mirror but does not carry the current
1177/// managed-block markers. Old husky installs, hand-rolled bridges, and scripts
1178/// that assign `truth_mirror_bin` all match so that uninstall can finish the
1179/// teardown that marker-based removal alone cannot.
1180fn is_legacy_truth_reference(content: &str) -> bool {
1181    content.contains("truth-mirror")
1182        || content.contains("truth_mirror_bin")
1183        || is_truth_mirror_managed(content)
1184}
1185
1186/// True when a `.pre-entire` file is already the simple Entire→husky bridge
1187/// that install/uninstall want to preserve.
1188fn is_simple_pre_entire_bridge(content: &str, hook_name: &str) -> bool {
1189    // Normalize whitespace so hand-formatted variants still match.
1190    let normalized: String = content
1191        .lines()
1192        .map(str::trim)
1193        .filter(|line| !line.is_empty() && !line.starts_with('#'))
1194        .collect::<Vec<_>>()
1195        .join(" ");
1196    normalized == format!(r#"exec "$(dirname "$0")/{hook_name}" "$@""#)
1197}
1198
1199/// Remove lines that are obviously part of a legacy truth-mirror hook, while
1200/// leaving Entire or other foreign content intact.
1201fn strip_legacy_truth_lines(content: &str) -> String {
1202    let mut out = Vec::new();
1203    for line in content.lines() {
1204        let trimmed = line.trim();
1205        if trimmed.contains("truth-mirror")
1206            || trimmed.contains("truth_mirror_bin")
1207            || trimmed.starts_with("# Truthfulness gate")
1208            || trimmed.starts_with("# truth-mirror's")
1209        {
1210            continue;
1211        }
1212        out.push(line);
1213    }
1214    if out.is_empty() {
1215        String::new()
1216    } else {
1217        format!("{}\n", out.join("\n"))
1218    }
1219}
1220
1221/// Scrub legacy truth-mirror references from `.husky/`, `.git/hooks/`, and
1222/// `.truth-mirror/hooks/` files that were not written with the current managed
1223/// block markers. Files that are solely truth-mirror are removed; files that
1224/// also contain Entire integration are stripped of truth-mirror lines.
1225///
1226/// `.pre-entire` bridges created by the husky+Entire+truth-mirror chain are
1227/// rewritten to a simple bridge so Entire keeps forwarding to the husky hook.
1228fn uninstall_legacy_hook_references(repo_root: &Path) -> Result<()> {
1229    let husky_dir = repo_root.join(".husky");
1230    let git_hooks = repo_root.join(".git/hooks");
1231    let legacy_hooks = repo_root
1232        .join(crate::config::LEGACY_STATE_DIR)
1233        .join("hooks");
1234
1235    for hook in INSTALLED_HOOKS {
1236        let hook_name = hook.as_str();
1237        let names: Vec<String> = vec![hook_name.to_owned(), format!("{hook_name}.pre-entire")];
1238        for dir in [&husky_dir, &git_hooks, &legacy_hooks] {
1239            for name in &names {
1240                let path = dir.join(name);
1241                let Some(content) = read_to_string_if_file(&path)? else {
1242                    continue;
1243                };
1244                if content.contains(MANAGED_MARKER) {
1245                    continue;
1246                }
1247                if name.ends_with(".pre-entire") {
1248                    if is_simple_pre_entire_bridge(&content, hook_name) {
1249                        continue;
1250                    }
1251                    if is_legacy_truth_reference(&content) || !shell_syntax_ok(&path) {
1252                        // Keep the Entire forwarding bridge but drop any custom
1253                        // truth-mirror logic that may leave broken `else` blocks.
1254                        let bridge =
1255                            format!("#!/bin/sh\nexec \"$(dirname \"$0\")/{hook_name}\" \"$@\"\n");
1256                        fs::write(&path, bridge)?;
1257                        make_executable(&path)?;
1258                    }
1259                    continue;
1260                }
1261                if !is_legacy_truth_reference(&content) {
1262                    continue;
1263                }
1264                if ENTIRE_MARKERS.iter().any(|marker| content.contains(marker)) {
1265                    let stripped = strip_legacy_truth_lines(&content);
1266                    if is_effectively_empty_shell(&stripped) {
1267                        fs::remove_file(&path)?;
1268                    } else {
1269                        fs::write(&path, stripped)?;
1270                        make_executable(&path)?;
1271                    }
1272                } else {
1273                    fs::remove_file(&path)?;
1274                }
1275            }
1276        }
1277    }
1278    Ok(())
1279}
1280
1281/// Move legacy TRUTH.md locations into the current state dir so that install
1282/// leaves the repo following the `.truth/TRUTH.md` convention.
1283fn migrate_truth_md(repo_root: &Path, state_dir: &Path) -> Result<()> {
1284    let target = state_dir.join("TRUTH.md");
1285    if target.is_file() {
1286        return Ok(());
1287    }
1288    let legacy = repo_root
1289        .join(crate::config::LEGACY_STATE_DIR)
1290        .join("TRUTH.md");
1291    if legacy.is_file() {
1292        fs::copy(&legacy, &target)?;
1293        fs::remove_file(&legacy)?;
1294        return Ok(());
1295    }
1296    let root = repo_root.join("TRUTH.md");
1297    if root.is_file() {
1298        fs::rename(&root, &target)?;
1299    }
1300    Ok(())
1301}
1302
1303fn active_hook_lines(content: &str) -> impl Iterator<Item = &str> {
1304    content
1305        .lines()
1306        .map(str::trim_start)
1307        .filter(|line| !line.trim().is_empty() && !line.starts_with('#'))
1308}
1309
1310fn content_forwards_to_local_git_hook(content: &str, hook: HookName) -> bool {
1311    let resolves_git_dir =
1312        content.contains("--git-common-dir") || content.contains("git rev-parse --git-common-dir");
1313    resolves_git_dir
1314        && active_hook_lines(content)
1315            .any(|line| line_forwards_to_local_git_hook_with_args(line, hook))
1316}
1317
1318fn line_forwards_to_local_git_hook_with_args(line: &str, hook: HookName) -> bool {
1319    line.contains("$@")
1320        && (line.contains(&format!("hooks/{}", hook.as_str()))
1321            || line.contains("hooks/$name")
1322            || line.contains("hooks/${name}")
1323            || line.contains("hooks/$hook")
1324            || line.contains("hooks/${hook}"))
1325}
1326
1327fn line_invokes_truth_hook_dispatch(line: &str, expected_hook: Option<HookName>) -> bool {
1328    crate::shell::shellish_token_segments(line)
1329        .iter()
1330        .any(|tokens| token_segment_invokes_truth_hook_dispatch(tokens, expected_hook))
1331}
1332
1333fn token_segment_invokes_truth_hook_dispatch(
1334    tokens: &[&str],
1335    expected_hook: Option<HookName>,
1336) -> bool {
1337    for (index, token) in tokens.iter().enumerate() {
1338        if !is_truth_binary(token) {
1339            continue;
1340        }
1341        let Some(dispatch_offset) = tokens[index + 1..]
1342            .iter()
1343            .position(|candidate| *candidate == "hook-dispatch")
1344        else {
1345            continue;
1346        };
1347        let hook_index = index + 1 + dispatch_offset + 1;
1348        let Some(hook) = tokens.get(hook_index) else {
1349            continue;
1350        };
1351        if expected_hook.is_some_and(|expected| hook == &expected.as_str())
1352            || expected_hook.is_none() && token_is_installed_hook(hook)
1353        {
1354            return true;
1355        }
1356    }
1357    false
1358}
1359
1360fn is_truth_binary(token: &str) -> bool {
1361    token == "truth"
1362        || token == "truth-mirror"
1363        || token.ends_with("/truth")
1364        || token.ends_with("/truth-mirror")
1365}
1366
1367fn token_is_installed_hook(token: &str) -> bool {
1368    INSTALLED_HOOKS.iter().any(|hook| token == hook.as_str())
1369}
1370
1371fn backup_path(path: &Path, suffix: &str) -> PathBuf {
1372    let file_name = path
1373        .file_name()
1374        .and_then(|name| name.to_str())
1375        .unwrap_or("hook");
1376    path.with_file_name(format!("{file_name}.{suffix}"))
1377}
1378
1379fn read_to_string_if_file(path: &Path) -> Result<Option<String>> {
1380    match fs::read_to_string(path) {
1381        Ok(content) => Ok(Some(content)),
1382        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
1383        Err(error) => Err(error.into()),
1384    }
1385}
1386
1387/// Read a path as raw bytes, returning `Ok(None)` when absent.
1388fn read_bytes_if_file(path: &Path) -> Result<Option<Vec<u8>>> {
1389    match fs::read(path) {
1390        Ok(bytes) => Ok(Some(bytes)),
1391        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
1392        Err(error) => Err(error.into()),
1393    }
1394}
1395
1396fn remove_file_if_exists(path: &Path) -> Result<()> {
1397    match fs::remove_file(path) {
1398        Ok(()) => Ok(()),
1399        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
1400        Err(error) => Err(error.into()),
1401    }
1402}
1403
1404fn unset_core_hooks_path(repo_root: &Path) -> Result<()> {
1405    let status = Command::new("git")
1406        .args(["config", "--unset", "core.hooksPath"])
1407        .current_dir(repo_root)
1408        .status()?;
1409    if !status.success() {
1410        // Git exits non-zero when the key is already absent; migration should be idempotent.
1411        return Ok(());
1412    }
1413    Ok(())
1414}
1415
1416fn dispatch_commit_msg(
1417    state_dir: &Path,
1418    args: &[String],
1419    config: &crate::config::TruthMirrorConfig,
1420) -> Result<()> {
1421    let commit_msg_path = args
1422        .first()
1423        .context("commit-msg hook requires COMMIT_EDITMSG path")?;
1424    let commit_message = fs::read_to_string(commit_msg_path)?;
1425    let diff = git_stdout(&["diff", "--cached"])?;
1426    let claim_file = fs::read_to_string(state_dir.join("claim.txt")).ok();
1427    let policy = config.gates.to_policy();
1428    claim::evaluate_commit_message(&commit_message, claim_file.as_deref(), Some(&diff), &policy)?;
1429    Ok(())
1430}
1431
1432fn dispatch_post_commit(state_dir: &Path) -> Result<()> {
1433    let sha = git_stdout(&["rev-parse", "HEAD"])?;
1434    ReviewQueue::new(state_dir).enqueue(sha.trim())?;
1435    // Enqueuing a claim must be sufficient to guarantee a consumer exists: kick the
1436    // idempotent watcher lifecycle so the queue drains without a human running
1437    // `watch` by hand. Best-effort and non-fatal — a failed spawn must never fail
1438    // the commit hook, and the next enqueue (or a manual `watch`) will retry.
1439    if let Err(error) = crate::watcher::ensure_watcher(state_dir) {
1440        tracing::warn!(%error, "post-commit: failed to ensure a review watcher");
1441    }
1442    Ok(())
1443}
1444
1445fn dispatch_pre_push(
1446    state_dir: &Path,
1447    config_path: Option<&Path>,
1448    config: &crate::config::TruthMirrorConfig,
1449    args: &[String],
1450) -> Result<()> {
1451    let remote_name = pre_push_remote_name_from_args(args);
1452    let mut stdin = String::new();
1453    io::stdin().read_to_string(&mut stdin)?;
1454    for line in stdin.lines() {
1455        if let Some(range) = pre_push_range_from_line_for_remote(line, remote_name) {
1456            gate::run(
1457                cli::GateArgs {
1458                    pre_push: Some(range),
1459                    commit_msg: None,
1460                    claim_file: None,
1461                    diff_file: None,
1462                    fake_markers: Vec::new(),
1463                    pre_tool_use: false,
1464                    tool: None,
1465                },
1466                state_dir,
1467                config_path,
1468                config,
1469            )?;
1470        }
1471    }
1472    Ok(())
1473}
1474
1475fn pre_push_range_from_line_for_remote(line: &str, remote_name: Option<&str>) -> Option<String> {
1476    let parts = line.split_whitespace().collect::<Vec<_>>();
1477    let local_sha = parts.get(1)?;
1478    let remote_sha = parts.get(3)?;
1479    if is_zero_sha(local_sha) {
1480        return None;
1481    }
1482
1483    if is_zero_sha(remote_sha) {
1484        match remote_name.filter(|name| safe_pre_push_remote_name(name)) {
1485            Some(remote_name) => Some(format!("new:{remote_name}:{local_sha}")),
1486            None => Some(format!("new:{local_sha}")),
1487        }
1488    } else {
1489        Some(format!("{remote_sha}..{local_sha}"))
1490    }
1491}
1492
1493fn pre_push_remote_name_from_args(args: &[String]) -> Option<&str> {
1494    let remote_name = args.first()?.as_str();
1495    let remote_url = args.get(1).map(String::as_str);
1496    if remote_url == Some(remote_name) || !safe_pre_push_remote_name(remote_name) {
1497        return None;
1498    }
1499    Some(remote_name)
1500}
1501
1502fn safe_pre_push_remote_name(value: &str) -> bool {
1503    !value.is_empty()
1504        && !value.starts_with('-')
1505        && !value.contains(':')
1506        && !value.contains("..")
1507        && !value
1508            .chars()
1509            .any(|character| character.is_whitespace() || character.is_control())
1510        && !value
1511            .chars()
1512            .any(|character| matches!(character, '*' | '?' | '['))
1513}
1514
1515fn is_zero_sha(value: &str) -> bool {
1516    value.chars().all(|character| character == '0')
1517}
1518
1519fn run_chained_hook(state_dir: &Path, hook: HookName, args: &[String]) -> Result<()> {
1520    let chained = state_dir.join("hooks/chained").join(hook.as_str());
1521    if !chained.is_file() {
1522        return Ok(());
1523    }
1524    let content = fs::read_to_string(&chained)?;
1525    if has_outer_hook_marker(&content) {
1526        eprintln!(
1527            "{} skipping unsafe chained hook {} because it appears to invoke an outer hook manager",
1528            crate::messages::diagnostic_prefix(),
1529            chained.display()
1530        );
1531        return Ok(());
1532    }
1533
1534    let status = Command::new(&chained).args(args).status()?;
1535    if !status.success() {
1536        bail!(
1537            "chained hook {} failed with status {status}",
1538            chained.display()
1539        );
1540    }
1541    Ok(())
1542}
1543
1544fn git_root() -> Result<PathBuf> {
1545    Ok(PathBuf::from(
1546        git_stdout(&["rev-parse", "--show-toplevel"])?.trim(),
1547    ))
1548}
1549
1550fn git_stdout(args: &[&str]) -> Result<String> {
1551    let output = Command::new("git").args(args).output()?;
1552    if !output.status.success() {
1553        bail!(
1554            "git {} failed: {}",
1555            args.join(" "),
1556            String::from_utf8_lossy(&output.stderr)
1557        );
1558    }
1559    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1560}
1561
1562fn git_config_get(repo_root: &Path, key: &str) -> Result<Option<String>> {
1563    let output = Command::new("git")
1564        .args(["config", "--get", key])
1565        .current_dir(repo_root)
1566        .output()?;
1567    if output.status.success() {
1568        return Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()));
1569    }
1570    if output.status.code() == Some(1) {
1571        return Ok(None);
1572    }
1573    bail!(
1574        "git config --get {key} failed: {}",
1575        String::from_utf8_lossy(&output.stderr)
1576    );
1577}
1578
1579#[cfg(unix)]
1580fn make_executable(path: &Path) -> Result<()> {
1581    use std::os::unix::fs::PermissionsExt;
1582
1583    let mut permissions = fs::metadata(path)?.permissions();
1584    permissions.set_mode(0o755);
1585    fs::set_permissions(path, permissions)?;
1586    Ok(())
1587}
1588
1589#[cfg(not(unix))]
1590fn make_executable(_path: &Path) -> Result<()> {
1591    Ok(())
1592}
1593
1594#[cfg(test)]
1595mod tests {
1596    use std::{fs, process::Command};
1597
1598    use proptest::prelude::*;
1599
1600    use super::{HookInstallPlan, pre_push_range_from_line_for_remote, render_shim};
1601    use crate::cli::HookName;
1602
1603    #[test]
1604    fn hook_shim_is_only_exec_delegation() {
1605        let shim = render_shim(HookName::CommitMsg, "");
1606        let lines = shim.lines().collect::<Vec<_>>();
1607
1608        assert_eq!(lines.len(), 3);
1609        assert_eq!(lines[0], "#!/bin/sh");
1610        assert_eq!(lines[1], super::MANAGED_MARKER);
1611        assert_eq!(
1612            lines[2],
1613            "exec truth-mirror hook-dispatch commit-msg \"$@\""
1614        );
1615    }
1616
1617    #[test]
1618    fn quote_git_arg_escapes_shell_metacharacters() {
1619        use std::path::Path;
1620        assert_eq!(super::quote_git_arg(Path::new("/a/b.toml")), "'/a/b.toml'");
1621        // metacharacters are neutralized by single-quoting
1622        assert_eq!(
1623            super::quote_git_arg(Path::new("/a/c;$(touch x).toml")),
1624            "'/a/c;$(touch x).toml'"
1625        );
1626        // an embedded single quote is escaped as '\''
1627        assert_eq!(
1628            super::quote_git_arg(Path::new("/a/it's.toml")),
1629            "'/a/it'\\''s.toml'"
1630        );
1631    }
1632
1633    #[test]
1634    fn normalize_path_preserves_unmatched_relative_parents() {
1635        use std::path::{Path, PathBuf};
1636
1637        assert_eq!(
1638            super::normalize_path(Path::new("../hooks")),
1639            PathBuf::from("../hooks")
1640        );
1641        assert_eq!(
1642            super::normalize_path(Path::new("a/../../hooks")),
1643            PathBuf::from("../hooks")
1644        );
1645    }
1646
1647    #[test]
1648    fn hook_shim_preserves_global_args() {
1649        let shim = render_shim(HookName::PrePush, "--config /abs/enforce.toml ");
1650        let lines = shim.lines().collect::<Vec<_>>();
1651
1652        assert_eq!(lines.len(), 3, "shim stays <=3 lines + exec-only");
1653        assert_eq!(
1654            lines[2],
1655            "exec truth-mirror --config /abs/enforce.toml hook-dispatch pre-push \"$@\""
1656        );
1657    }
1658
1659    #[test]
1660    fn dry_run_plan_names_hooks_and_hooks_path() {
1661        let plan = HookInstallPlan::new(
1662            std::path::Path::new("/repo"),
1663            std::path::Path::new("/repo/.truth/hooks"),
1664            false,
1665        );
1666        let rendered = plan.render();
1667
1668        assert!(rendered.contains("commit-msg"));
1669        assert!(rendered.contains("post-commit"));
1670        assert!(rendered.contains("pre-push"));
1671        assert!(rendered.contains("stateHooks=/repo/.truth/hooks"));
1672        assert!(rendered.contains("path=/repo/.git/hooks/commit-msg"));
1673        assert!(rendered.contains("body:\n#!/bin/sh\n# truth-mirror managed hook"));
1674    }
1675
1676    #[test]
1677    fn managed_block_replace_is_idempotent() {
1678        let block = super::render_husky_block(HookName::CommitMsg, "");
1679        let original = "#!/bin/sh\necho before\n";
1680        let once = super::append_managed_block(original, &block);
1681        let twice = super::append_managed_block(&once, &block);
1682
1683        assert_eq!(twice.matches(super::MANAGED_MARKER).count(), 1);
1684        assert!(twice.contains("echo before"));
1685    }
1686
1687    #[test]
1688    fn chained_hook_safety_filter_detects_outer_tools() {
1689        assert!(super::has_outer_hook_marker(
1690            "#!/bin/sh\n# Entire CLI hooks\nentire hooks git commit-msg\n"
1691        ));
1692        assert!(super::has_outer_hook_marker(
1693            "#!/bin/sh\n# truth-mirror managed hook\nexec truth-mirror hook-dispatch commit-msg \"$@\"\n"
1694        ));
1695        assert!(super::has_outer_hook_marker(
1696            "#!/bin/sh\nexec truth hook-dispatch commit-msg \"$@\"\n"
1697        ));
1698        assert!(super::has_outer_hook_marker(
1699            "#!/bin/sh\nexec /usr/local/bin/truth hook-dispatch commit-msg \"$@\"\n"
1700        ));
1701        assert!(!super::has_outer_hook_marker(
1702            "#!/bin/sh\n# exec truth hook-dispatch commit-msg \"$@\"\n"
1703        ));
1704        assert!(!super::has_outer_hook_marker("#!/bin/sh\necho safe\n"));
1705    }
1706
1707    #[test]
1708    fn managed_detection_ignores_commented_and_unrelated_truth_lines() {
1709        assert!(!super::is_truth_mirror_managed(
1710            "#!/bin/sh\n# exec truth hook-dispatch commit-msg \"$@\"\n"
1711        ));
1712        assert!(!super::is_truth_mirror_managed(
1713            "#!/bin/sh\nexec truth unrelated \"$@\"\n"
1714        ));
1715        assert!(!super::is_truth_mirror_managed(
1716            "#!/bin/sh\nexec truth --version && hook-dispatch commit-msg \"$@\"\n"
1717        ));
1718        assert!(!super::is_truth_mirror_managed(
1719            "#!/bin/sh\nhook-dispatch commit-msg \"$@\"; exec truth --version\n"
1720        ));
1721        assert!(super::is_truth_mirror_managed(
1722            "#!/bin/sh\nexec /opt/bin/truth hook-dispatch pre-push \"$@\"\n"
1723        ));
1724    }
1725
1726    #[test]
1727    fn run_chained_hook_skips_marker_bearing_stale_hook() {
1728        let temp = tempfile::tempdir().unwrap();
1729        let chained = temp.path().join("hooks/chained/commit-msg");
1730        std::fs::create_dir_all(chained.parent().unwrap()).unwrap();
1731        std::fs::write(&chained, "#!/bin/sh\n# Entire CLI hooks\nexit 42\n").unwrap();
1732        super::make_executable(&chained).unwrap();
1733
1734        super::run_chained_hook(temp.path(), HookName::CommitMsg, &[]).unwrap();
1735    }
1736
1737    #[test]
1738    fn pre_push_line_maps_to_git_range() {
1739        let line = "refs/heads/main abc123 refs/heads/main def456";
1740
1741        assert_eq!(
1742            pre_push_range_from_line_for_remote(line, None),
1743            Some("def456..abc123".to_owned())
1744        );
1745    }
1746
1747    #[test]
1748    fn pre_push_new_branch_maps_to_new_branch_token() {
1749        let line =
1750            "refs/heads/topic abc123 refs/heads/topic 0000000000000000000000000000000000000000";
1751
1752        assert_eq!(
1753            pre_push_range_from_line_for_remote(line, None),
1754            Some("new:abc123".to_owned())
1755        );
1756    }
1757
1758    #[test]
1759    fn pre_push_new_branch_includes_safe_remote_name_when_available() {
1760        let line =
1761            "refs/heads/topic abc123 refs/heads/topic 0000000000000000000000000000000000000000";
1762
1763        assert_eq!(
1764            pre_push_range_from_line_for_remote(line, Some("origin")),
1765            Some("new:origin:abc123".to_owned())
1766        );
1767        assert_eq!(
1768            pre_push_range_from_line_for_remote(line, Some("../bad")),
1769            Some("new:abc123".to_owned())
1770        );
1771    }
1772
1773    #[test]
1774    fn pre_push_args_omit_remote_name_for_direct_path_pushes() {
1775        assert_eq!(
1776            super::pre_push_remote_name_from_args(&[
1777                "origin".to_owned(),
1778                "git@example/repo".to_owned()
1779            ]),
1780            Some("origin")
1781        );
1782        assert_eq!(
1783            super::pre_push_remote_name_from_args(&[
1784                "../repo.git".to_owned(),
1785                "../repo.git".to_owned()
1786            ]),
1787            None
1788        );
1789    }
1790
1791    #[test]
1792    fn pre_push_args_omit_glob_like_remote_names() {
1793        for remote_name in ["bad*remote", "bad?remote", "bad[remote"] {
1794            assert_eq!(
1795                super::pre_push_remote_name_from_args(&[
1796                    remote_name.to_owned(),
1797                    "git@example/repo".to_owned()
1798                ]),
1799                None
1800            );
1801        }
1802    }
1803
1804    #[test]
1805    fn legacy_truth_only_husky_hook_is_removed() {
1806        let temp = tempfile::tempdir().unwrap();
1807        let husky = temp.path().join(".husky");
1808        fs::create_dir_all(&husky).unwrap();
1809        let hook = husky.join("commit-msg");
1810        fs::write(
1811            &hook,
1812            "#!/usr/bin/env sh\nset -e\n# Truthfulness gate\nif command -v truth-mirror >/dev/null 2>&1; then truth-mirror hook-dispatch commit-msg \"$@\"; else :; fi\n",
1813        )
1814        .unwrap();
1815        super::make_executable(&hook).unwrap();
1816        super::uninstall_legacy_hook_references(temp.path()).unwrap();
1817        assert!(!hook.exists());
1818    }
1819
1820    #[test]
1821    fn legacy_truth_hook_with_entire_is_stripped_not_removed() {
1822        let temp = tempfile::tempdir().unwrap();
1823        let husky = temp.path().join(".husky");
1824        fs::create_dir_all(&husky).unwrap();
1825        let hook = husky.join("pre-push");
1826        fs::write(
1827            &hook,
1828            "#!/bin/sh\nif command -v entire >/dev/null 2>&1; then entire hooks git pre-push \"$1\"; fi\nexec truth-mirror hook-dispatch pre-push \"$@\"\n",
1829        )
1830        .unwrap();
1831        super::make_executable(&hook).unwrap();
1832        super::uninstall_legacy_hook_references(temp.path()).unwrap();
1833        let content = fs::read_to_string(&hook).unwrap();
1834        assert!(!content.contains("truth-mirror"));
1835        assert!(content.contains("entire hooks git pre-push"));
1836    }
1837
1838    #[test]
1839    fn legacy_truth_mirror_bin_pre_entire_bridge_is_rewritten_to_simple_bridge() {
1840        let temp = tempfile::tempdir().unwrap();
1841        let husky = temp.path().join(".husky");
1842        fs::create_dir_all(&husky).unwrap();
1843        let bridge = husky.join("pre-push.pre-entire");
1844        fs::write(
1845            &bridge,
1846            "#!/usr/bin/env sh\ntruth_mirror_bin=\"$(command -v truth-mirror || true)\"\n[ -n \"$truth_mirror_bin\" ] || exit 0\n\"$truth_mirror_bin\" hook-dispatch pre-push \"$@\"\n",
1847        )
1848        .unwrap();
1849        super::make_executable(&bridge).unwrap();
1850        super::uninstall_legacy_hook_references(temp.path()).unwrap();
1851        let content = fs::read_to_string(&bridge).unwrap();
1852        assert!(!content.contains("truth-mirror"));
1853        assert!(content.contains(r#"exec "$(dirname "$0")/pre-push" "$@""#));
1854    }
1855
1856    #[test]
1857    fn broken_pre_entire_bridge_is_rewritten_even_without_truth_reference() {
1858        let temp = tempfile::tempdir().unwrap();
1859        let husky = temp.path().join(".husky");
1860        fs::create_dir_all(&husky).unwrap();
1861        let bridge = husky.join("pre-push.pre-entire");
1862        // A stripped legacy bridge can end up with an empty `else` branch,
1863        // which `sh -n` rejects.
1864        fs::write(
1865            &bridge,
1866            "#!/usr/bin/env sh\nset -e\nif command -v entire >/dev/null; then\n  echo ok\nelse\nfi\n",
1867        )
1868        .unwrap();
1869        super::make_executable(&bridge).unwrap();
1870        super::uninstall_legacy_hook_references(temp.path()).unwrap();
1871        let content = fs::read_to_string(&bridge).unwrap();
1872        assert!(content.contains(r#"exec "$(dirname "$0")/pre-push" "$@""#));
1873    }
1874
1875    #[test]
1876    fn simple_pre_entire_bridge_is_preserved() {
1877        let temp = tempfile::tempdir().unwrap();
1878        let husky = temp.path().join(".husky");
1879        fs::create_dir_all(&husky).unwrap();
1880        let bridge = husky.join("commit-msg.pre-entire");
1881        fs::write(
1882            &bridge,
1883            "#!/bin/sh\nexec \"$(dirname \"$0\")/commit-msg\" \"$@\"\n",
1884        )
1885        .unwrap();
1886        super::make_executable(&bridge).unwrap();
1887        super::uninstall_legacy_hook_references(temp.path()).unwrap();
1888        let content = fs::read_to_string(&bridge).unwrap();
1889        assert!(content.contains(r#"exec "$(dirname "$0")/commit-msg" "$@""#));
1890    }
1891
1892    #[test]
1893    fn install_migrates_root_truth_md_to_state_dir() {
1894        let temp = tempfile::tempdir().unwrap();
1895        let repo_root = temp.path().join("repo");
1896        fs::create_dir_all(&repo_root).unwrap();
1897        Command::new("git")
1898            .args(["init"])
1899            .current_dir(&repo_root)
1900            .output()
1901            .unwrap();
1902        let root_truth = repo_root.join("TRUTH.md");
1903        fs::write(&root_truth, "# truth\n").unwrap();
1904        let state_dir = repo_root.join(".truth");
1905        fs::create_dir_all(&state_dir).unwrap();
1906        super::migrate_truth_md(&repo_root, &state_dir).unwrap();
1907        assert!(state_dir.join("TRUTH.md").is_file());
1908        assert!(!root_truth.exists());
1909        assert_eq!(
1910            fs::read_to_string(state_dir.join("TRUTH.md")).unwrap(),
1911            "# truth\n"
1912        );
1913    }
1914
1915    #[test]
1916    fn install_migrates_legacy_truth_mirror_md_to_state_dir() {
1917        let temp = tempfile::tempdir().unwrap();
1918        let repo_root = temp.path().join("repo");
1919        fs::create_dir_all(&repo_root).unwrap();
1920        let legacy_dir = repo_root.join(".truth-mirror");
1921        fs::create_dir_all(&legacy_dir).unwrap();
1922        fs::write(legacy_dir.join("TRUTH.md"), "# legacy truth\n").unwrap();
1923        let state_dir = repo_root.join(".truth");
1924        fs::create_dir_all(&state_dir).unwrap();
1925        super::migrate_truth_md(&repo_root, &state_dir).unwrap();
1926        assert!(state_dir.join("TRUTH.md").is_file());
1927        assert!(!legacy_dir.join("TRUTH.md").exists());
1928    }
1929
1930    proptest! {
1931        #[test]
1932        fn hook_shim_rendering_stays_tiny_exec_only(index in 0usize..3) {
1933            let hook = [HookName::CommitMsg, HookName::PostCommit, HookName::PrePush][index];
1934            let shim = render_shim(hook, "");
1935            let lines = shim.lines().collect::<Vec<_>>();
1936
1937            prop_assert_eq!(lines.len(), 3);
1938            prop_assert_eq!(lines[0], "#!/bin/sh");
1939            prop_assert_eq!(lines[1], super::MANAGED_MARKER);
1940            prop_assert!(lines[2].starts_with("exec truth-mirror hook-dispatch "));
1941            prop_assert!(lines[2].contains(hook.as_str()));
1942            prop_assert_eq!(shim.matches(super::MANAGED_MARKER).count(), 1);
1943            prop_assert_eq!(shim.matches("exec truth-mirror").count(), 1);
1944        }
1945    }
1946}