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];
22
23/// Printed after `--pi` install: Pi only executes project extensions once the
24/// folder is trusted (verified against Pi 0.80.3 `core/project-trust.js`).
25const PI_TRUST_NOTE: &str = "pi: installed .pi/extensions/truth-mirror.js — Pi loads it once you trust this project folder in Pi.";
26
27pub fn run(
28    args: cli::InstallHooksArgs,
29    state_dir: &Path,
30    config_path: Option<&Path>,
31    config: &crate::config::TruthMirrorConfig,
32) -> Result<ExitCode> {
33    let repo_root = git_root()?;
34    let hooks_path = repo_root.join(state_dir).join("hooks");
35    let plan = HookInstallPlan::new(&repo_root, &hooks_path, args.uninstall);
36    // Preserve non-default global flags so the INSTALLED hooks use the same config
37    // and state dir at runtime instead of silently reloading defaults.
38    let global_args = hook_global_args(config_path, &repo_root, state_dir);
39    let plan = HookInstallPlan {
40        global_args,
41        ..plan
42    };
43    let agents = file_surface_agents(&args);
44    let pi = pi_targeted(&args);
45
46    if args.dry_run {
47        println!("{}", plan.render());
48        for agent in &agents {
49            println!(
50                "surface: {} -> {}",
51                surface::agent_slug(*agent),
52                surface::surface_relative_path(*agent)
53            );
54        }
55        if pi {
56            println!("surface: pi -> {}", surface::PI_EXTENSION_RELATIVE);
57        }
58        return Ok(ExitCode::SUCCESS);
59    }
60
61    let enforcement_enabled = config.enforcement.is_enabled();
62
63    if args.uninstall {
64        uninstall(&plan)?;
65        for agent in &agents {
66            surface::uninstall_enforcement(&repo_root, *agent)?;
67            SurfacePlan::for_agent(&repo_root, *agent).uninstall()?;
68        }
69        if pi {
70            surface::uninstall_pi_extension(&repo_root)?;
71            remove_legacy_pi_hooks(&repo_root)?;
72        }
73    } else {
74        install(&plan)?;
75        for agent in &agents {
76            SurfacePlan::for_agent(&repo_root, *agent).install()?;
77            // Enforcement is opt-in: only install the tool-blocking hook when the
78            // repo config enables it. Preserve global flags so it uses this config.
79            if enforcement_enabled {
80                surface::install_enforcement(&repo_root, *agent, &plan.global_args)?;
81            }
82        }
83        if args.pi {
84            // Clean any bogus .pi/hooks.json a prior truth-mirror wrote, install the
85            // real project-local extension, and note the one-time trust step.
86            remove_legacy_pi_hooks(&repo_root)?;
87            surface::install_pi_extension(&repo_root)?;
88            println!("{PI_TRUST_NOTE}");
89        }
90    }
91
92    Ok(ExitCode::SUCCESS)
93}
94
95/// File-surface agents (Claude, Codex) touched by this invocation. Install acts
96/// only on explicitly selected agents; a bare `install-hooks --uninstall` clears
97/// all file surfaces so it fully reverses a prior per-agent install.
98fn file_surface_agents(args: &cli::InstallHooksArgs) -> Vec<Agent> {
99    let mut agents = Vec::new();
100    if args.claude {
101        agents.push(Agent::Claude);
102    }
103    if args.codex {
104        agents.push(Agent::Codex);
105    }
106
107    if agents.is_empty() && args.uninstall && !args.pi {
108        return surface::FILE_SURFACE_AGENTS.to_vec();
109    }
110    agents
111}
112
113/// Whether this invocation should act on Pi: explicit `--pi`, or a bare
114/// `--uninstall` (no agent flags) that clears everything including legacy Pi files.
115fn pi_targeted(args: &cli::InstallHooksArgs) -> bool {
116    args.pi || (args.uninstall && !args.claude && !args.codex)
117}
118
119/// Remove a `.pi/hooks.json` left by an earlier (incorrect) truth-mirror version.
120fn remove_legacy_pi_hooks(repo_root: &Path) -> Result<()> {
121    let path = repo_root.join(".pi/hooks.json");
122    if path.is_file() {
123        fs::remove_file(&path)?;
124    }
125    Ok(())
126}
127
128pub fn dispatch(
129    args: cli::HookDispatchArgs,
130    state_dir: &Path,
131    config: &crate::config::TruthMirrorConfig,
132) -> Result<ExitCode> {
133    run_chained_hook(state_dir, args.hook, &args.args)?;
134
135    match args.hook {
136        HookName::CommitMsg => dispatch_commit_msg(state_dir, &args.args, config)?,
137        HookName::PostCommit => dispatch_post_commit(state_dir)?,
138        HookName::PrePush => dispatch_pre_push(state_dir, config)?,
139    }
140
141    Ok(ExitCode::SUCCESS)
142}
143
144#[derive(Clone, Debug, Default, Eq, PartialEq)]
145pub struct HookInstallPlan {
146    pub repo_root: PathBuf,
147    pub hooks_path: PathBuf,
148    pub uninstall: bool,
149    /// Global CLI flags (`--config`, `--state-dir`) preserved into the shims so a
150    /// custom-config install keeps using that config at hook runtime. Empty for a
151    /// default install (the trailing space is included when non-empty).
152    pub global_args: String,
153}
154
155impl HookInstallPlan {
156    pub fn new(repo_root: &Path, hooks_path: &Path, uninstall: bool) -> Self {
157        Self {
158            repo_root: repo_root.to_path_buf(),
159            hooks_path: hooks_path.to_path_buf(),
160            uninstall,
161            global_args: String::new(),
162        }
163    }
164
165    pub fn render(&self) -> String {
166        let action = if self.uninstall {
167            "uninstall"
168        } else {
169            "install"
170        };
171        let hooks = INSTALLED_HOOKS
172            .iter()
173            .map(|hook| hook.as_str())
174            .collect::<Vec<_>>()
175            .join(", ");
176        format!(
177            "truth-mirror hook plan\nrepo={}\naction={action}\nhooksPath={}\nhooks={hooks}",
178            self.repo_root.display(),
179            self.hooks_path.display()
180        )
181    }
182}
183
184/// Build the preserved global-flag prefix (with a trailing space when non-empty).
185fn hook_global_args(config_path: Option<&Path>, repo_root: &Path, state_dir: &Path) -> String {
186    let mut parts = Vec::new();
187    if let Some(config) = config_path {
188        parts.push(format!(
189            "--config {}",
190            quote_git_arg(&absolutize(repo_root, config))
191        ));
192    }
193    if state_dir != Path::new(".truth-mirror") {
194        parts.push(format!(
195            "--state-dir {}",
196            quote_git_arg(&absolutize(repo_root, state_dir))
197        ));
198    }
199    if parts.is_empty() {
200        String::new()
201    } else {
202        format!("{} ", parts.join(" "))
203    }
204}
205
206fn absolutize(repo_root: &Path, path: &Path) -> PathBuf {
207    if path.is_absolute() {
208        path.to_path_buf()
209    } else {
210        repo_root.join(path)
211    }
212}
213
214/// POSIX single-quote escaping for a path embedded in the generated `/bin/sh`
215/// shim: wrap in single quotes and replace any embedded single quote with `'\''`
216/// so no metacharacter (`;`, `$()`, `'`, spaces, …) can break or inject shell.
217fn quote_git_arg(path: &Path) -> String {
218    let value = path.to_string_lossy();
219    format!("'{}'", value.replace('\'', "'\\''"))
220}
221
222pub fn render_shim(hook: HookName, global_args: &str) -> String {
223    format!(
224        "#!/bin/sh\nexec truth-mirror {global_args}hook-dispatch {} \"$@\"\n",
225        hook.as_str()
226    )
227}
228
229fn install(plan: &HookInstallPlan) -> Result<()> {
230    fs::create_dir_all(&plan.hooks_path)?;
231    fs::create_dir_all(plan.hooks_path.join("chained"))?;
232    let git_hooks = plan.repo_root.join(".git/hooks");
233
234    for hook in INSTALLED_HOOKS {
235        let existing = git_hooks.join(hook.as_str());
236        if existing.is_file() {
237            let chained = plan.hooks_path.join("chained").join(hook.as_str());
238            fs::copy(&existing, chained)?;
239        }
240
241        let hook_path = plan.hooks_path.join(hook.as_str());
242        fs::write(&hook_path, render_shim(*hook, &plan.global_args))?;
243        make_executable(&hook_path)?;
244    }
245
246    git_config(&["config", "core.hooksPath", &path_for_git(&plan.hooks_path)])?;
247    Ok(())
248}
249
250fn uninstall(plan: &HookInstallPlan) -> Result<()> {
251    let _ = Command::new("git")
252        .args(["config", "--unset", "core.hooksPath"])
253        .current_dir(&plan.repo_root)
254        .status();
255
256    if plan.hooks_path.exists() {
257        fs::remove_dir_all(&plan.hooks_path)?;
258    }
259    Ok(())
260}
261
262fn dispatch_commit_msg(
263    state_dir: &Path,
264    args: &[String],
265    config: &crate::config::TruthMirrorConfig,
266) -> Result<()> {
267    let commit_msg_path = args
268        .first()
269        .context("commit-msg hook requires COMMIT_EDITMSG path")?;
270    let commit_message = fs::read_to_string(commit_msg_path)?;
271    let diff = git_stdout(&["diff", "--cached"])?;
272    let claim_file = fs::read_to_string(state_dir.join("claim.txt")).ok();
273    let policy = config.gates.to_policy();
274    claim::evaluate_commit_message(&commit_message, claim_file.as_deref(), Some(&diff), &policy)?;
275    Ok(())
276}
277
278fn dispatch_post_commit(state_dir: &Path) -> Result<()> {
279    let sha = git_stdout(&["rev-parse", "HEAD"])?;
280    ReviewQueue::new(state_dir).enqueue(sha.trim())?;
281    Ok(())
282}
283
284fn dispatch_pre_push(state_dir: &Path, config: &crate::config::TruthMirrorConfig) -> Result<()> {
285    let mut stdin = String::new();
286    io::stdin().read_to_string(&mut stdin)?;
287    for line in stdin.lines() {
288        if let Some(range) = pre_push_range_from_line(line) {
289            gate::run(
290                cli::GateArgs {
291                    pre_push: Some(range),
292                    commit_msg: None,
293                    claim_file: None,
294                    diff_file: None,
295                    fake_markers: Vec::new(),
296                    pre_tool_use: false,
297                    tool: None,
298                },
299                state_dir,
300                config,
301            )?;
302        }
303    }
304    Ok(())
305}
306
307fn pre_push_range_from_line(line: &str) -> Option<String> {
308    let parts = line.split_whitespace().collect::<Vec<_>>();
309    let local_sha = parts.get(1)?;
310    let remote_sha = parts.get(3)?;
311    if is_zero_sha(local_sha) {
312        return None;
313    }
314
315    if is_zero_sha(remote_sha) {
316        Some((*local_sha).to_owned())
317    } else {
318        Some(format!("{remote_sha}..{local_sha}"))
319    }
320}
321
322fn is_zero_sha(value: &str) -> bool {
323    value.chars().all(|character| character == '0')
324}
325
326fn run_chained_hook(state_dir: &Path, hook: HookName, args: &[String]) -> Result<()> {
327    let chained = state_dir.join("hooks/chained").join(hook.as_str());
328    if !chained.is_file() {
329        return Ok(());
330    }
331
332    let status = Command::new(&chained).args(args).status()?;
333    if !status.success() {
334        bail!(
335            "chained hook {} failed with status {status}",
336            chained.display()
337        );
338    }
339    Ok(())
340}
341
342fn git_root() -> Result<PathBuf> {
343    Ok(PathBuf::from(
344        git_stdout(&["rev-parse", "--show-toplevel"])?.trim(),
345    ))
346}
347
348fn git_stdout(args: &[&str]) -> Result<String> {
349    let output = Command::new("git").args(args).output()?;
350    if !output.status.success() {
351        bail!(
352            "git {} failed: {}",
353            args.join(" "),
354            String::from_utf8_lossy(&output.stderr)
355        );
356    }
357    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
358}
359
360fn git_config(args: &[&str]) -> Result<()> {
361    let status = Command::new("git").args(args).status()?;
362    if !status.success() {
363        bail!("git {} failed with status {status}", args.join(" "));
364    }
365    Ok(())
366}
367
368fn path_for_git(path: &Path) -> String {
369    path.to_string_lossy().into_owned()
370}
371
372#[cfg(unix)]
373fn make_executable(path: &Path) -> Result<()> {
374    use std::os::unix::fs::PermissionsExt;
375
376    let mut permissions = fs::metadata(path)?.permissions();
377    permissions.set_mode(0o755);
378    fs::set_permissions(path, permissions)?;
379    Ok(())
380}
381
382#[cfg(not(unix))]
383fn make_executable(_path: &Path) -> Result<()> {
384    Ok(())
385}
386
387#[cfg(test)]
388mod tests {
389    use proptest::prelude::*;
390
391    use super::{HookInstallPlan, pre_push_range_from_line, render_shim};
392    use crate::cli::HookName;
393
394    #[test]
395    fn hook_shim_is_only_exec_delegation() {
396        let shim = render_shim(HookName::CommitMsg, "");
397        let lines = shim.lines().collect::<Vec<_>>();
398
399        assert_eq!(lines.len(), 2);
400        assert_eq!(lines[0], "#!/bin/sh");
401        assert_eq!(
402            lines[1],
403            "exec truth-mirror hook-dispatch commit-msg \"$@\""
404        );
405    }
406
407    #[test]
408    fn quote_git_arg_escapes_shell_metacharacters() {
409        use std::path::Path;
410        assert_eq!(super::quote_git_arg(Path::new("/a/b.toml")), "'/a/b.toml'");
411        // metacharacters are neutralized by single-quoting
412        assert_eq!(
413            super::quote_git_arg(Path::new("/a/c;$(touch x).toml")),
414            "'/a/c;$(touch x).toml'"
415        );
416        // an embedded single quote is escaped as '\''
417        assert_eq!(
418            super::quote_git_arg(Path::new("/a/it's.toml")),
419            "'/a/it'\\''s.toml'"
420        );
421    }
422
423    #[test]
424    fn hook_shim_preserves_global_args() {
425        let shim = render_shim(HookName::PrePush, "--config /abs/enforce.toml ");
426        let lines = shim.lines().collect::<Vec<_>>();
427
428        assert_eq!(lines.len(), 2, "shim stays tiny + exec-only");
429        assert_eq!(
430            lines[1],
431            "exec truth-mirror --config /abs/enforce.toml hook-dispatch pre-push \"$@\""
432        );
433    }
434
435    #[test]
436    fn dry_run_plan_names_hooks_and_hooks_path() {
437        let plan = HookInstallPlan::new(
438            std::path::Path::new("/repo"),
439            std::path::Path::new("/repo/.truth-mirror/hooks"),
440            false,
441        );
442        let rendered = plan.render();
443
444        assert!(rendered.contains("commit-msg"));
445        assert!(rendered.contains("post-commit"));
446        assert!(rendered.contains("pre-push"));
447        assert!(rendered.contains("hooksPath=/repo/.truth-mirror/hooks"));
448    }
449
450    #[test]
451    fn pre_push_line_maps_to_git_range() {
452        let line = "refs/heads/main abc123 refs/heads/main def456";
453
454        assert_eq!(
455            pre_push_range_from_line(line),
456            Some("def456..abc123".to_owned())
457        );
458    }
459
460    proptest! {
461        #[test]
462        fn hook_shim_rendering_stays_tiny_exec_only(index in 0usize..3) {
463            let hook = [HookName::CommitMsg, HookName::PostCommit, HookName::PrePush][index];
464            let shim = render_shim(hook, "");
465            let lines = shim.lines().collect::<Vec<_>>();
466
467            prop_assert_eq!(lines.len(), 2);
468            prop_assert_eq!(lines[0], "#!/bin/sh");
469            prop_assert!(lines[1].starts_with("exec truth-mirror hook-dispatch "));
470            prop_assert!(lines[1].contains(hook.as_str()));
471        }
472    }
473}