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