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