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/// True when a `.pre-entire` file is already the simple Entire→husky bridge
1133/// that install/uninstall want to preserve.
1134fn is_simple_pre_entire_bridge(content: &str, hook_name: &str) -> bool {
1135    // Normalize whitespace so hand-formatted variants still match.
1136    let normalized: String = content
1137        .lines()
1138        .map(str::trim)
1139        .filter(|line| !line.is_empty() && !line.starts_with('#'))
1140        .collect::<Vec<_>>()
1141        .join(" ");
1142    normalized == format!(r#"exec "$(dirname "$0")/{hook_name}" "$@""#)
1143}
1144
1145/// Remove lines that are obviously part of a legacy truth-mirror hook, while
1146/// leaving Entire or other foreign content intact.
1147fn strip_legacy_truth_lines(content: &str) -> String {
1148    let mut out = Vec::new();
1149    for line in content.lines() {
1150        let trimmed = line.trim();
1151        if trimmed.contains("truth-mirror")
1152            || trimmed.contains("truth_mirror_bin")
1153            || trimmed.starts_with("# Truthfulness gate")
1154            || trimmed.starts_with("# truth-mirror's")
1155        {
1156            continue;
1157        }
1158        out.push(line);
1159    }
1160    if out.is_empty() {
1161        String::new()
1162    } else {
1163        format!("{}\n", out.join("\n"))
1164    }
1165}
1166
1167/// Scrub legacy truth-mirror references from `.husky/`, `.git/hooks/`, and
1168/// `.truth-mirror/hooks/` files that were not written with the current managed
1169/// block markers. Files that are solely truth-mirror are removed; files that
1170/// also contain Entire integration are stripped of truth-mirror lines.
1171///
1172/// `.pre-entire` bridges created by the husky+Entire+truth-mirror chain are
1173/// rewritten to a simple bridge so Entire keeps forwarding to the husky hook.
1174fn uninstall_legacy_hook_references(repo_root: &Path) -> Result<()> {
1175    let husky_dir = repo_root.join(".husky");
1176    let git_hooks = repo_root.join(".git/hooks");
1177    let legacy_hooks = repo_root
1178        .join(crate::config::LEGACY_STATE_DIR)
1179        .join("hooks");
1180
1181    for hook in INSTALLED_HOOKS {
1182        let hook_name = hook.as_str();
1183        let names: Vec<String> = vec![hook_name.to_owned(), format!("{hook_name}.pre-entire")];
1184        for dir in [&husky_dir, &git_hooks, &legacy_hooks] {
1185            for name in &names {
1186                let path = dir.join(name);
1187                let Some(content) = read_to_string_if_file(&path)? else {
1188                    continue;
1189                };
1190                if content.contains(MANAGED_MARKER) {
1191                    continue;
1192                }
1193                if name.ends_with(".pre-entire") {
1194                    if is_simple_pre_entire_bridge(&content, hook_name) {
1195                        continue;
1196                    }
1197                    if is_legacy_truth_reference(&content) || !shell_syntax_ok(&path) {
1198                        // Keep the Entire forwarding bridge but drop any custom
1199                        // truth-mirror logic that may leave broken `else` blocks.
1200                        let bridge =
1201                            format!("#!/bin/sh\nexec \"$(dirname \"$0\")/{hook_name}\" \"$@\"\n");
1202                        fs::write(&path, bridge)?;
1203                        make_executable(&path)?;
1204                    }
1205                    continue;
1206                }
1207                if !is_legacy_truth_reference(&content) {
1208                    continue;
1209                }
1210                if ENTIRE_MARKERS.iter().any(|marker| content.contains(marker)) {
1211                    let stripped = strip_legacy_truth_lines(&content);
1212                    if is_effectively_empty_shell(&stripped) {
1213                        fs::remove_file(&path)?;
1214                    } else {
1215                        fs::write(&path, stripped)?;
1216                        make_executable(&path)?;
1217                    }
1218                } else {
1219                    fs::remove_file(&path)?;
1220                }
1221            }
1222        }
1223    }
1224    Ok(())
1225}
1226
1227/// Move legacy TRUTH.md locations into the current state dir so that install
1228/// leaves the repo following the `.truth/TRUTH.md` convention.
1229fn migrate_truth_md(repo_root: &Path, state_dir: &Path) -> Result<()> {
1230    let target = state_dir.join("TRUTH.md");
1231    if target.is_file() {
1232        return Ok(());
1233    }
1234    let legacy = repo_root
1235        .join(crate::config::LEGACY_STATE_DIR)
1236        .join("TRUTH.md");
1237    if legacy.is_file() {
1238        fs::copy(&legacy, &target)?;
1239        fs::remove_file(&legacy)?;
1240        return Ok(());
1241    }
1242    let root = repo_root.join("TRUTH.md");
1243    if root.is_file() {
1244        fs::rename(&root, &target)?;
1245    }
1246    Ok(())
1247}
1248
1249fn active_hook_lines(content: &str) -> impl Iterator<Item = &str> {
1250    content
1251        .lines()
1252        .map(str::trim_start)
1253        .filter(|line| !line.trim().is_empty() && !line.starts_with('#'))
1254}
1255
1256fn content_forwards_to_local_git_hook(content: &str, hook: HookName) -> bool {
1257    let resolves_git_dir =
1258        content.contains("--git-common-dir") || content.contains("git rev-parse --git-common-dir");
1259    resolves_git_dir
1260        && active_hook_lines(content)
1261            .any(|line| line_forwards_to_local_git_hook_with_args(line, hook))
1262}
1263
1264fn line_forwards_to_local_git_hook_with_args(line: &str, hook: HookName) -> bool {
1265    line.contains("$@")
1266        && (line.contains(&format!("hooks/{}", hook.as_str()))
1267            || line.contains("hooks/$name")
1268            || line.contains("hooks/${name}")
1269            || line.contains("hooks/$hook")
1270            || line.contains("hooks/${hook}"))
1271}
1272
1273fn line_invokes_truth_hook_dispatch(line: &str, expected_hook: Option<HookName>) -> bool {
1274    crate::shell::shellish_token_segments(line)
1275        .iter()
1276        .any(|tokens| token_segment_invokes_truth_hook_dispatch(tokens, expected_hook))
1277}
1278
1279fn token_segment_invokes_truth_hook_dispatch(
1280    tokens: &[&str],
1281    expected_hook: Option<HookName>,
1282) -> bool {
1283    for (index, token) in tokens.iter().enumerate() {
1284        if !is_truth_binary(token) {
1285            continue;
1286        }
1287        let Some(dispatch_offset) = tokens[index + 1..]
1288            .iter()
1289            .position(|candidate| *candidate == "hook-dispatch")
1290        else {
1291            continue;
1292        };
1293        let hook_index = index + 1 + dispatch_offset + 1;
1294        let Some(hook) = tokens.get(hook_index) else {
1295            continue;
1296        };
1297        if expected_hook.is_some_and(|expected| hook == &expected.as_str())
1298            || expected_hook.is_none() && token_is_installed_hook(hook)
1299        {
1300            return true;
1301        }
1302    }
1303    false
1304}
1305
1306fn is_truth_binary(token: &str) -> bool {
1307    token == "truth"
1308        || token == "truth-mirror"
1309        || token.ends_with("/truth")
1310        || token.ends_with("/truth-mirror")
1311}
1312
1313fn token_is_installed_hook(token: &str) -> bool {
1314    INSTALLED_HOOKS.iter().any(|hook| token == hook.as_str())
1315}
1316
1317fn backup_path(path: &Path, suffix: &str) -> PathBuf {
1318    let file_name = path
1319        .file_name()
1320        .and_then(|name| name.to_str())
1321        .unwrap_or("hook");
1322    path.with_file_name(format!("{file_name}.{suffix}"))
1323}
1324
1325fn read_to_string_if_file(path: &Path) -> Result<Option<String>> {
1326    match fs::read_to_string(path) {
1327        Ok(content) => Ok(Some(content)),
1328        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
1329        Err(error) => Err(error.into()),
1330    }
1331}
1332
1333/// Read a path as raw bytes, returning `Ok(None)` when absent.
1334fn read_bytes_if_file(path: &Path) -> Result<Option<Vec<u8>>> {
1335    match fs::read(path) {
1336        Ok(bytes) => Ok(Some(bytes)),
1337        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
1338        Err(error) => Err(error.into()),
1339    }
1340}
1341
1342fn remove_file_if_exists(path: &Path) -> Result<()> {
1343    match fs::remove_file(path) {
1344        Ok(()) => Ok(()),
1345        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
1346        Err(error) => Err(error.into()),
1347    }
1348}
1349
1350fn unset_core_hooks_path(repo_root: &Path) -> Result<()> {
1351    let status = Command::new("git")
1352        .args(["config", "--unset", "core.hooksPath"])
1353        .current_dir(repo_root)
1354        .status()?;
1355    if !status.success() {
1356        // Git exits non-zero when the key is already absent; migration should be idempotent.
1357        return Ok(());
1358    }
1359    Ok(())
1360}
1361
1362fn dispatch_commit_msg(
1363    state_dir: &Path,
1364    args: &[String],
1365    config: &crate::config::TruthMirrorConfig,
1366) -> Result<()> {
1367    let commit_msg_path = args
1368        .first()
1369        .context("commit-msg hook requires COMMIT_EDITMSG path")?;
1370    let commit_message = fs::read_to_string(commit_msg_path)?;
1371    let diff = git_stdout(&["diff", "--cached"])?;
1372    let claim_file = fs::read_to_string(state_dir.join("claim.txt")).ok();
1373    let policy = config.gates.to_policy();
1374    claim::evaluate_commit_message(&commit_message, claim_file.as_deref(), Some(&diff), &policy)?;
1375    Ok(())
1376}
1377
1378fn dispatch_post_commit(state_dir: &Path) -> Result<()> {
1379    let sha = git_stdout(&["rev-parse", "HEAD"])?;
1380    ReviewQueue::new(state_dir).enqueue(sha.trim())?;
1381    Ok(())
1382}
1383
1384fn dispatch_pre_push(
1385    state_dir: &Path,
1386    config_path: Option<&Path>,
1387    config: &crate::config::TruthMirrorConfig,
1388    args: &[String],
1389) -> Result<()> {
1390    let remote_name = pre_push_remote_name_from_args(args);
1391    let mut stdin = String::new();
1392    io::stdin().read_to_string(&mut stdin)?;
1393    for line in stdin.lines() {
1394        if let Some(range) = pre_push_range_from_line_for_remote(line, remote_name) {
1395            gate::run(
1396                cli::GateArgs {
1397                    pre_push: Some(range),
1398                    commit_msg: None,
1399                    claim_file: None,
1400                    diff_file: None,
1401                    fake_markers: Vec::new(),
1402                    pre_tool_use: false,
1403                    tool: None,
1404                },
1405                state_dir,
1406                config_path,
1407                config,
1408            )?;
1409        }
1410    }
1411    Ok(())
1412}
1413
1414fn pre_push_range_from_line_for_remote(line: &str, remote_name: Option<&str>) -> Option<String> {
1415    let parts = line.split_whitespace().collect::<Vec<_>>();
1416    let local_sha = parts.get(1)?;
1417    let remote_sha = parts.get(3)?;
1418    if is_zero_sha(local_sha) {
1419        return None;
1420    }
1421
1422    if is_zero_sha(remote_sha) {
1423        match remote_name.filter(|name| safe_pre_push_remote_name(name)) {
1424            Some(remote_name) => Some(format!("new:{remote_name}:{local_sha}")),
1425            None => Some(format!("new:{local_sha}")),
1426        }
1427    } else {
1428        Some(format!("{remote_sha}..{local_sha}"))
1429    }
1430}
1431
1432fn pre_push_remote_name_from_args(args: &[String]) -> Option<&str> {
1433    let remote_name = args.first()?.as_str();
1434    let remote_url = args.get(1).map(String::as_str);
1435    if remote_url == Some(remote_name) || !safe_pre_push_remote_name(remote_name) {
1436        return None;
1437    }
1438    Some(remote_name)
1439}
1440
1441fn safe_pre_push_remote_name(value: &str) -> bool {
1442    !value.is_empty()
1443        && !value.starts_with('-')
1444        && !value.contains(':')
1445        && !value.contains("..")
1446        && !value
1447            .chars()
1448            .any(|character| character.is_whitespace() || character.is_control())
1449        && !value
1450            .chars()
1451            .any(|character| matches!(character, '*' | '?' | '['))
1452}
1453
1454fn is_zero_sha(value: &str) -> bool {
1455    value.chars().all(|character| character == '0')
1456}
1457
1458fn run_chained_hook(state_dir: &Path, hook: HookName, args: &[String]) -> Result<()> {
1459    let chained = state_dir.join("hooks/chained").join(hook.as_str());
1460    if !chained.is_file() {
1461        return Ok(());
1462    }
1463    let content = fs::read_to_string(&chained)?;
1464    if has_outer_hook_marker(&content) {
1465        eprintln!(
1466            "{} skipping unsafe chained hook {} because it appears to invoke an outer hook manager",
1467            crate::messages::diagnostic_prefix(),
1468            chained.display()
1469        );
1470        return Ok(());
1471    }
1472
1473    let status = Command::new(&chained).args(args).status()?;
1474    if !status.success() {
1475        bail!(
1476            "chained hook {} failed with status {status}",
1477            chained.display()
1478        );
1479    }
1480    Ok(())
1481}
1482
1483fn git_root() -> Result<PathBuf> {
1484    Ok(PathBuf::from(
1485        git_stdout(&["rev-parse", "--show-toplevel"])?.trim(),
1486    ))
1487}
1488
1489fn git_stdout(args: &[&str]) -> Result<String> {
1490    let output = Command::new("git").args(args).output()?;
1491    if !output.status.success() {
1492        bail!(
1493            "git {} failed: {}",
1494            args.join(" "),
1495            String::from_utf8_lossy(&output.stderr)
1496        );
1497    }
1498    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1499}
1500
1501fn git_config_get(repo_root: &Path, key: &str) -> Result<Option<String>> {
1502    let output = Command::new("git")
1503        .args(["config", "--get", key])
1504        .current_dir(repo_root)
1505        .output()?;
1506    if output.status.success() {
1507        return Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()));
1508    }
1509    if output.status.code() == Some(1) {
1510        return Ok(None);
1511    }
1512    bail!(
1513        "git config --get {key} failed: {}",
1514        String::from_utf8_lossy(&output.stderr)
1515    );
1516}
1517
1518#[cfg(unix)]
1519fn make_executable(path: &Path) -> Result<()> {
1520    use std::os::unix::fs::PermissionsExt;
1521
1522    let mut permissions = fs::metadata(path)?.permissions();
1523    permissions.set_mode(0o755);
1524    fs::set_permissions(path, permissions)?;
1525    Ok(())
1526}
1527
1528#[cfg(not(unix))]
1529fn make_executable(_path: &Path) -> Result<()> {
1530    Ok(())
1531}
1532
1533#[cfg(test)]
1534mod tests {
1535    use std::{fs, process::Command};
1536
1537    use proptest::prelude::*;
1538
1539    use super::{HookInstallPlan, pre_push_range_from_line_for_remote, render_shim};
1540    use crate::cli::HookName;
1541
1542    #[test]
1543    fn hook_shim_is_only_exec_delegation() {
1544        let shim = render_shim(HookName::CommitMsg, "");
1545        let lines = shim.lines().collect::<Vec<_>>();
1546
1547        assert_eq!(lines.len(), 3);
1548        assert_eq!(lines[0], "#!/bin/sh");
1549        assert_eq!(lines[1], super::MANAGED_MARKER);
1550        assert_eq!(
1551            lines[2],
1552            "exec truth-mirror hook-dispatch commit-msg \"$@\""
1553        );
1554    }
1555
1556    #[test]
1557    fn quote_git_arg_escapes_shell_metacharacters() {
1558        use std::path::Path;
1559        assert_eq!(super::quote_git_arg(Path::new("/a/b.toml")), "'/a/b.toml'");
1560        // metacharacters are neutralized by single-quoting
1561        assert_eq!(
1562            super::quote_git_arg(Path::new("/a/c;$(touch x).toml")),
1563            "'/a/c;$(touch x).toml'"
1564        );
1565        // an embedded single quote is escaped as '\''
1566        assert_eq!(
1567            super::quote_git_arg(Path::new("/a/it's.toml")),
1568            "'/a/it'\\''s.toml'"
1569        );
1570    }
1571
1572    #[test]
1573    fn normalize_path_preserves_unmatched_relative_parents() {
1574        use std::path::{Path, PathBuf};
1575
1576        assert_eq!(
1577            super::normalize_path(Path::new("../hooks")),
1578            PathBuf::from("../hooks")
1579        );
1580        assert_eq!(
1581            super::normalize_path(Path::new("a/../../hooks")),
1582            PathBuf::from("../hooks")
1583        );
1584    }
1585
1586    #[test]
1587    fn hook_shim_preserves_global_args() {
1588        let shim = render_shim(HookName::PrePush, "--config /abs/enforce.toml ");
1589        let lines = shim.lines().collect::<Vec<_>>();
1590
1591        assert_eq!(lines.len(), 3, "shim stays <=3 lines + exec-only");
1592        assert_eq!(
1593            lines[2],
1594            "exec truth-mirror --config /abs/enforce.toml hook-dispatch pre-push \"$@\""
1595        );
1596    }
1597
1598    #[test]
1599    fn dry_run_plan_names_hooks_and_hooks_path() {
1600        let plan = HookInstallPlan::new(
1601            std::path::Path::new("/repo"),
1602            std::path::Path::new("/repo/.truth/hooks"),
1603            false,
1604        );
1605        let rendered = plan.render();
1606
1607        assert!(rendered.contains("commit-msg"));
1608        assert!(rendered.contains("post-commit"));
1609        assert!(rendered.contains("pre-push"));
1610        assert!(rendered.contains("stateHooks=/repo/.truth/hooks"));
1611        assert!(rendered.contains("path=/repo/.git/hooks/commit-msg"));
1612        assert!(rendered.contains("body:\n#!/bin/sh\n# truth-mirror managed hook"));
1613    }
1614
1615    #[test]
1616    fn managed_block_replace_is_idempotent() {
1617        let block = super::render_husky_block(HookName::CommitMsg, "");
1618        let original = "#!/bin/sh\necho before\n";
1619        let once = super::append_managed_block(original, &block);
1620        let twice = super::append_managed_block(&once, &block);
1621
1622        assert_eq!(twice.matches(super::MANAGED_MARKER).count(), 1);
1623        assert!(twice.contains("echo before"));
1624    }
1625
1626    #[test]
1627    fn chained_hook_safety_filter_detects_outer_tools() {
1628        assert!(super::has_outer_hook_marker(
1629            "#!/bin/sh\n# Entire CLI hooks\nentire hooks git commit-msg\n"
1630        ));
1631        assert!(super::has_outer_hook_marker(
1632            "#!/bin/sh\n# truth-mirror managed hook\nexec truth-mirror hook-dispatch commit-msg \"$@\"\n"
1633        ));
1634        assert!(super::has_outer_hook_marker(
1635            "#!/bin/sh\nexec truth hook-dispatch commit-msg \"$@\"\n"
1636        ));
1637        assert!(super::has_outer_hook_marker(
1638            "#!/bin/sh\nexec /usr/local/bin/truth hook-dispatch commit-msg \"$@\"\n"
1639        ));
1640        assert!(!super::has_outer_hook_marker(
1641            "#!/bin/sh\n# exec truth hook-dispatch commit-msg \"$@\"\n"
1642        ));
1643        assert!(!super::has_outer_hook_marker("#!/bin/sh\necho safe\n"));
1644    }
1645
1646    #[test]
1647    fn managed_detection_ignores_commented_and_unrelated_truth_lines() {
1648        assert!(!super::is_truth_mirror_managed(
1649            "#!/bin/sh\n# exec truth hook-dispatch commit-msg \"$@\"\n"
1650        ));
1651        assert!(!super::is_truth_mirror_managed(
1652            "#!/bin/sh\nexec truth unrelated \"$@\"\n"
1653        ));
1654        assert!(!super::is_truth_mirror_managed(
1655            "#!/bin/sh\nexec truth --version && hook-dispatch commit-msg \"$@\"\n"
1656        ));
1657        assert!(!super::is_truth_mirror_managed(
1658            "#!/bin/sh\nhook-dispatch commit-msg \"$@\"; exec truth --version\n"
1659        ));
1660        assert!(super::is_truth_mirror_managed(
1661            "#!/bin/sh\nexec /opt/bin/truth hook-dispatch pre-push \"$@\"\n"
1662        ));
1663    }
1664
1665    #[test]
1666    fn run_chained_hook_skips_marker_bearing_stale_hook() {
1667        let temp = tempfile::tempdir().unwrap();
1668        let chained = temp.path().join("hooks/chained/commit-msg");
1669        std::fs::create_dir_all(chained.parent().unwrap()).unwrap();
1670        std::fs::write(&chained, "#!/bin/sh\n# Entire CLI hooks\nexit 42\n").unwrap();
1671        super::make_executable(&chained).unwrap();
1672
1673        super::run_chained_hook(temp.path(), HookName::CommitMsg, &[]).unwrap();
1674    }
1675
1676    #[test]
1677    fn pre_push_line_maps_to_git_range() {
1678        let line = "refs/heads/main abc123 refs/heads/main def456";
1679
1680        assert_eq!(
1681            pre_push_range_from_line_for_remote(line, None),
1682            Some("def456..abc123".to_owned())
1683        );
1684    }
1685
1686    #[test]
1687    fn pre_push_new_branch_maps_to_new_branch_token() {
1688        let line =
1689            "refs/heads/topic abc123 refs/heads/topic 0000000000000000000000000000000000000000";
1690
1691        assert_eq!(
1692            pre_push_range_from_line_for_remote(line, None),
1693            Some("new:abc123".to_owned())
1694        );
1695    }
1696
1697    #[test]
1698    fn pre_push_new_branch_includes_safe_remote_name_when_available() {
1699        let line =
1700            "refs/heads/topic abc123 refs/heads/topic 0000000000000000000000000000000000000000";
1701
1702        assert_eq!(
1703            pre_push_range_from_line_for_remote(line, Some("origin")),
1704            Some("new:origin:abc123".to_owned())
1705        );
1706        assert_eq!(
1707            pre_push_range_from_line_for_remote(line, Some("../bad")),
1708            Some("new:abc123".to_owned())
1709        );
1710    }
1711
1712    #[test]
1713    fn pre_push_args_omit_remote_name_for_direct_path_pushes() {
1714        assert_eq!(
1715            super::pre_push_remote_name_from_args(&[
1716                "origin".to_owned(),
1717                "git@example/repo".to_owned()
1718            ]),
1719            Some("origin")
1720        );
1721        assert_eq!(
1722            super::pre_push_remote_name_from_args(&[
1723                "../repo.git".to_owned(),
1724                "../repo.git".to_owned()
1725            ]),
1726            None
1727        );
1728    }
1729
1730    #[test]
1731    fn pre_push_args_omit_glob_like_remote_names() {
1732        for remote_name in ["bad*remote", "bad?remote", "bad[remote"] {
1733            assert_eq!(
1734                super::pre_push_remote_name_from_args(&[
1735                    remote_name.to_owned(),
1736                    "git@example/repo".to_owned()
1737                ]),
1738                None
1739            );
1740        }
1741    }
1742
1743    #[test]
1744    fn legacy_truth_only_husky_hook_is_removed() {
1745        let temp = tempfile::tempdir().unwrap();
1746        let husky = temp.path().join(".husky");
1747        fs::create_dir_all(&husky).unwrap();
1748        let hook = husky.join("commit-msg");
1749        fs::write(
1750            &hook,
1751            "#!/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",
1752        )
1753        .unwrap();
1754        super::make_executable(&hook).unwrap();
1755        super::uninstall_legacy_hook_references(temp.path()).unwrap();
1756        assert!(!hook.exists());
1757    }
1758
1759    #[test]
1760    fn legacy_truth_hook_with_entire_is_stripped_not_removed() {
1761        let temp = tempfile::tempdir().unwrap();
1762        let husky = temp.path().join(".husky");
1763        fs::create_dir_all(&husky).unwrap();
1764        let hook = husky.join("pre-push");
1765        fs::write(
1766            &hook,
1767            "#!/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",
1768        )
1769        .unwrap();
1770        super::make_executable(&hook).unwrap();
1771        super::uninstall_legacy_hook_references(temp.path()).unwrap();
1772        let content = fs::read_to_string(&hook).unwrap();
1773        assert!(!content.contains("truth-mirror"));
1774        assert!(content.contains("entire hooks git pre-push"));
1775    }
1776
1777    #[test]
1778    fn legacy_truth_mirror_bin_pre_entire_bridge_is_rewritten_to_simple_bridge() {
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        fs::write(
1784            &bridge,
1785            "#!/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",
1786        )
1787        .unwrap();
1788        super::make_executable(&bridge).unwrap();
1789        super::uninstall_legacy_hook_references(temp.path()).unwrap();
1790        let content = fs::read_to_string(&bridge).unwrap();
1791        assert!(!content.contains("truth-mirror"));
1792        assert!(content.contains(r#"exec "$(dirname "$0")/pre-push" "$@""#));
1793    }
1794
1795    #[test]
1796    fn broken_pre_entire_bridge_is_rewritten_even_without_truth_reference() {
1797        let temp = tempfile::tempdir().unwrap();
1798        let husky = temp.path().join(".husky");
1799        fs::create_dir_all(&husky).unwrap();
1800        let bridge = husky.join("pre-push.pre-entire");
1801        // A stripped legacy bridge can end up with an empty `else` branch,
1802        // which `sh -n` rejects.
1803        fs::write(
1804            &bridge,
1805            "#!/usr/bin/env sh\nset -e\nif command -v entire >/dev/null; then\n  echo ok\nelse\nfi\n",
1806        )
1807        .unwrap();
1808        super::make_executable(&bridge).unwrap();
1809        super::uninstall_legacy_hook_references(temp.path()).unwrap();
1810        let content = fs::read_to_string(&bridge).unwrap();
1811        assert!(content.contains(r#"exec "$(dirname "$0")/pre-push" "$@""#));
1812    }
1813
1814    #[test]
1815    fn simple_pre_entire_bridge_is_preserved() {
1816        let temp = tempfile::tempdir().unwrap();
1817        let husky = temp.path().join(".husky");
1818        fs::create_dir_all(&husky).unwrap();
1819        let bridge = husky.join("commit-msg.pre-entire");
1820        fs::write(
1821            &bridge,
1822            "#!/bin/sh\nexec \"$(dirname \"$0\")/commit-msg\" \"$@\"\n",
1823        )
1824        .unwrap();
1825        super::make_executable(&bridge).unwrap();
1826        super::uninstall_legacy_hook_references(temp.path()).unwrap();
1827        let content = fs::read_to_string(&bridge).unwrap();
1828        assert!(content.contains(r#"exec "$(dirname "$0")/commit-msg" "$@""#));
1829    }
1830
1831    #[test]
1832    fn install_migrates_root_truth_md_to_state_dir() {
1833        let temp = tempfile::tempdir().unwrap();
1834        let repo_root = temp.path().join("repo");
1835        fs::create_dir_all(&repo_root).unwrap();
1836        Command::new("git")
1837            .args(["init"])
1838            .current_dir(&repo_root)
1839            .output()
1840            .unwrap();
1841        let root_truth = repo_root.join("TRUTH.md");
1842        fs::write(&root_truth, "# truth\n").unwrap();
1843        let state_dir = repo_root.join(".truth");
1844        fs::create_dir_all(&state_dir).unwrap();
1845        super::migrate_truth_md(&repo_root, &state_dir).unwrap();
1846        assert!(state_dir.join("TRUTH.md").is_file());
1847        assert!(!root_truth.exists());
1848        assert_eq!(
1849            fs::read_to_string(state_dir.join("TRUTH.md")).unwrap(),
1850            "# truth\n"
1851        );
1852    }
1853
1854    #[test]
1855    fn install_migrates_legacy_truth_mirror_md_to_state_dir() {
1856        let temp = tempfile::tempdir().unwrap();
1857        let repo_root = temp.path().join("repo");
1858        fs::create_dir_all(&repo_root).unwrap();
1859        let legacy_dir = repo_root.join(".truth-mirror");
1860        fs::create_dir_all(&legacy_dir).unwrap();
1861        fs::write(legacy_dir.join("TRUTH.md"), "# legacy truth\n").unwrap();
1862        let state_dir = repo_root.join(".truth");
1863        fs::create_dir_all(&state_dir).unwrap();
1864        super::migrate_truth_md(&repo_root, &state_dir).unwrap();
1865        assert!(state_dir.join("TRUTH.md").is_file());
1866        assert!(!legacy_dir.join("TRUTH.md").exists());
1867    }
1868
1869    proptest! {
1870        #[test]
1871        fn hook_shim_rendering_stays_tiny_exec_only(index in 0usize..3) {
1872            let hook = [HookName::CommitMsg, HookName::PostCommit, HookName::PrePush][index];
1873            let shim = render_shim(hook, "");
1874            let lines = shim.lines().collect::<Vec<_>>();
1875
1876            prop_assert_eq!(lines.len(), 3);
1877            prop_assert_eq!(lines[0], "#!/bin/sh");
1878            prop_assert_eq!(lines[1], super::MANAGED_MARKER);
1879            prop_assert!(lines[2].starts_with("exec truth-mirror hook-dispatch "));
1880            prop_assert!(lines[2].contains(hook.as_str()));
1881            prop_assert_eq!(shim.matches(super::MANAGED_MARKER).count(), 1);
1882            prop_assert_eq!(shim.matches("exec truth-mirror").count(), 1);
1883        }
1884    }
1885}