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];
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
45const 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 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 if enforcement_enabled {
103 surface::install_enforcement(&repo_root, *agent, &plan.global_args)?;
104 }
105 }
106 if args.pi {
107 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
118fn 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
136fn pi_targeted(args: &cli::InstallHooksArgs) -> bool {
139 args.pi || (args.uninstall && !args.claude && !args.codex)
140}
141
142fn 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 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
260fn 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
290fn 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 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 assert_eq!(
925 super::quote_git_arg(Path::new("/a/c;$(touch x).toml")),
926 "'/a/c;$(touch x).toml'"
927 );
928 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}