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