1use 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
23const 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 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 if enforcement_enabled {
80 surface::install_enforcement(&repo_root, *agent, &plan.global_args)?;
81 }
82 }
83 if args.pi {
84 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
95fn 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
113fn pi_targeted(args: &cli::InstallHooksArgs) -> bool {
116 args.pi || (args.uninstall && !args.claude && !args.codex)
117}
118
119fn 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 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
184fn 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
214fn 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 assert_eq!(
413 super::quote_git_arg(Path::new("/a/c;$(touch x).toml")),
414 "'/a/c;$(touch x).toml'"
415 );
416 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}