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/// True when the only non-comment content left is a shebang and shell options.
973/// Used after stripping legacy truth-mirror lines so that bare `set -e` lines
974/// do not keep an otherwise-empty husky hook file alive.
975fn is_effectively_empty_shell(content: &str) -> bool {
976    content
977        .lines()
978        .map(str::trim)
979        .filter(|line| !line.is_empty() && !line.starts_with('#'))
980        .all(|line| line == "#!/bin/sh" || line == "#!/usr/bin/env sh" || line.starts_with("set "))
981}
982
983#[derive(Debug)]
984struct LocalHookSource {
985    path: PathBuf,
986    content: String,
987}
988
989fn local_hook_source(active: &Path, backup: &Path) -> Result<Option<LocalHookSource>> {
990    let Some(active_content) = read_to_string_if_file(active)? else {
991        return Ok(None);
992    };
993    if is_truth_mirror_managed(&active_content) {
994        return read_to_string_if_file(backup)?.map_or(Ok(None), |content| {
995            Ok(Some(LocalHookSource {
996                path: backup.to_path_buf(),
997                content,
998            }))
999        });
1000    }
1001    Ok(Some(LocalHookSource {
1002        path: active.to_path_buf(),
1003        content: active_content,
1004    }))
1005}
1006
1007fn custom_forwarder_present(hooks_path: &Path, hook: HookName) -> bool {
1008    read_to_string_if_file(&hooks_path.join(hook.as_str()))
1009        .ok()
1010        .flatten()
1011        .is_some_and(|content| {
1012            active_hook_lines(&content).any(|line| line.contains(FORWARDER_NAME))
1013                || content_forwards_to_local_git_hook(&content, hook)
1014                || is_truth_mirror_managed(&content)
1015        })
1016}
1017
1018fn ensure_state_gitignore(repo_root: &Path, state_dir: &Path) -> Result<()> {
1019    let state_dir = absolutize(repo_root, state_dir);
1020    fs::create_dir_all(&state_dir)?;
1021    let gitignore = state_dir.join(".gitignore");
1022    let existing = read_to_string_if_file(&gitignore)?.unwrap_or_default();
1023    let content = render_state_gitignore(&existing);
1024    fs::write(&gitignore, content)?;
1025    print_tracked_runtime_hints(repo_root, &state_dir)?;
1026    Ok(())
1027}
1028
1029fn render_state_gitignore(existing: &str) -> String {
1030    let mut content = String::new();
1031    content.push_str(STATE_GITIGNORE_HEADER);
1032    content.push('\n');
1033    for line in STATE_GITIGNORE_LINES {
1034        content.push_str(line);
1035        content.push('\n');
1036    }
1037
1038    let standard = |line: &str| {
1039        let trimmed = line.trim();
1040        trimmed == STATE_GITIGNORE_HEADER || STATE_GITIGNORE_LINES.contains(&trimmed)
1041    };
1042
1043    let mut wrote_separator = false;
1044    for line in existing.lines().filter(|line| !standard(line)) {
1045        if !wrote_separator {
1046            content.push('\n');
1047            wrote_separator = true;
1048        }
1049        content.push_str(line);
1050        content.push('\n');
1051    }
1052
1053    content
1054}
1055
1056fn print_tracked_runtime_hints(repo_root: &Path, state_dir: &Path) -> Result<()> {
1057    let rel_state = state_dir.strip_prefix(repo_root).unwrap_or(state_dir);
1058    for path in [
1059        rel_state.join("ledger.jsonl"),
1060        rel_state.join("ledger.md"),
1061        rel_state.join("review-queue.jsonl"),
1062        rel_state.join("runs"),
1063        rel_state.join("tmp"),
1064        rel_state.join("logs"),
1065    ] {
1066        let output = Command::new("git")
1067            .args(["ls-files", "--"])
1068            .arg(&path)
1069            .current_dir(repo_root)
1070            .output()?;
1071        if output.status.success() {
1072            for tracked in String::from_utf8_lossy(&output.stdout)
1073                .lines()
1074                .filter(|line| !line.trim().is_empty())
1075            {
1076                eprintln!("hint: run: git rm --cached {tracked}");
1077            }
1078        }
1079    }
1080    Ok(())
1081}
1082
1083fn clear_chained_hooks(plan: &HookInstallPlan) -> Result<()> {
1084    for hook in INSTALLED_HOOKS {
1085        remove_file_if_exists(&plan.hooks_path.join("chained").join(hook.as_str()))?;
1086    }
1087    Ok(())
1088}
1089
1090fn has_outer_hook_marker(content: &str) -> bool {
1091    content.lines().take(20).any(|line| {
1092        OUTER_HOOK_MARKERS
1093            .iter()
1094            .any(|marker| line.contains(marker))
1095            || (!line.trim_start().starts_with('#') && line_invokes_truth_hook_dispatch(line, None))
1096    })
1097}
1098
1099fn is_truth_mirror_managed(content: &str) -> bool {
1100    content.contains(MANAGED_MARKER)
1101        || active_hook_lines(content).any(|line| line_invokes_truth_hook_dispatch(line, None))
1102}
1103
1104/// Markers that indicate a hook file also contains Entire CLI integration.
1105/// When removing legacy truth-mirror references we must preserve Entire.
1106const ENTIRE_MARKERS: &[&str] = &[
1107    "# Entire CLI hooks",
1108    "entire hooks git",
1109    "command -v entire",
1110];
1111
1112/// Detect content that references truth-mirror but does not carry the current
1113/// managed-block markers. Old husky installs, hand-rolled bridges, and scripts
1114/// that assign `truth_mirror_bin` all match so that uninstall can finish the
1115/// teardown that marker-based removal alone cannot.
1116fn is_legacy_truth_reference(content: &str) -> bool {
1117    content.contains("truth-mirror")
1118        || content.contains("truth_mirror_bin")
1119        || is_truth_mirror_managed(content)
1120}
1121
1122/// Remove lines that are obviously part of a legacy truth-mirror hook, while
1123/// leaving Entire or other foreign content intact.
1124fn strip_legacy_truth_lines(content: &str) -> String {
1125    let mut out = Vec::new();
1126    for line in content.lines() {
1127        let trimmed = line.trim();
1128        if trimmed.contains("truth-mirror")
1129            || trimmed.contains("truth_mirror_bin")
1130            || trimmed.starts_with("# Truthfulness gate")
1131            || trimmed.starts_with("# truth-mirror's")
1132        {
1133            continue;
1134        }
1135        out.push(line);
1136    }
1137    if out.is_empty() {
1138        String::new()
1139    } else {
1140        format!("{}\n", out.join("\n"))
1141    }
1142}
1143
1144/// Scrub legacy truth-mirror references from `.husky/`, `.git/hooks/`, and
1145/// `.truth-mirror/hooks/` files that were not written with the current managed
1146/// block markers. Files that are solely truth-mirror are removed; files that
1147/// also contain Entire integration are stripped of truth-mirror lines.
1148fn uninstall_legacy_hook_references(repo_root: &Path) -> Result<()> {
1149    let husky_dir = repo_root.join(".husky");
1150    let git_hooks = repo_root.join(".git/hooks");
1151    let legacy_hooks = repo_root
1152        .join(crate::config::LEGACY_STATE_DIR)
1153        .join("hooks");
1154
1155    for hook in INSTALLED_HOOKS {
1156        let hook_name = hook.as_str();
1157        let names: Vec<String> = vec![hook_name.to_owned(), format!("{hook_name}.pre-entire")];
1158        for dir in [&husky_dir, &git_hooks, &legacy_hooks] {
1159            for name in &names {
1160                let path = dir.join(name);
1161                let Some(content) = read_to_string_if_file(&path)? else {
1162                    continue;
1163                };
1164                if content.contains(MANAGED_MARKER) {
1165                    continue;
1166                }
1167                if !is_legacy_truth_reference(&content) {
1168                    continue;
1169                }
1170                if ENTIRE_MARKERS.iter().any(|marker| content.contains(marker)) {
1171                    let stripped = strip_legacy_truth_lines(&content);
1172                    if is_effectively_empty_shell(&stripped) {
1173                        fs::remove_file(&path)?;
1174                    } else {
1175                        fs::write(&path, stripped)?;
1176                        make_executable(&path)?;
1177                    }
1178                } else {
1179                    fs::remove_file(&path)?;
1180                }
1181            }
1182        }
1183    }
1184    Ok(())
1185}
1186
1187/// Move legacy TRUTH.md locations into the current state dir so that install
1188/// leaves the repo following the `.truth/TRUTH.md` convention.
1189fn migrate_truth_md(repo_root: &Path, state_dir: &Path) -> Result<()> {
1190    let target = state_dir.join("TRUTH.md");
1191    if target.is_file() {
1192        return Ok(());
1193    }
1194    let legacy = repo_root
1195        .join(crate::config::LEGACY_STATE_DIR)
1196        .join("TRUTH.md");
1197    if legacy.is_file() {
1198        fs::copy(&legacy, &target)?;
1199        fs::remove_file(&legacy)?;
1200        return Ok(());
1201    }
1202    let root = repo_root.join("TRUTH.md");
1203    if root.is_file() {
1204        fs::rename(&root, &target)?;
1205    }
1206    Ok(())
1207}
1208
1209fn active_hook_lines(content: &str) -> impl Iterator<Item = &str> {
1210    content
1211        .lines()
1212        .map(str::trim_start)
1213        .filter(|line| !line.trim().is_empty() && !line.starts_with('#'))
1214}
1215
1216fn content_forwards_to_local_git_hook(content: &str, hook: HookName) -> bool {
1217    let resolves_git_dir =
1218        content.contains("--git-common-dir") || content.contains("git rev-parse --git-common-dir");
1219    resolves_git_dir
1220        && active_hook_lines(content)
1221            .any(|line| line_forwards_to_local_git_hook_with_args(line, hook))
1222}
1223
1224fn line_forwards_to_local_git_hook_with_args(line: &str, hook: HookName) -> bool {
1225    line.contains("$@")
1226        && (line.contains(&format!("hooks/{}", hook.as_str()))
1227            || line.contains("hooks/$name")
1228            || line.contains("hooks/${name}")
1229            || line.contains("hooks/$hook")
1230            || line.contains("hooks/${hook}"))
1231}
1232
1233fn line_invokes_truth_hook_dispatch(line: &str, expected_hook: Option<HookName>) -> bool {
1234    crate::shell::shellish_token_segments(line)
1235        .iter()
1236        .any(|tokens| token_segment_invokes_truth_hook_dispatch(tokens, expected_hook))
1237}
1238
1239fn token_segment_invokes_truth_hook_dispatch(
1240    tokens: &[&str],
1241    expected_hook: Option<HookName>,
1242) -> bool {
1243    for (index, token) in tokens.iter().enumerate() {
1244        if !is_truth_binary(token) {
1245            continue;
1246        }
1247        let Some(dispatch_offset) = tokens[index + 1..]
1248            .iter()
1249            .position(|candidate| *candidate == "hook-dispatch")
1250        else {
1251            continue;
1252        };
1253        let hook_index = index + 1 + dispatch_offset + 1;
1254        let Some(hook) = tokens.get(hook_index) else {
1255            continue;
1256        };
1257        if expected_hook.is_some_and(|expected| hook == &expected.as_str())
1258            || expected_hook.is_none() && token_is_installed_hook(hook)
1259        {
1260            return true;
1261        }
1262    }
1263    false
1264}
1265
1266fn is_truth_binary(token: &str) -> bool {
1267    token == "truth"
1268        || token == "truth-mirror"
1269        || token.ends_with("/truth")
1270        || token.ends_with("/truth-mirror")
1271}
1272
1273fn token_is_installed_hook(token: &str) -> bool {
1274    INSTALLED_HOOKS.iter().any(|hook| token == hook.as_str())
1275}
1276
1277fn backup_path(path: &Path, suffix: &str) -> PathBuf {
1278    let file_name = path
1279        .file_name()
1280        .and_then(|name| name.to_str())
1281        .unwrap_or("hook");
1282    path.with_file_name(format!("{file_name}.{suffix}"))
1283}
1284
1285fn read_to_string_if_file(path: &Path) -> Result<Option<String>> {
1286    match fs::read_to_string(path) {
1287        Ok(content) => Ok(Some(content)),
1288        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
1289        Err(error) => Err(error.into()),
1290    }
1291}
1292
1293/// Read a path as raw bytes, returning `Ok(None)` when absent.
1294fn read_bytes_if_file(path: &Path) -> Result<Option<Vec<u8>>> {
1295    match fs::read(path) {
1296        Ok(bytes) => Ok(Some(bytes)),
1297        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
1298        Err(error) => Err(error.into()),
1299    }
1300}
1301
1302fn remove_file_if_exists(path: &Path) -> Result<()> {
1303    match fs::remove_file(path) {
1304        Ok(()) => Ok(()),
1305        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
1306        Err(error) => Err(error.into()),
1307    }
1308}
1309
1310fn unset_core_hooks_path(repo_root: &Path) -> Result<()> {
1311    let status = Command::new("git")
1312        .args(["config", "--unset", "core.hooksPath"])
1313        .current_dir(repo_root)
1314        .status()?;
1315    if !status.success() {
1316        // Git exits non-zero when the key is already absent; migration should be idempotent.
1317        return Ok(());
1318    }
1319    Ok(())
1320}
1321
1322fn dispatch_commit_msg(
1323    state_dir: &Path,
1324    args: &[String],
1325    config: &crate::config::TruthMirrorConfig,
1326) -> Result<()> {
1327    let commit_msg_path = args
1328        .first()
1329        .context("commit-msg hook requires COMMIT_EDITMSG path")?;
1330    let commit_message = fs::read_to_string(commit_msg_path)?;
1331    let diff = git_stdout(&["diff", "--cached"])?;
1332    let claim_file = fs::read_to_string(state_dir.join("claim.txt")).ok();
1333    let policy = config.gates.to_policy();
1334    claim::evaluate_commit_message(&commit_message, claim_file.as_deref(), Some(&diff), &policy)?;
1335    Ok(())
1336}
1337
1338fn dispatch_post_commit(state_dir: &Path) -> Result<()> {
1339    let sha = git_stdout(&["rev-parse", "HEAD"])?;
1340    ReviewQueue::new(state_dir).enqueue(sha.trim())?;
1341    Ok(())
1342}
1343
1344fn dispatch_pre_push(
1345    state_dir: &Path,
1346    config_path: Option<&Path>,
1347    config: &crate::config::TruthMirrorConfig,
1348    args: &[String],
1349) -> Result<()> {
1350    let remote_name = pre_push_remote_name_from_args(args);
1351    let mut stdin = String::new();
1352    io::stdin().read_to_string(&mut stdin)?;
1353    for line in stdin.lines() {
1354        if let Some(range) = pre_push_range_from_line_for_remote(line, remote_name) {
1355            gate::run(
1356                cli::GateArgs {
1357                    pre_push: Some(range),
1358                    commit_msg: None,
1359                    claim_file: None,
1360                    diff_file: None,
1361                    fake_markers: Vec::new(),
1362                    pre_tool_use: false,
1363                    tool: None,
1364                },
1365                state_dir,
1366                config_path,
1367                config,
1368            )?;
1369        }
1370    }
1371    Ok(())
1372}
1373
1374fn pre_push_range_from_line_for_remote(line: &str, remote_name: Option<&str>) -> Option<String> {
1375    let parts = line.split_whitespace().collect::<Vec<_>>();
1376    let local_sha = parts.get(1)?;
1377    let remote_sha = parts.get(3)?;
1378    if is_zero_sha(local_sha) {
1379        return None;
1380    }
1381
1382    if is_zero_sha(remote_sha) {
1383        match remote_name.filter(|name| safe_pre_push_remote_name(name)) {
1384            Some(remote_name) => Some(format!("new:{remote_name}:{local_sha}")),
1385            None => Some(format!("new:{local_sha}")),
1386        }
1387    } else {
1388        Some(format!("{remote_sha}..{local_sha}"))
1389    }
1390}
1391
1392fn pre_push_remote_name_from_args(args: &[String]) -> Option<&str> {
1393    let remote_name = args.first()?.as_str();
1394    let remote_url = args.get(1).map(String::as_str);
1395    if remote_url == Some(remote_name) || !safe_pre_push_remote_name(remote_name) {
1396        return None;
1397    }
1398    Some(remote_name)
1399}
1400
1401fn safe_pre_push_remote_name(value: &str) -> bool {
1402    !value.is_empty()
1403        && !value.starts_with('-')
1404        && !value.contains(':')
1405        && !value.contains("..")
1406        && !value
1407            .chars()
1408            .any(|character| character.is_whitespace() || character.is_control())
1409        && !value
1410            .chars()
1411            .any(|character| matches!(character, '*' | '?' | '['))
1412}
1413
1414fn is_zero_sha(value: &str) -> bool {
1415    value.chars().all(|character| character == '0')
1416}
1417
1418fn run_chained_hook(state_dir: &Path, hook: HookName, args: &[String]) -> Result<()> {
1419    let chained = state_dir.join("hooks/chained").join(hook.as_str());
1420    if !chained.is_file() {
1421        return Ok(());
1422    }
1423    let content = fs::read_to_string(&chained)?;
1424    if has_outer_hook_marker(&content) {
1425        eprintln!(
1426            "{} skipping unsafe chained hook {} because it appears to invoke an outer hook manager",
1427            crate::messages::diagnostic_prefix(),
1428            chained.display()
1429        );
1430        return Ok(());
1431    }
1432
1433    let status = Command::new(&chained).args(args).status()?;
1434    if !status.success() {
1435        bail!(
1436            "chained hook {} failed with status {status}",
1437            chained.display()
1438        );
1439    }
1440    Ok(())
1441}
1442
1443fn git_root() -> Result<PathBuf> {
1444    Ok(PathBuf::from(
1445        git_stdout(&["rev-parse", "--show-toplevel"])?.trim(),
1446    ))
1447}
1448
1449fn git_stdout(args: &[&str]) -> Result<String> {
1450    let output = Command::new("git").args(args).output()?;
1451    if !output.status.success() {
1452        bail!(
1453            "git {} failed: {}",
1454            args.join(" "),
1455            String::from_utf8_lossy(&output.stderr)
1456        );
1457    }
1458    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1459}
1460
1461fn git_config_get(repo_root: &Path, key: &str) -> Result<Option<String>> {
1462    let output = Command::new("git")
1463        .args(["config", "--get", key])
1464        .current_dir(repo_root)
1465        .output()?;
1466    if output.status.success() {
1467        return Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()));
1468    }
1469    if output.status.code() == Some(1) {
1470        return Ok(None);
1471    }
1472    bail!(
1473        "git config --get {key} failed: {}",
1474        String::from_utf8_lossy(&output.stderr)
1475    );
1476}
1477
1478#[cfg(unix)]
1479fn make_executable(path: &Path) -> Result<()> {
1480    use std::os::unix::fs::PermissionsExt;
1481
1482    let mut permissions = fs::metadata(path)?.permissions();
1483    permissions.set_mode(0o755);
1484    fs::set_permissions(path, permissions)?;
1485    Ok(())
1486}
1487
1488#[cfg(not(unix))]
1489fn make_executable(_path: &Path) -> Result<()> {
1490    Ok(())
1491}
1492
1493#[cfg(test)]
1494mod tests {
1495    use std::{fs, process::Command};
1496
1497    use proptest::prelude::*;
1498
1499    use super::{HookInstallPlan, pre_push_range_from_line_for_remote, render_shim};
1500    use crate::cli::HookName;
1501
1502    #[test]
1503    fn hook_shim_is_only_exec_delegation() {
1504        let shim = render_shim(HookName::CommitMsg, "");
1505        let lines = shim.lines().collect::<Vec<_>>();
1506
1507        assert_eq!(lines.len(), 3);
1508        assert_eq!(lines[0], "#!/bin/sh");
1509        assert_eq!(lines[1], super::MANAGED_MARKER);
1510        assert_eq!(
1511            lines[2],
1512            "exec truth-mirror hook-dispatch commit-msg \"$@\""
1513        );
1514    }
1515
1516    #[test]
1517    fn quote_git_arg_escapes_shell_metacharacters() {
1518        use std::path::Path;
1519        assert_eq!(super::quote_git_arg(Path::new("/a/b.toml")), "'/a/b.toml'");
1520        // metacharacters are neutralized by single-quoting
1521        assert_eq!(
1522            super::quote_git_arg(Path::new("/a/c;$(touch x).toml")),
1523            "'/a/c;$(touch x).toml'"
1524        );
1525        // an embedded single quote is escaped as '\''
1526        assert_eq!(
1527            super::quote_git_arg(Path::new("/a/it's.toml")),
1528            "'/a/it'\\''s.toml'"
1529        );
1530    }
1531
1532    #[test]
1533    fn normalize_path_preserves_unmatched_relative_parents() {
1534        use std::path::{Path, PathBuf};
1535
1536        assert_eq!(
1537            super::normalize_path(Path::new("../hooks")),
1538            PathBuf::from("../hooks")
1539        );
1540        assert_eq!(
1541            super::normalize_path(Path::new("a/../../hooks")),
1542            PathBuf::from("../hooks")
1543        );
1544    }
1545
1546    #[test]
1547    fn hook_shim_preserves_global_args() {
1548        let shim = render_shim(HookName::PrePush, "--config /abs/enforce.toml ");
1549        let lines = shim.lines().collect::<Vec<_>>();
1550
1551        assert_eq!(lines.len(), 3, "shim stays <=3 lines + exec-only");
1552        assert_eq!(
1553            lines[2],
1554            "exec truth-mirror --config /abs/enforce.toml hook-dispatch pre-push \"$@\""
1555        );
1556    }
1557
1558    #[test]
1559    fn dry_run_plan_names_hooks_and_hooks_path() {
1560        let plan = HookInstallPlan::new(
1561            std::path::Path::new("/repo"),
1562            std::path::Path::new("/repo/.truth/hooks"),
1563            false,
1564        );
1565        let rendered = plan.render();
1566
1567        assert!(rendered.contains("commit-msg"));
1568        assert!(rendered.contains("post-commit"));
1569        assert!(rendered.contains("pre-push"));
1570        assert!(rendered.contains("stateHooks=/repo/.truth/hooks"));
1571        assert!(rendered.contains("path=/repo/.git/hooks/commit-msg"));
1572        assert!(rendered.contains("body:\n#!/bin/sh\n# truth-mirror managed hook"));
1573    }
1574
1575    #[test]
1576    fn managed_block_replace_is_idempotent() {
1577        let block = super::render_husky_block(HookName::CommitMsg, "");
1578        let original = "#!/bin/sh\necho before\n";
1579        let once = super::append_managed_block(original, &block);
1580        let twice = super::append_managed_block(&once, &block);
1581
1582        assert_eq!(twice.matches(super::MANAGED_MARKER).count(), 1);
1583        assert!(twice.contains("echo before"));
1584    }
1585
1586    #[test]
1587    fn chained_hook_safety_filter_detects_outer_tools() {
1588        assert!(super::has_outer_hook_marker(
1589            "#!/bin/sh\n# Entire CLI hooks\nentire hooks git commit-msg\n"
1590        ));
1591        assert!(super::has_outer_hook_marker(
1592            "#!/bin/sh\n# truth-mirror managed hook\nexec truth-mirror hook-dispatch commit-msg \"$@\"\n"
1593        ));
1594        assert!(super::has_outer_hook_marker(
1595            "#!/bin/sh\nexec truth hook-dispatch commit-msg \"$@\"\n"
1596        ));
1597        assert!(super::has_outer_hook_marker(
1598            "#!/bin/sh\nexec /usr/local/bin/truth hook-dispatch commit-msg \"$@\"\n"
1599        ));
1600        assert!(!super::has_outer_hook_marker(
1601            "#!/bin/sh\n# exec truth hook-dispatch commit-msg \"$@\"\n"
1602        ));
1603        assert!(!super::has_outer_hook_marker("#!/bin/sh\necho safe\n"));
1604    }
1605
1606    #[test]
1607    fn managed_detection_ignores_commented_and_unrelated_truth_lines() {
1608        assert!(!super::is_truth_mirror_managed(
1609            "#!/bin/sh\n# exec truth hook-dispatch commit-msg \"$@\"\n"
1610        ));
1611        assert!(!super::is_truth_mirror_managed(
1612            "#!/bin/sh\nexec truth unrelated \"$@\"\n"
1613        ));
1614        assert!(!super::is_truth_mirror_managed(
1615            "#!/bin/sh\nexec truth --version && hook-dispatch commit-msg \"$@\"\n"
1616        ));
1617        assert!(!super::is_truth_mirror_managed(
1618            "#!/bin/sh\nhook-dispatch commit-msg \"$@\"; exec truth --version\n"
1619        ));
1620        assert!(super::is_truth_mirror_managed(
1621            "#!/bin/sh\nexec /opt/bin/truth hook-dispatch pre-push \"$@\"\n"
1622        ));
1623    }
1624
1625    #[test]
1626    fn run_chained_hook_skips_marker_bearing_stale_hook() {
1627        let temp = tempfile::tempdir().unwrap();
1628        let chained = temp.path().join("hooks/chained/commit-msg");
1629        std::fs::create_dir_all(chained.parent().unwrap()).unwrap();
1630        std::fs::write(&chained, "#!/bin/sh\n# Entire CLI hooks\nexit 42\n").unwrap();
1631        super::make_executable(&chained).unwrap();
1632
1633        super::run_chained_hook(temp.path(), HookName::CommitMsg, &[]).unwrap();
1634    }
1635
1636    #[test]
1637    fn pre_push_line_maps_to_git_range() {
1638        let line = "refs/heads/main abc123 refs/heads/main def456";
1639
1640        assert_eq!(
1641            pre_push_range_from_line_for_remote(line, None),
1642            Some("def456..abc123".to_owned())
1643        );
1644    }
1645
1646    #[test]
1647    fn pre_push_new_branch_maps_to_new_branch_token() {
1648        let line =
1649            "refs/heads/topic abc123 refs/heads/topic 0000000000000000000000000000000000000000";
1650
1651        assert_eq!(
1652            pre_push_range_from_line_for_remote(line, None),
1653            Some("new:abc123".to_owned())
1654        );
1655    }
1656
1657    #[test]
1658    fn pre_push_new_branch_includes_safe_remote_name_when_available() {
1659        let line =
1660            "refs/heads/topic abc123 refs/heads/topic 0000000000000000000000000000000000000000";
1661
1662        assert_eq!(
1663            pre_push_range_from_line_for_remote(line, Some("origin")),
1664            Some("new:origin:abc123".to_owned())
1665        );
1666        assert_eq!(
1667            pre_push_range_from_line_for_remote(line, Some("../bad")),
1668            Some("new:abc123".to_owned())
1669        );
1670    }
1671
1672    #[test]
1673    fn pre_push_args_omit_remote_name_for_direct_path_pushes() {
1674        assert_eq!(
1675            super::pre_push_remote_name_from_args(&[
1676                "origin".to_owned(),
1677                "git@example/repo".to_owned()
1678            ]),
1679            Some("origin")
1680        );
1681        assert_eq!(
1682            super::pre_push_remote_name_from_args(&[
1683                "../repo.git".to_owned(),
1684                "../repo.git".to_owned()
1685            ]),
1686            None
1687        );
1688    }
1689
1690    #[test]
1691    fn pre_push_args_omit_glob_like_remote_names() {
1692        for remote_name in ["bad*remote", "bad?remote", "bad[remote"] {
1693            assert_eq!(
1694                super::pre_push_remote_name_from_args(&[
1695                    remote_name.to_owned(),
1696                    "git@example/repo".to_owned()
1697                ]),
1698                None
1699            );
1700        }
1701    }
1702
1703    #[test]
1704    fn legacy_truth_only_husky_hook_is_removed() {
1705        let temp = tempfile::tempdir().unwrap();
1706        let husky = temp.path().join(".husky");
1707        fs::create_dir_all(&husky).unwrap();
1708        let hook = husky.join("commit-msg");
1709        fs::write(
1710            &hook,
1711            "#!/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",
1712        )
1713        .unwrap();
1714        super::make_executable(&hook).unwrap();
1715        super::uninstall_legacy_hook_references(temp.path()).unwrap();
1716        assert!(!hook.exists());
1717    }
1718
1719    #[test]
1720    fn legacy_truth_hook_with_entire_is_stripped_not_removed() {
1721        let temp = tempfile::tempdir().unwrap();
1722        let husky = temp.path().join(".husky");
1723        fs::create_dir_all(&husky).unwrap();
1724        let hook = husky.join("pre-push");
1725        fs::write(
1726            &hook,
1727            "#!/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",
1728        )
1729        .unwrap();
1730        super::make_executable(&hook).unwrap();
1731        super::uninstall_legacy_hook_references(temp.path()).unwrap();
1732        let content = fs::read_to_string(&hook).unwrap();
1733        assert!(!content.contains("truth-mirror"));
1734        assert!(content.contains("entire hooks git pre-push"));
1735    }
1736
1737    #[test]
1738    fn legacy_truth_mirror_bin_pre_entire_bridge_is_removed() {
1739        let temp = tempfile::tempdir().unwrap();
1740        let husky = temp.path().join(".husky");
1741        fs::create_dir_all(&husky).unwrap();
1742        let bridge = husky.join("pre-push.pre-entire");
1743        fs::write(
1744            &bridge,
1745            "#!/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",
1746        )
1747        .unwrap();
1748        super::make_executable(&bridge).unwrap();
1749        super::uninstall_legacy_hook_references(temp.path()).unwrap();
1750        assert!(!bridge.exists());
1751    }
1752
1753    #[test]
1754    fn install_migrates_root_truth_md_to_state_dir() {
1755        let temp = tempfile::tempdir().unwrap();
1756        let repo_root = temp.path().join("repo");
1757        fs::create_dir_all(&repo_root).unwrap();
1758        Command::new("git")
1759            .args(["init"])
1760            .current_dir(&repo_root)
1761            .output()
1762            .unwrap();
1763        let root_truth = repo_root.join("TRUTH.md");
1764        fs::write(&root_truth, "# truth\n").unwrap();
1765        let state_dir = repo_root.join(".truth");
1766        fs::create_dir_all(&state_dir).unwrap();
1767        super::migrate_truth_md(&repo_root, &state_dir).unwrap();
1768        assert!(state_dir.join("TRUTH.md").is_file());
1769        assert!(!root_truth.exists());
1770        assert_eq!(
1771            fs::read_to_string(state_dir.join("TRUTH.md")).unwrap(),
1772            "# truth\n"
1773        );
1774    }
1775
1776    #[test]
1777    fn install_migrates_legacy_truth_mirror_md_to_state_dir() {
1778        let temp = tempfile::tempdir().unwrap();
1779        let repo_root = temp.path().join("repo");
1780        fs::create_dir_all(&repo_root).unwrap();
1781        let legacy_dir = repo_root.join(".truth-mirror");
1782        fs::create_dir_all(&legacy_dir).unwrap();
1783        fs::write(legacy_dir.join("TRUTH.md"), "# legacy truth\n").unwrap();
1784        let state_dir = repo_root.join(".truth");
1785        fs::create_dir_all(&state_dir).unwrap();
1786        super::migrate_truth_md(&repo_root, &state_dir).unwrap();
1787        assert!(state_dir.join("TRUTH.md").is_file());
1788        assert!(!legacy_dir.join("TRUTH.md").exists());
1789    }
1790
1791    proptest! {
1792        #[test]
1793        fn hook_shim_rendering_stays_tiny_exec_only(index in 0usize..3) {
1794            let hook = [HookName::CommitMsg, HookName::PostCommit, HookName::PrePush][index];
1795            let shim = render_shim(hook, "");
1796            let lines = shim.lines().collect::<Vec<_>>();
1797
1798            prop_assert_eq!(lines.len(), 3);
1799            prop_assert_eq!(lines[0], "#!/bin/sh");
1800            prop_assert_eq!(lines[1], super::MANAGED_MARKER);
1801            prop_assert!(lines[2].starts_with("exec truth-mirror hook-dispatch "));
1802            prop_assert!(lines[2].contains(hook.as_str()));
1803            prop_assert_eq!(shim.matches(super::MANAGED_MARKER).count(), 1);
1804            prop_assert_eq!(shim.matches("exec truth-mirror").count(), 1);
1805        }
1806    }
1807}