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::{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];
22const MANAGED_MARKER: &str = "# truth-mirror managed hook";
23const MANAGED_END_MARKER: &str = "# truth-mirror hook end";
24const HUSKY_ENTIRE_BRIDGE_MARKER: &str = "# Bridge for the husky+entire chain";
25const FORWARDER_NAME: &str = "_forward-local.sh";
26const OUTER_HOOK_MARKERS: &[&str] = &[
27    "# Entire CLI hooks",
28    HUSKY_ENTIRE_BRIDGE_MARKER,
29    "# truth-mirror managed hook",
30    "exec truth-mirror",
31    "entire hooks git",
32];
33const STATE_GITIGNORE_HEADER: &str = "# truth-mirror runtime state - machine-generated";
34const STATE_GITIGNORE_LINES: &[&str] =
35    &["runs/", "review-queue.jsonl", "tmp/", "logs/", "*.local.*"];
36const FORWARDER_SOURCE: &str = r#"#!/bin/sh
37# _forward-local.sh - delegate to .git/hooks from a committed core.hooksPath.
38# Uses --git-common-dir (resolves to .git/) not core.hooksPath to prevent an infinite loop.
39name="$1"; shift
40git_dir="$(git rev-parse --git-common-dir 2>/dev/null)"
41[ -n "$git_dir" ] || exit 0
42local_hook="$git_dir/hooks/$name"
43if [ -x "$local_hook" ]; then exec "$local_hook" "$@"; fi
44exit 0
45"#;
46
47/// Printed after `--pi` install: Pi only executes project extensions once the
48/// folder is trusted (verified against Pi 0.80.3 `core/project-trust.js`).
49const PI_TRUST_NOTE: &str = "pi: installed .pi/extensions/truth-mirror.js — Pi loads it once you trust this project folder in Pi.";
50
51pub fn run(
52    args: cli::InstallHooksArgs,
53    state_dir: &Path,
54    config_path: Option<&Path>,
55    config: &crate::config::TruthMirrorConfig,
56) -> Result<ExitCode> {
57    let repo_root = git_root()?;
58    let hooks_path = repo_root.join(state_dir).join("hooks");
59    let plan = HookInstallPlan::new(&repo_root, &hooks_path, args.uninstall);
60    // Preserve non-default global flags so the INSTALLED hooks use the same config
61    // and state dir at runtime instead of silently reloading defaults.
62    let global_args = hook_global_args(config_path, &repo_root, state_dir);
63    let plan = HookInstallPlan {
64        global_args,
65        mode: detect_hook_mode(&repo_root, state_dir)?,
66        ..plan
67    };
68    let agents = file_surface_agents(&args);
69    let pi = pi_targeted(&args);
70
71    if args.dry_run {
72        println!("{}", plan.render());
73        for agent in &agents {
74            println!(
75                "surface: {} -> {}",
76                surface::agent_slug(*agent),
77                surface::surface_relative_path(*agent)
78            );
79        }
80        if pi {
81            println!("surface: pi -> {}", surface::PI_EXTENSION_RELATIVE);
82        }
83        return Ok(ExitCode::SUCCESS);
84    }
85
86    let enforcement_enabled = config.enforcement.is_enabled();
87
88    if args.uninstall {
89        uninstall(&plan)?;
90        for agent in &agents {
91            surface::uninstall_enforcement(&repo_root, *agent)?;
92            SurfacePlan::for_agent(&repo_root, *agent).uninstall()?;
93        }
94        if pi {
95            surface::uninstall_pi_extension(&repo_root)?;
96            remove_legacy_pi_hooks(&repo_root)?;
97        }
98    } else {
99        install(&plan, args.inject_forwarder)?;
100        for agent in &agents {
101            SurfacePlan::for_agent(&repo_root, *agent).install()?;
102            // Enforcement is opt-in: only install the tool-blocking hook when the
103            // repo config enables it. Preserve global flags so it uses this config.
104            if enforcement_enabled {
105                surface::install_enforcement(&repo_root, *agent, &plan.global_args)?;
106            }
107        }
108        if args.pi {
109            // Clean any bogus .pi/hooks.json a prior truth-mirror wrote, install the
110            // real project-local extension, and note the one-time trust step.
111            remove_legacy_pi_hooks(&repo_root)?;
112            surface::install_pi_extension(&repo_root)?;
113            println!("{PI_TRUST_NOTE}");
114        }
115    }
116
117    Ok(ExitCode::SUCCESS)
118}
119
120/// File-surface agents (Claude, Codex) touched by this invocation. Install acts
121/// only on explicitly selected agents; a bare `install-hooks --uninstall` clears
122/// all file surfaces so it fully reverses a prior per-agent install.
123fn file_surface_agents(args: &cli::InstallHooksArgs) -> Vec<Agent> {
124    let mut agents = Vec::new();
125    if args.claude {
126        agents.push(Agent::Claude);
127    }
128    if args.codex {
129        agents.push(Agent::Codex);
130    }
131
132    if agents.is_empty() && args.uninstall && !args.pi {
133        return surface::FILE_SURFACE_AGENTS.to_vec();
134    }
135    agents
136}
137
138/// Whether this invocation should act on Pi: explicit `--pi`, or a bare
139/// `--uninstall` (no agent flags) that clears everything including legacy Pi files.
140fn pi_targeted(args: &cli::InstallHooksArgs) -> bool {
141    args.pi || (args.uninstall && !args.claude && !args.codex)
142}
143
144/// Remove a `.pi/hooks.json` left by an earlier (incorrect) truth-mirror version.
145fn remove_legacy_pi_hooks(repo_root: &Path) -> Result<()> {
146    let path = repo_root.join(".pi/hooks.json");
147    if path.is_file() {
148        fs::remove_file(&path)?;
149    }
150    Ok(())
151}
152
153pub fn dispatch(
154    args: cli::HookDispatchArgs,
155    state_dir: &Path,
156    config: &crate::config::TruthMirrorConfig,
157) -> Result<ExitCode> {
158    run_chained_hook(state_dir, args.hook, &args.args)?;
159
160    match args.hook {
161        HookName::CommitMsg => dispatch_commit_msg(state_dir, &args.args, config)?,
162        HookName::PostCommit => dispatch_post_commit(state_dir)?,
163        HookName::PrePush => dispatch_pre_push(state_dir, config)?,
164    }
165
166    Ok(ExitCode::SUCCESS)
167}
168
169#[derive(Clone, Debug, Default, Eq, PartialEq)]
170pub struct HookInstallPlan {
171    pub repo_root: PathBuf,
172    pub hooks_path: PathBuf,
173    pub uninstall: bool,
174    pub mode: HookInstallMode,
175    /// Global CLI flags (`--config`, `--state-dir`) preserved into the shims so a
176    /// custom-config install keeps using that config at hook runtime. Empty for a
177    /// default install (the trailing space is included when non-empty).
178    pub global_args: String,
179}
180
181#[derive(Clone, Debug, Default, Eq, PartialEq)]
182pub enum HookInstallMode {
183    #[default]
184    Plain,
185    LegacyTruthMirrorHooksPath {
186        configured_path: String,
187    },
188    Husky {
189        content_dir: PathBuf,
190    },
191    CustomCommitted {
192        hooks_path: PathBuf,
193    },
194}
195
196impl HookInstallPlan {
197    pub fn new(repo_root: &Path, hooks_path: &Path, uninstall: bool) -> Self {
198        Self {
199            repo_root: repo_root.to_path_buf(),
200            hooks_path: hooks_path.to_path_buf(),
201            uninstall,
202            mode: HookInstallMode::Plain,
203            global_args: String::new(),
204        }
205    }
206
207    pub fn render(&self) -> String {
208        let action = if self.uninstall {
209            "uninstall"
210        } else {
211            "install"
212        };
213        let mut output = format!(
214            "truth-mirror hook plan\nrepo={}\naction={action}\nmode={}\nstateHooks={}\n",
215            self.repo_root.display(),
216            self.mode.as_str(),
217            self.hooks_path.display()
218        );
219        for hook in INSTALLED_HOOKS {
220            output.push_str(&format!(
221                "\nhook={}\npath={}\nbody:\n{}",
222                hook.as_str(),
223                self.active_hook_path(*hook).display(),
224                self.render_hook_body(*hook)
225            ));
226            if let HookInstallMode::Husky { content_dir } = &self.mode {
227                output.push_str(&format!(
228                    "\nbridge={}.pre-entire\npath={}\nbody:\n{}",
229                    hook.as_str(),
230                    husky_entire_bridge_path(content_dir, *hook).display(),
231                    render_husky_entire_bridge(*hook)
232                ));
233            }
234        }
235        output
236    }
237
238    fn active_hook_path(&self, hook: HookName) -> PathBuf {
239        match &self.mode {
240            HookInstallMode::Husky { content_dir } => content_dir.join(hook.as_str()),
241            HookInstallMode::Plain | HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
242                self.repo_root.join(".git/hooks").join(hook.as_str())
243            }
244            HookInstallMode::CustomCommitted { hooks_path } => hooks_path.join(hook.as_str()),
245        }
246    }
247
248    fn render_hook_body(&self, hook: HookName) -> String {
249        match self.mode {
250            HookInstallMode::Husky { .. } => render_husky_block(hook, &self.global_args),
251            HookInstallMode::CustomCommitted { .. } => render_custom_forwarder_stub(hook),
252            HookInstallMode::Plain | HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
253                render_shim(hook, &self.global_args)
254            }
255        }
256    }
257}
258
259impl HookInstallMode {
260    fn as_str(&self) -> &'static str {
261        match self {
262            HookInstallMode::Plain => "plain",
263            HookInstallMode::LegacyTruthMirrorHooksPath { .. } => "plain-legacy-truth-mirror",
264            HookInstallMode::Husky { .. } => "husky",
265            HookInstallMode::CustomCommitted { .. } => "custom-committed",
266        }
267    }
268}
269
270/// Build the preserved global-flag prefix (with a trailing space when non-empty).
271fn hook_global_args(config_path: Option<&Path>, repo_root: &Path, state_dir: &Path) -> String {
272    let mut parts = Vec::new();
273    if let Some(config) = config_path {
274        parts.push(format!(
275            "--config {}",
276            quote_git_arg(&absolutize(repo_root, config))
277        ));
278    }
279    if state_dir != Path::new(crate::config::DEFAULT_STATE_DIR) {
280        parts.push(format!(
281            "--state-dir {}",
282            quote_git_arg(&absolutize(repo_root, state_dir))
283        ));
284    }
285    if parts.is_empty() {
286        String::new()
287    } else {
288        format!("{} ", parts.join(" "))
289    }
290}
291
292fn absolutize(repo_root: &Path, path: &Path) -> PathBuf {
293    if path.is_absolute() {
294        path.to_path_buf()
295    } else {
296        repo_root.join(path)
297    }
298}
299
300/// POSIX single-quote escaping for a path embedded in the generated `/bin/sh`
301/// shim: wrap in single quotes and replace any embedded single quote with `'\''`
302/// so no metacharacter (`;`, `$()`, `'`, spaces, …) can break or inject shell.
303fn quote_git_arg(path: &Path) -> String {
304    let value = path.to_string_lossy();
305    format!("'{}'", value.replace('\'', "'\\''"))
306}
307
308pub fn render_shim(hook: HookName, global_args: &str) -> String {
309    format!(
310        "#!/bin/sh\n{MANAGED_MARKER}\nexec truth-mirror {global_args}hook-dispatch {} \"$@\"\n",
311        hook.as_str()
312    )
313}
314
315fn render_husky_block(hook: HookName, global_args: &str) -> String {
316    format!(
317        "{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",
318        hook.as_str()
319    )
320}
321
322fn render_husky_entire_bridge(hook: HookName) -> String {
323    format!(
324        "#!/bin/sh\n{HUSKY_ENTIRE_BRIDGE_MARKER}\n{}\n",
325        husky_entire_bridge_exec_line(hook)
326    )
327}
328
329fn husky_entire_bridge_exec_line(hook: HookName) -> String {
330    format!("exec \"$(dirname \"$0\")/{}\" \"$@\"", hook.as_str())
331}
332
333fn render_custom_forwarder_stub(hook: HookName) -> String {
334    format!(
335        "#!/bin/sh\n{MANAGED_MARKER}\nexec \"$(dirname \"$0\")/{FORWARDER_NAME}\" {} \"$@\"\n",
336        hook.as_str()
337    )
338}
339
340fn detect_hook_mode(repo_root: &Path, state_dir: &Path) -> Result<HookInstallMode> {
341    let Some(configured) = git_config_get(repo_root, "core.hooksPath")? else {
342        return Ok(HookInstallMode::Plain);
343    };
344    let configured = configured.trim().to_owned();
345    if configured.is_empty() {
346        return Ok(HookInstallMode::Plain);
347    }
348    if is_managed_hooks_path(repo_root, state_dir, &configured) {
349        return Ok(HookInstallMode::LegacyTruthMirrorHooksPath {
350            configured_path: configured,
351        });
352    }
353    if configured.contains(".husky") {
354        let hooks_path = repo_relative_path(repo_root, Path::new(&configured));
355        let content_dir = if hooks_path.file_name().and_then(|name| name.to_str()) == Some("_") {
356            hooks_path
357                .parent()
358                .map_or_else(|| repo_root.join(".husky"), Path::to_path_buf)
359        } else {
360            hooks_path
361        };
362        return Ok(HookInstallMode::Husky { content_dir });
363    }
364    Ok(HookInstallMode::CustomCommitted {
365        hooks_path: repo_relative_path(repo_root, Path::new(&configured)),
366    })
367}
368
369fn repo_relative_path(repo_root: &Path, path: &Path) -> PathBuf {
370    if path.is_absolute() {
371        path.to_path_buf()
372    } else {
373        repo_root.join(path)
374    }
375}
376
377fn is_managed_hooks_path(repo_root: &Path, state_dir: &Path, configured: &str) -> bool {
378    [
379        state_dir.join("hooks"),
380        PathBuf::from(crate::config::DEFAULT_STATE_DIR).join("hooks"),
381        PathBuf::from(crate::config::LEGACY_STATE_DIR).join("hooks"),
382    ]
383    .iter()
384    .any(|candidate| path_config_matches(repo_root, candidate, configured))
385}
386
387fn path_config_matches(repo_root: &Path, candidate: &Path, configured: &str) -> bool {
388    let candidate_string = candidate.to_string_lossy().into_owned();
389    let absolute_string = repo_root.join(candidate).to_string_lossy().into_owned();
390    configured == candidate_string || configured == absolute_string
391}
392
393fn install(plan: &HookInstallPlan, inject_forwarder: bool) -> Result<()> {
394    match &plan.mode {
395        HookInstallMode::Plain => {
396            prepare_state_hooks(plan)?;
397            install_plain(plan)
398        }
399        HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
400            prepare_state_hooks(plan)?;
401            unset_core_hooks_path(&plan.repo_root)?;
402            install_plain(plan)
403        }
404        HookInstallMode::Husky { content_dir } => {
405            prepare_state_hooks(plan)?;
406            install_husky(plan, content_dir)
407        }
408        HookInstallMode::CustomCommitted { hooks_path } => {
409            install_custom(plan, hooks_path, inject_forwarder)
410        }
411    }
412}
413
414fn uninstall(plan: &HookInstallPlan) -> Result<()> {
415    match &plan.mode {
416        HookInstallMode::Plain => uninstall_plain(plan)?,
417        HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
418            unset_core_hooks_path(&plan.repo_root)?;
419            uninstall_plain(plan)?;
420        }
421        HookInstallMode::Husky { content_dir } => uninstall_husky(plan, content_dir)?,
422        HookInstallMode::CustomCommitted { hooks_path } => {
423            uninstall_plain(plan)?;
424            uninstall_custom_forwarders(hooks_path)?;
425        }
426    }
427
428    if plan.hooks_path.exists() {
429        fs::remove_dir_all(&plan.hooks_path)?;
430    }
431    Ok(())
432}
433
434fn state_dir_from_hooks_path(hooks_path: &Path) -> &Path {
435    hooks_path.parent().unwrap_or(hooks_path)
436}
437
438fn prepare_state_hooks(plan: &HookInstallPlan) -> Result<()> {
439    ensure_state_gitignore(&plan.repo_root, state_dir_from_hooks_path(&plan.hooks_path))?;
440    fs::create_dir_all(&plan.hooks_path)?;
441    fs::create_dir_all(plan.hooks_path.join("chained"))?;
442    clear_chained_hooks(plan)
443}
444
445fn install_plain(plan: &HookInstallPlan) -> Result<()> {
446    let git_hooks = plan.repo_root.join(".git/hooks");
447    fs::create_dir_all(&git_hooks)?;
448    for hook in INSTALLED_HOOKS {
449        install_local_hook(plan, &git_hooks, *hook)?;
450    }
451    Ok(())
452}
453
454fn install_local_hook(plan: &HookInstallPlan, git_hooks: &Path, hook: HookName) -> Result<()> {
455    let active = git_hooks.join(hook.as_str());
456    let backup = backup_path(&active, "pre-truth-mirror");
457    let chained = plan.hooks_path.join("chained").join(hook.as_str());
458    remove_file_if_exists(&chained)?;
459
460    let source = local_hook_source(&active, &backup)?;
461    if let Some(source) = source {
462        if source.path != backup {
463            fs::copy(&source.path, &backup)?;
464            make_executable(&backup)?;
465        }
466        if !has_outer_hook_marker(&source.content) {
467            fs::copy(&source.path, &chained)?;
468            make_executable(&chained)?;
469        }
470    }
471
472    fs::write(&active, render_shim(hook, &plan.global_args))?;
473    make_executable(&active)?;
474    Ok(())
475}
476
477fn uninstall_plain(plan: &HookInstallPlan) -> Result<()> {
478    let git_hooks = plan.repo_root.join(".git/hooks");
479    for hook in INSTALLED_HOOKS {
480        let active = git_hooks.join(hook.as_str());
481        let backup = backup_path(&active, "pre-truth-mirror");
482        let active_content = read_to_string_if_file(&active)?;
483        if active_content
484            .as_deref()
485            .is_some_and(is_truth_mirror_managed)
486        {
487            if backup.is_file() {
488                fs::copy(&backup, &active)?;
489                make_executable(&active)?;
490                fs::remove_file(&backup)?;
491            } else {
492                remove_file_if_exists(&active)?;
493            }
494        }
495        remove_file_if_exists(&plan.hooks_path.join("chained").join(hook.as_str()))?;
496    }
497    Ok(())
498}
499
500fn install_husky(plan: &HookInstallPlan, content_dir: &Path) -> Result<()> {
501    fs::create_dir_all(content_dir)?;
502    for hook in INSTALLED_HOOKS {
503        let target = content_dir.join(hook.as_str());
504        let block = render_husky_block(*hook, &plan.global_args);
505        let next = match read_to_string_if_file(&target)? {
506            Some(content) => append_managed_block(&content, &block),
507            None => format!("#!/bin/sh\n{block}"),
508        };
509        fs::write(&target, next)?;
510        make_executable(&target)?;
511        install_husky_entire_bridge(content_dir, *hook)?;
512    }
513    Ok(())
514}
515
516fn install_husky_entire_bridge(content_dir: &Path, hook: HookName) -> Result<()> {
517    let target = husky_entire_bridge_path(content_dir, hook);
518    let backup = backup_path(&target, "pre-truth-mirror");
519    if let Some(content) = read_to_string_if_file(&target)? {
520        if is_husky_entire_bridge(&content) && has_husky_entire_bridge_exec(&content, hook) {
521            make_executable(&target)?;
522            return Ok(());
523        }
524        if !is_husky_entire_bridge(&content) {
525            fs::copy(&target, &backup)?;
526            make_executable(&backup)?;
527        }
528    }
529    fs::write(&target, render_husky_entire_bridge(hook))?;
530    make_executable(&target)?;
531    Ok(())
532}
533
534fn uninstall_husky(_plan: &HookInstallPlan, content_dir: &Path) -> Result<()> {
535    for hook in INSTALLED_HOOKS {
536        let target = content_dir.join(hook.as_str());
537        if let Some(content) = read_to_string_if_file(&target)? {
538            let next = remove_managed_block(&content);
539            if is_empty_or_shebang_only(&next) {
540                fs::remove_file(&target)?;
541            } else {
542                fs::write(&target, next)?;
543                make_executable(&target)?;
544            }
545        }
546        uninstall_husky_entire_bridge(content_dir, *hook)?;
547    }
548    Ok(())
549}
550
551fn uninstall_husky_entire_bridge(content_dir: &Path, hook: HookName) -> Result<()> {
552    let target = husky_entire_bridge_path(content_dir, hook);
553    let backup = backup_path(&target, "pre-truth-mirror");
554    match read_to_string_if_file(&target)? {
555        Some(content) if is_husky_entire_bridge(&content) => {
556            if backup.is_file() {
557                fs::copy(&backup, &target)?;
558                make_executable(&target)?;
559                fs::remove_file(&backup)?;
560            } else {
561                remove_file_if_exists(&target)?;
562            }
563        }
564        None if backup.is_file() => {
565            fs::copy(&backup, &target)?;
566            make_executable(&target)?;
567            fs::remove_file(&backup)?;
568        }
569        _ => {}
570    }
571    Ok(())
572}
573
574fn husky_entire_bridge_path(content_dir: &Path, hook: HookName) -> PathBuf {
575    content_dir.join(format!("{}.pre-entire", hook.as_str()))
576}
577
578fn install_custom(plan: &HookInstallPlan, hooks_path: &Path, inject_forwarder: bool) -> Result<()> {
579    let missing_forwarder = INSTALLED_HOOKS
580        .iter()
581        .any(|hook| !custom_forwarder_present(hooks_path, *hook));
582    if missing_forwarder && !inject_forwarder {
583        bail!(
584            "truth-mirror: core.hooksPath is set to '{}' (a non-husky committed directory).\nTo install, either:\n  (a) run `truth-mirror install-hooks --inject-forwarder` 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",
585            hooks_path.display()
586        );
587    }
588
589    if inject_forwarder {
590        inject_custom_forwarders(hooks_path)?;
591    }
592
593    prepare_state_hooks(plan)?;
594    let git_hooks = plan.repo_root.join(".git/hooks");
595    fs::create_dir_all(&git_hooks)?;
596    for hook in INSTALLED_HOOKS {
597        install_local_hook(plan, &git_hooks, *hook)?;
598    }
599    Ok(())
600}
601
602fn inject_custom_forwarders(hooks_path: &Path) -> Result<()> {
603    fs::create_dir_all(hooks_path)?;
604    let forwarder = hooks_path.join(FORWARDER_NAME);
605    fs::write(&forwarder, FORWARDER_SOURCE)?;
606    make_executable(&forwarder)?;
607
608    for hook in INSTALLED_HOOKS {
609        let target = hooks_path.join(hook.as_str());
610        if custom_forwarder_present(hooks_path, *hook) {
611            continue;
612        }
613        if target.is_file() {
614            fs::copy(&target, backup_path(&target, "pre-truth-mirror"))?;
615        }
616        fs::write(&target, render_custom_forwarder_stub(*hook))?;
617        make_executable(&target)?;
618    }
619    eprintln!(
620        "hint: commit the truth-mirror forwarder changes in {}",
621        hooks_path.display()
622    );
623    Ok(())
624}
625
626fn uninstall_custom_forwarders(hooks_path: &Path) -> Result<()> {
627    for hook in INSTALLED_HOOKS {
628        let target = hooks_path.join(hook.as_str());
629        let backup = backup_path(&target, "pre-truth-mirror");
630        let content = read_to_string_if_file(&target)?;
631        if content.as_deref().is_some_and(is_truth_mirror_managed) {
632            if backup.is_file() {
633                fs::copy(&backup, &target)?;
634                make_executable(&target)?;
635                fs::remove_file(&backup)?;
636            } else {
637                remove_file_if_exists(&target)?;
638            }
639        }
640    }
641    let forwarder = hooks_path.join(FORWARDER_NAME);
642    if read_to_string_if_file(&forwarder)?.as_deref() == Some(FORWARDER_SOURCE) {
643        fs::remove_file(forwarder)?;
644    }
645    Ok(())
646}
647
648fn append_managed_block(content: &str, block: &str) -> String {
649    let mut next = remove_managed_block(content);
650    if !next.ends_with('\n') {
651        next.push('\n');
652    }
653    next.push_str(block);
654    next
655}
656
657fn remove_managed_block(content: &str) -> String {
658    let mut out = Vec::new();
659    let mut skipping = false;
660    for line in content.lines() {
661        if line == MANAGED_MARKER {
662            skipping = true;
663            continue;
664        }
665        if skipping {
666            if line == MANAGED_END_MARKER {
667                skipping = false;
668            }
669            continue;
670        }
671        out.push(line);
672    }
673    if out.is_empty() {
674        String::new()
675    } else {
676        format!("{}\n", out.join("\n"))
677    }
678}
679
680fn is_empty_or_shebang_only(content: &str) -> bool {
681    let lines = content
682        .lines()
683        .map(str::trim)
684        .filter(|line| !line.is_empty())
685        .collect::<Vec<_>>();
686    lines.is_empty() || lines == ["#!/bin/sh"]
687}
688
689#[derive(Debug)]
690struct LocalHookSource {
691    path: PathBuf,
692    content: String,
693}
694
695fn local_hook_source(active: &Path, backup: &Path) -> Result<Option<LocalHookSource>> {
696    let Some(active_content) = read_to_string_if_file(active)? else {
697        return Ok(None);
698    };
699    if is_truth_mirror_managed(&active_content) {
700        return read_to_string_if_file(backup)?.map_or(Ok(None), |content| {
701            Ok(Some(LocalHookSource {
702                path: backup.to_path_buf(),
703                content,
704            }))
705        });
706    }
707    Ok(Some(LocalHookSource {
708        path: active.to_path_buf(),
709        content: active_content,
710    }))
711}
712
713fn custom_forwarder_present(hooks_path: &Path, hook: HookName) -> bool {
714    read_to_string_if_file(&hooks_path.join(hook.as_str()))
715        .ok()
716        .flatten()
717        .is_some_and(|content| {
718            content.contains("--git-common-dir")
719                || content.contains(FORWARDER_NAME)
720                || is_truth_mirror_managed(&content)
721        })
722}
723
724fn ensure_state_gitignore(repo_root: &Path, state_dir: &Path) -> Result<()> {
725    let state_dir = absolutize(repo_root, state_dir);
726    fs::create_dir_all(&state_dir)?;
727    let gitignore = state_dir.join(".gitignore");
728    let mut content = read_to_string_if_file(&gitignore)?.unwrap_or_default();
729    if content.is_empty() {
730        content.push_str(STATE_GITIGNORE_HEADER);
731        content.push('\n');
732    }
733    for line in STATE_GITIGNORE_LINES {
734        if !content.lines().any(|existing| existing.trim() == *line) {
735            if !content.ends_with('\n') {
736                content.push('\n');
737            }
738            content.push_str(line);
739            content.push('\n');
740        }
741    }
742    fs::write(&gitignore, content)?;
743    print_tracked_runtime_hints(repo_root, &state_dir)?;
744    Ok(())
745}
746
747fn print_tracked_runtime_hints(repo_root: &Path, state_dir: &Path) -> Result<()> {
748    let rel_state = state_dir.strip_prefix(repo_root).unwrap_or(state_dir);
749    for path in [
750        rel_state.join("review-queue.jsonl"),
751        rel_state.join("runs"),
752        rel_state.join("tmp"),
753        rel_state.join("logs"),
754    ] {
755        let output = Command::new("git")
756            .args(["ls-files", "--"])
757            .arg(&path)
758            .current_dir(repo_root)
759            .output()?;
760        if output.status.success() {
761            for tracked in String::from_utf8_lossy(&output.stdout)
762                .lines()
763                .filter(|line| !line.trim().is_empty())
764            {
765                eprintln!("hint: run: git rm --cached {tracked}");
766            }
767        }
768    }
769    Ok(())
770}
771
772fn clear_chained_hooks(plan: &HookInstallPlan) -> Result<()> {
773    for hook in INSTALLED_HOOKS {
774        remove_file_if_exists(&plan.hooks_path.join("chained").join(hook.as_str()))?;
775    }
776    Ok(())
777}
778
779fn has_outer_hook_marker(content: &str) -> bool {
780    content.lines().take(20).any(|line| {
781        OUTER_HOOK_MARKERS
782            .iter()
783            .any(|marker| line.contains(marker))
784    })
785}
786
787fn is_truth_mirror_managed(content: &str) -> bool {
788    content.contains(MANAGED_MARKER)
789        || content.contains(HUSKY_ENTIRE_BRIDGE_MARKER)
790        || content.contains("exec truth-mirror")
791}
792
793fn is_husky_entire_bridge(content: &str) -> bool {
794    content.contains(HUSKY_ENTIRE_BRIDGE_MARKER)
795}
796
797fn has_husky_entire_bridge_exec(content: &str, hook: HookName) -> bool {
798    let expected = husky_entire_bridge_exec_line(hook);
799    content.lines().any(|line| line.trim() == expected)
800}
801
802fn backup_path(path: &Path, suffix: &str) -> PathBuf {
803    let file_name = path
804        .file_name()
805        .and_then(|name| name.to_str())
806        .unwrap_or("hook");
807    path.with_file_name(format!("{file_name}.{suffix}"))
808}
809
810fn read_to_string_if_file(path: &Path) -> Result<Option<String>> {
811    match fs::read_to_string(path) {
812        Ok(content) => Ok(Some(content)),
813        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
814        Err(error) => Err(error.into()),
815    }
816}
817
818fn remove_file_if_exists(path: &Path) -> Result<()> {
819    match fs::remove_file(path) {
820        Ok(()) => Ok(()),
821        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
822        Err(error) => Err(error.into()),
823    }
824}
825
826fn unset_core_hooks_path(repo_root: &Path) -> Result<()> {
827    let status = Command::new("git")
828        .args(["config", "--unset", "core.hooksPath"])
829        .current_dir(repo_root)
830        .status()?;
831    if !status.success() {
832        // Git exits non-zero when the key is already absent; migration should be idempotent.
833        return Ok(());
834    }
835    Ok(())
836}
837
838fn dispatch_commit_msg(
839    state_dir: &Path,
840    args: &[String],
841    config: &crate::config::TruthMirrorConfig,
842) -> Result<()> {
843    let commit_msg_path = args
844        .first()
845        .context("commit-msg hook requires COMMIT_EDITMSG path")?;
846    let commit_message = fs::read_to_string(commit_msg_path)?;
847    let diff = git_stdout(&["diff", "--cached"])?;
848    let claim_file = fs::read_to_string(state_dir.join("claim.txt")).ok();
849    let policy = config.gates.to_policy();
850    claim::evaluate_commit_message(&commit_message, claim_file.as_deref(), Some(&diff), &policy)?;
851    Ok(())
852}
853
854fn dispatch_post_commit(state_dir: &Path) -> Result<()> {
855    let sha = git_stdout(&["rev-parse", "HEAD"])?;
856    ReviewQueue::new(state_dir).enqueue(sha.trim())?;
857    Ok(())
858}
859
860fn dispatch_pre_push(state_dir: &Path, config: &crate::config::TruthMirrorConfig) -> Result<()> {
861    let mut stdin = String::new();
862    io::stdin().read_to_string(&mut stdin)?;
863    for line in stdin.lines() {
864        if let Some(range) = pre_push_range_from_line(line) {
865            gate::run(
866                cli::GateArgs {
867                    pre_push: Some(range),
868                    commit_msg: None,
869                    claim_file: None,
870                    diff_file: None,
871                    fake_markers: Vec::new(),
872                    pre_tool_use: false,
873                    tool: None,
874                },
875                state_dir,
876                config,
877            )?;
878        }
879    }
880    Ok(())
881}
882
883fn pre_push_range_from_line(line: &str) -> Option<String> {
884    let parts = line.split_whitespace().collect::<Vec<_>>();
885    let local_sha = parts.get(1)?;
886    let remote_sha = parts.get(3)?;
887    if is_zero_sha(local_sha) {
888        return None;
889    }
890
891    if is_zero_sha(remote_sha) {
892        Some((*local_sha).to_owned())
893    } else {
894        Some(format!("{remote_sha}..{local_sha}"))
895    }
896}
897
898fn is_zero_sha(value: &str) -> bool {
899    value.chars().all(|character| character == '0')
900}
901
902fn run_chained_hook(state_dir: &Path, hook: HookName, args: &[String]) -> Result<()> {
903    let chained = state_dir.join("hooks/chained").join(hook.as_str());
904    if !chained.is_file() {
905        return Ok(());
906    }
907    let content = fs::read_to_string(&chained)?;
908    if has_outer_hook_marker(&content) {
909        eprintln!(
910            "truth-mirror: skipping unsafe chained hook {} because it appears to invoke an outer hook manager",
911            chained.display()
912        );
913        return Ok(());
914    }
915
916    let status = Command::new(&chained).args(args).status()?;
917    if !status.success() {
918        bail!(
919            "chained hook {} failed with status {status}",
920            chained.display()
921        );
922    }
923    Ok(())
924}
925
926fn git_root() -> Result<PathBuf> {
927    Ok(PathBuf::from(
928        git_stdout(&["rev-parse", "--show-toplevel"])?.trim(),
929    ))
930}
931
932fn git_stdout(args: &[&str]) -> Result<String> {
933    let output = Command::new("git").args(args).output()?;
934    if !output.status.success() {
935        bail!(
936            "git {} failed: {}",
937            args.join(" "),
938            String::from_utf8_lossy(&output.stderr)
939        );
940    }
941    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
942}
943
944fn git_config_get(repo_root: &Path, key: &str) -> Result<Option<String>> {
945    let output = Command::new("git")
946        .args(["config", "--get", key])
947        .current_dir(repo_root)
948        .output()?;
949    if output.status.success() {
950        return Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()));
951    }
952    if output.status.code() == Some(1) {
953        return Ok(None);
954    }
955    bail!(
956        "git config --get {key} failed: {}",
957        String::from_utf8_lossy(&output.stderr)
958    );
959}
960
961#[cfg(unix)]
962fn make_executable(path: &Path) -> Result<()> {
963    use std::os::unix::fs::PermissionsExt;
964
965    let mut permissions = fs::metadata(path)?.permissions();
966    permissions.set_mode(0o755);
967    fs::set_permissions(path, permissions)?;
968    Ok(())
969}
970
971#[cfg(not(unix))]
972fn make_executable(_path: &Path) -> Result<()> {
973    Ok(())
974}
975
976#[cfg(test)]
977mod tests {
978    use proptest::prelude::*;
979
980    use super::{HookInstallMode, HookInstallPlan, pre_push_range_from_line, render_shim};
981    use crate::cli::HookName;
982
983    #[test]
984    fn hook_shim_is_only_exec_delegation() {
985        let shim = render_shim(HookName::CommitMsg, "");
986        let lines = shim.lines().collect::<Vec<_>>();
987
988        assert_eq!(lines.len(), 3);
989        assert_eq!(lines[0], "#!/bin/sh");
990        assert_eq!(lines[1], super::MANAGED_MARKER);
991        assert_eq!(
992            lines[2],
993            "exec truth-mirror hook-dispatch commit-msg \"$@\""
994        );
995    }
996
997    #[test]
998    fn quote_git_arg_escapes_shell_metacharacters() {
999        use std::path::Path;
1000        assert_eq!(super::quote_git_arg(Path::new("/a/b.toml")), "'/a/b.toml'");
1001        // metacharacters are neutralized by single-quoting
1002        assert_eq!(
1003            super::quote_git_arg(Path::new("/a/c;$(touch x).toml")),
1004            "'/a/c;$(touch x).toml'"
1005        );
1006        // an embedded single quote is escaped as '\''
1007        assert_eq!(
1008            super::quote_git_arg(Path::new("/a/it's.toml")),
1009            "'/a/it'\\''s.toml'"
1010        );
1011    }
1012
1013    #[test]
1014    fn hook_shim_preserves_global_args() {
1015        let shim = render_shim(HookName::PrePush, "--config /abs/enforce.toml ");
1016        let lines = shim.lines().collect::<Vec<_>>();
1017
1018        assert_eq!(lines.len(), 3, "shim stays <=3 lines + exec-only");
1019        assert_eq!(
1020            lines[2],
1021            "exec truth-mirror --config /abs/enforce.toml hook-dispatch pre-push \"$@\""
1022        );
1023    }
1024
1025    #[test]
1026    fn husky_entire_bridge_is_only_exec_delegation() {
1027        let bridge = super::render_husky_entire_bridge(HookName::CommitMsg);
1028        let lines = bridge.lines().collect::<Vec<_>>();
1029
1030        assert_eq!(lines.len(), 3);
1031        assert_eq!(lines[0], "#!/bin/sh");
1032        assert_eq!(lines[1], super::HUSKY_ENTIRE_BRIDGE_MARKER);
1033        assert_eq!(lines[2], "exec \"$(dirname \"$0\")/commit-msg\" \"$@\"");
1034    }
1035
1036    #[test]
1037    fn dry_run_plan_names_hooks_and_hooks_path() {
1038        let plan = HookInstallPlan::new(
1039            std::path::Path::new("/repo"),
1040            std::path::Path::new("/repo/.truth/hooks"),
1041            false,
1042        );
1043        let rendered = plan.render();
1044
1045        assert!(rendered.contains("commit-msg"));
1046        assert!(rendered.contains("post-commit"));
1047        assert!(rendered.contains("pre-push"));
1048        assert!(rendered.contains("stateHooks=/repo/.truth/hooks"));
1049        assert!(rendered.contains("path=/repo/.git/hooks/commit-msg"));
1050        assert!(rendered.contains("body:\n#!/bin/sh\n# truth-mirror managed hook"));
1051    }
1052
1053    #[test]
1054    fn husky_dry_run_plan_names_entire_bridge_paths() {
1055        let mut plan = HookInstallPlan::new(
1056            std::path::Path::new("/repo"),
1057            std::path::Path::new("/repo/.truth/hooks"),
1058            false,
1059        );
1060        plan.mode = HookInstallMode::Husky {
1061            content_dir: std::path::PathBuf::from("/repo/.husky"),
1062        };
1063        let rendered = plan.render();
1064
1065        assert!(rendered.contains("bridge=commit-msg.pre-entire"));
1066        assert!(rendered.contains("path=/repo/.husky/commit-msg.pre-entire"));
1067        assert!(rendered.contains(super::HUSKY_ENTIRE_BRIDGE_MARKER));
1068    }
1069
1070    #[test]
1071    fn managed_block_replace_is_idempotent() {
1072        let block = super::render_husky_block(HookName::CommitMsg, "");
1073        let original = "#!/bin/sh\necho before\n";
1074        let once = super::append_managed_block(original, &block);
1075        let twice = super::append_managed_block(&once, &block);
1076
1077        assert_eq!(twice.matches(super::MANAGED_MARKER).count(), 1);
1078        assert!(twice.contains("echo before"));
1079    }
1080
1081    #[test]
1082    fn chained_hook_safety_filter_detects_outer_tools() {
1083        assert!(super::has_outer_hook_marker(
1084            "#!/bin/sh\n# Entire CLI hooks\nentire hooks git commit-msg\n"
1085        ));
1086        assert!(super::has_outer_hook_marker(
1087            "#!/bin/sh\n# truth-mirror managed hook\nexec truth-mirror hook-dispatch commit-msg \"$@\"\n"
1088        ));
1089        assert!(!super::has_outer_hook_marker("#!/bin/sh\necho safe\n"));
1090    }
1091
1092    #[test]
1093    fn run_chained_hook_skips_marker_bearing_stale_hook() {
1094        let temp = tempfile::tempdir().unwrap();
1095        let chained = temp.path().join("hooks/chained/commit-msg");
1096        std::fs::create_dir_all(chained.parent().unwrap()).unwrap();
1097        std::fs::write(&chained, "#!/bin/sh\n# Entire CLI hooks\nexit 42\n").unwrap();
1098        super::make_executable(&chained).unwrap();
1099
1100        super::run_chained_hook(temp.path(), HookName::CommitMsg, &[]).unwrap();
1101    }
1102
1103    #[test]
1104    fn pre_push_line_maps_to_git_range() {
1105        let line = "refs/heads/main abc123 refs/heads/main def456";
1106
1107        assert_eq!(
1108            pre_push_range_from_line(line),
1109            Some("def456..abc123".to_owned())
1110        );
1111    }
1112
1113    proptest! {
1114        #[test]
1115        fn hook_shim_rendering_stays_tiny_exec_only(index in 0usize..3) {
1116            let hook = [HookName::CommitMsg, HookName::PostCommit, HookName::PrePush][index];
1117            let shim = render_shim(hook, "");
1118            let lines = shim.lines().collect::<Vec<_>>();
1119
1120            prop_assert_eq!(lines.len(), 3);
1121            prop_assert_eq!(lines[0], "#!/bin/sh");
1122            prop_assert_eq!(lines[1], super::MANAGED_MARKER);
1123            prop_assert!(lines[2].starts_with("exec truth-mirror hook-dispatch "));
1124            prop_assert!(lines[2].contains(hook.as_str()));
1125            prop_assert_eq!(shim.matches(super::MANAGED_MARKER).count(), 1);
1126            prop_assert_eq!(shim.matches("exec truth-mirror").count(), 1);
1127        }
1128
1129        #[test]
1130        fn husky_entire_bridge_rendering_stays_tiny_exec_only(index in 0usize..3) {
1131            let hook = [HookName::CommitMsg, HookName::PostCommit, HookName::PrePush][index];
1132            let bridge = super::render_husky_entire_bridge(hook);
1133            let lines = bridge.lines().collect::<Vec<_>>();
1134
1135            prop_assert_eq!(lines.len(), 3);
1136            prop_assert_eq!(lines[0], "#!/bin/sh");
1137            prop_assert_eq!(lines[1], super::HUSKY_ENTIRE_BRIDGE_MARKER);
1138            prop_assert_eq!(lines[2], format!("exec \"$(dirname \"$0\")/{}\" \"$@\"", hook.as_str()));
1139            prop_assert_eq!(bridge.matches(super::HUSKY_ENTIRE_BRIDGE_MARKER).count(), 1);
1140            prop_assert_eq!(bridge.matches("exec ").count(), 1);
1141        }
1142    }
1143}