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