1use std::{
4 fs,
5 io::{self, Read},
6 path::{Component, 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];
22pub(crate) const MANAGED_MARKER: &str = "# truth-mirror managed hook";
23const MANAGED_END_MARKER: &str = "# truth-mirror hook end";
24pub(crate) const FORWARDER_NAME: &str = "_forward-local.sh";
25const OUTER_HOOK_MARKERS: &[&str] = &[
26 "# Entire CLI hooks",
27 "# truth-mirror managed hook",
28 "entire hooks git",
29];
30const STATE_GITIGNORE_HEADER: &str = "# truth-mirror runtime state - machine-generated";
31const STATE_GITIGNORE_LINES: &[&str] = &[
32 "*",
33 "!.gitignore",
34 "!config.toml",
35 "runs/",
36 "review-queue.jsonl",
37 "ledger.jsonl",
38 "ledger.md",
39 "tmp/",
40 "logs/",
41 "*.local.*",
42];
43pub(crate) const FORWARDER_SOURCE: &str = r#"#!/bin/sh
44# _forward-local.sh - delegate to .git/hooks from a committed core.hooksPath.
45# Uses --git-common-dir (resolves to .git/) not core.hooksPath to prevent an infinite loop.
46name="$1"; shift
47git_dir="$(git rev-parse --git-common-dir 2>/dev/null)"
48[ -n "$git_dir" ] || exit 0
49local_hook="$git_dir/hooks/$name"
50if [ -x "$local_hook" ]; then exec "$local_hook" "$@"; fi
51exit 0
52"#;
53
54const PI_TRUST_NOTE: &str = "pi: installed .pi/extensions/truth-mirror.js — Pi loads it once you trust this project folder in Pi.";
57
58const GROK_SKILL_NOTE: &str = "grok: installed .grok/skills/truth-mirror/SKILL.md — Grok does not inject passive hook stdout; run `truth-mirror reinject --agent grok` (skill reminds the agent). Project hooks need `/hooks-trust` if enforcement hooks are installed.";
60
61pub fn run(
62 args: cli::InstallHooksArgs,
63 state_dir: &Path,
64 config_path: Option<&Path>,
65 config: &crate::config::TruthMirrorConfig,
66) -> Result<ExitCode> {
67 let repo_root = git_root()?;
68 let hooks_path = repo_root.join(state_dir).join("hooks");
69 let plan = HookInstallPlan::new(&repo_root, &hooks_path, args.uninstall);
70 let global_args = hook_global_args(config_path, &repo_root, state_dir);
73 let plan = HookInstallPlan {
74 global_args,
75 git_hooks_path: git_common_dir()?.join("hooks"),
76 mode: detect_hook_mode(&repo_root, state_dir)?,
77 ..plan
78 };
79 let agents = file_surface_agents(&args);
80 let pi = pi_targeted(&args);
81 let grok = grok_targeted(&args);
82
83 if args.dry_run {
84 println!("{}", plan.render());
85 for agent in &agents {
86 println!(
87 "surface: {} -> {}",
88 surface::agent_slug(*agent),
89 surface::surface_relative_path(*agent)
90 );
91 }
92 if pi {
93 println!("surface: pi -> {}", surface::PI_EXTENSION_RELATIVE);
94 }
95 if grok {
96 println!("surface: grok -> {}", surface::GROK_SKILL_RELATIVE);
97 if config.enforcement.is_enabled() {
98 println!(
99 "surface: grok-enforcement -> {}",
100 surface::surface_relative_path(Agent::Grok)
101 );
102 }
103 }
104 return Ok(ExitCode::SUCCESS);
105 }
106
107 let enforcement_enabled = config.enforcement.is_enabled();
108
109 if args.uninstall {
110 uninstall(&plan)?;
111 for agent in &agents {
112 surface::uninstall_enforcement(&repo_root, *agent)?;
113 SurfacePlan::for_agent(&repo_root, *agent).uninstall()?;
114 }
115 if pi {
116 surface::uninstall_pi_extension(&repo_root)?;
117 remove_legacy_pi_hooks(&repo_root)?;
118 }
119 if grok {
120 if let Err(error) = surface::uninstall_enforcement(&repo_root, Agent::Grok) {
123 eprintln!(
124 "{} grok enforcement uninstall failed (continuing skill cleanup): {error}",
125 crate::messages::diagnostic_prefix()
126 );
127 }
128 surface::uninstall_grok_skill(&repo_root)?;
129 surface::cleanup_empty_grok_dirs(&repo_root);
130 }
131 uninstall_legacy_hook_references_at(&repo_root, &plan.git_hooks_path)?;
132 } else {
133 install(&plan, args.inject_forwarder)?;
134 print_async_review_guidance(&plan.global_args);
135 for agent in &agents {
136 SurfacePlan::for_agent(&repo_root, *agent).install()?;
137 if enforcement_enabled {
140 surface::install_enforcement(&repo_root, *agent, &plan.global_args)?;
141 }
142 }
143 if args.pi {
144 remove_legacy_pi_hooks(&repo_root)?;
147 surface::install_pi_extension(&repo_root)?;
148 println!("{PI_TRUST_NOTE}");
149 }
150 if args.grok {
151 surface::install_grok_skill(&repo_root)?;
154 if enforcement_enabled {
155 surface::install_enforcement(&repo_root, Agent::Grok, &plan.global_args)?;
156 }
157 println!("{GROK_SKILL_NOTE}");
158 }
159 }
160
161 Ok(ExitCode::SUCCESS)
162}
163
164fn print_async_review_guidance(global_args: &str) {
165 eprintln!(
166 "{} post-commit hooks queue async reviews; {}.",
167 crate::messages::diagnostic_prefix(),
168 crate::messages::async_review_drain_hint(global_args)
169 );
170}
171
172pub fn run_uninstall(
181 args: cli::UninstallArgs,
182 state_dir: &Path,
183 config_path: Option<&Path>,
184) -> Result<std::process::ExitCode> {
185 let repo_root = git_root()?;
186 let hooks_path = repo_root.join(state_dir).join("hooks");
187 let global_args = hook_global_args(config_path, &repo_root, state_dir);
188 let plan = HookInstallPlan {
189 repo_root: repo_root.clone(),
190 hooks_path: hooks_path.clone(),
191 git_hooks_path: git_common_dir()?.join("hooks"),
192 uninstall: true,
193 mode: detect_hook_mode(&repo_root, state_dir)?,
194 global_args,
195 };
196
197 if args.dry_run {
198 println!("{}", plan.render());
199 let agent_list = surface::FILE_SURFACE_AGENTS
200 .iter()
201 .map(|agent| surface::agent_slug(*agent))
202 .chain(["pi", "grok"])
203 .collect::<Vec<_>>()
204 .join(", ");
205 println!("uninstall: would remove all agent surfaces ({agent_list})");
206 println!(
207 "uninstall: would clean .pre-entire bridges and orphaned .pre-truth-mirror backups"
208 );
209 println!("uninstall: would remove legacy .truth-mirror/hooks/ install artifacts");
210 println!(
211 "uninstall: would scrub legacy truth-mirror hook references without managed markers"
212 );
213 if args.purge {
214 println!(
215 "uninstall: --purge: would delete {} and .truth-mirror/ entirely",
216 state_dir.display()
217 );
218 } else {
219 println!(
220 "uninstall: ledger data in {} is preserved (use --purge to delete)",
221 state_dir.display()
222 );
223 }
224 return Ok(std::process::ExitCode::SUCCESS);
225 }
226
227 uninstall(&plan)?;
229 if !matches!(plan.mode, HookInstallMode::Plain) {
234 uninstall_plain(&plan)?;
235 }
236
237 let mut surface_errors: Vec<String> = Vec::new();
239 for &agent in surface::FILE_SURFACE_AGENTS.iter() {
240 if let Err(error) = surface::uninstall_enforcement(&repo_root, agent) {
241 surface_errors.push(format!(
242 "enforcement/{}: {error}",
243 surface::agent_slug(agent)
244 ));
245 }
246 if let Err(error) = surface::SurfacePlan::for_agent(&repo_root, agent).uninstall() {
247 surface_errors.push(format!("surface/{}: {error}", surface::agent_slug(agent)));
248 }
249 }
250 if let Err(error) = surface::uninstall_pi_extension(&repo_root) {
252 surface_errors.push(format!("surface/pi-extension: {error}"));
253 } else {
254 let pi_extensions = repo_root.join(".pi/extensions");
256 let pi_dir = repo_root.join(".pi");
257 let _ = fs::remove_dir(&pi_extensions);
259 let _ = fs::remove_dir(&pi_dir);
260 }
261 if let Err(error) = surface::uninstall_enforcement(&repo_root, Agent::Grok) {
263 surface_errors.push(format!("enforcement/grok: {error}"));
264 }
265 if let Err(error) = surface::uninstall_grok_skill(&repo_root) {
266 surface_errors.push(format!("surface/grok-skill: {error}"));
267 } else {
268 surface::cleanup_empty_grok_dirs(&repo_root);
269 }
270 if let Err(error) = remove_legacy_pi_hooks(&repo_root) {
272 surface_errors.push(format!("surface/pi-legacy: {error}"));
273 }
274
275 if let Err(e) = uninstall_pre_entire_bridges(&plan) {
280 surface_errors.push(format!("bridges: {e}"));
281 }
282
283 let git_hooks = &plan.git_hooks_path;
285 if let Err(e) = cleanup_orphaned_backups(git_hooks) {
286 surface_errors.push(format!("backups/{}: {e}", git_hooks.display()));
287 }
288 if let HookInstallMode::CustomCommitted { ref hooks_path } = plan.mode.clone()
289 && let Err(e) = cleanup_orphaned_backups(hooks_path)
290 {
291 surface_errors.push(format!("backups/{}: {e}", hooks_path.display()));
292 }
293
294 let legacy_state = repo_root.join(crate::config::LEGACY_STATE_DIR);
296 let legacy_hooks_dir = legacy_state.join("hooks");
297 if legacy_hooks_dir.is_dir()
298 && let Err(e) = fs::remove_dir_all(&legacy_hooks_dir)
299 {
300 surface_errors.push(format!("legacy-hooks-dir: {e}"));
301 }
302
303 let real_state_dir = absolutize(&repo_root, state_dir);
305 remove_state_gitignore_if_pristine(&real_state_dir);
306
307 if args.purge {
309 if real_state_dir.is_dir()
310 && let Err(e) = fs::remove_dir_all(&real_state_dir)
311 {
312 surface_errors.push(format!("purge {}: {e}", real_state_dir.display()));
313 }
314 if legacy_state.is_dir()
315 && let Err(e) = fs::remove_dir_all(&legacy_state)
316 {
317 surface_errors.push(format!("purge {}: {e}", legacy_state.display()));
318 }
319 }
320
321 if let Err(e) = uninstall_legacy_hook_references_at(&repo_root, &plan.git_hooks_path) {
326 surface_errors.push(format!("legacy-hook-refs: {e}"));
327 }
328
329 if !surface_errors.is_empty() {
334 for error in &surface_errors {
335 eprintln!(
336 "{} uninstall error: {error}",
337 crate::messages::diagnostic_prefix()
338 );
339 }
340 return Ok(std::process::ExitCode::FAILURE);
341 }
342
343 Ok(std::process::ExitCode::SUCCESS)
344}
345
346fn uninstall_pre_entire_bridges(plan: &HookInstallPlan) -> Result<()> {
350 let dirs: Vec<PathBuf> = match &plan.mode {
351 HookInstallMode::Husky { content_dir } => {
352 vec![plan.git_hooks_path.clone(), content_dir.clone()]
353 }
354 _ => vec![plan.git_hooks_path.clone()],
355 };
356 for dir in dirs {
357 for hook in INSTALLED_HOOKS {
358 let bridge = dir.join(format!("{}.pre-entire", hook.as_str()));
359 if read_to_string_if_file(&bridge)?
360 .as_deref()
361 .is_some_and(is_truth_mirror_managed)
362 {
363 remove_file_if_exists(&bridge)?;
364 }
365 }
366 }
367 Ok(())
368}
369
370fn cleanup_orphaned_backups(hooks_dir: &Path) -> Result<()> {
385 if !hooks_dir.is_dir() {
386 return Ok(());
387 }
388 for hook in INSTALLED_HOOKS {
389 let active = hooks_dir.join(hook.as_str());
390 let backup = backup_path(&active, "pre-truth-mirror");
391 if !backup.is_file() {
392 continue;
393 }
394
395 let backup_bytes = match fs::read(&backup) {
397 Ok(b) => b,
398 Err(e) => {
399 eprintln!(
400 "{} warning: could not read backup {}; leaving it in place: {e}",
401 crate::messages::diagnostic_prefix(),
402 backup.display(),
403 );
404 continue;
405 }
406 };
407
408 if active.is_dir() {
410 eprintln!(
411 "{} warning: active hook path {} is a directory, not a file; \
412 leaving backup {} in place.",
413 crate::messages::diagnostic_prefix(),
414 active.display(),
415 backup.display(),
416 );
417 continue;
418 }
419
420 let active_bytes = match read_bytes_if_file(&active) {
422 Ok(b) => b,
423 Err(e) => {
424 eprintln!(
425 "{} warning: could not read active hook {}; leaving backup {} in place: {e}",
426 crate::messages::diagnostic_prefix(),
427 active.display(),
428 backup.display(),
429 );
430 continue;
431 }
432 };
433
434 let active_str = active_bytes
438 .as_deref()
439 .and_then(|b| std::str::from_utf8(b).ok());
440 if active_str.is_some_and(is_truth_mirror_managed) {
441 continue;
442 }
443
444 match active_bytes {
445 None => {
446 fs::write(&active, &backup_bytes)?;
448 make_executable(&active)?;
449 fs::remove_file(&backup)?;
450 }
451 Some(ref ab) if ab == &backup_bytes => {
452 fs::remove_file(&backup)?;
454 }
455 Some(_) => {
456 eprintln!(
458 "{} warning: preserved {} because its content differs from the active \
459 hook {}; inspect and remove manually.",
460 crate::messages::diagnostic_prefix(),
461 backup.display(),
462 active.display(),
463 );
464 }
465 }
466 }
467 Ok(())
468}
469
470fn remove_state_gitignore_if_pristine(state_dir: &Path) {
473 let gitignore = state_dir.join(".gitignore");
474 match read_to_string_if_file(&gitignore) {
475 Ok(Some(content)) if content.starts_with(STATE_GITIGNORE_HEADER) => {
476 if let Err(error) = remove_file_if_exists(&gitignore) {
477 eprintln!(
478 "{} warning: could not remove state .gitignore {}: {error}",
479 crate::messages::diagnostic_prefix(),
480 gitignore.display(),
481 );
482 }
483 }
484 Ok(Some(_)) => {
485 eprintln!(
486 "{} hint: {} appears user-modified; leaving it in place.",
487 crate::messages::diagnostic_prefix(),
488 gitignore.display(),
489 );
490 }
491 _ => {}
492 }
493}
494
495fn file_surface_agents(args: &cli::InstallHooksArgs) -> Vec<Agent> {
499 let mut agents = Vec::new();
500 if args.claude {
501 agents.push(Agent::Claude);
502 }
503 if args.codex {
504 agents.push(Agent::Codex);
505 }
506
507 if agents.is_empty() && args.uninstall && !args.pi && !args.grok {
508 return surface::FILE_SURFACE_AGENTS.to_vec();
509 }
510 agents
511}
512
513fn pi_targeted(args: &cli::InstallHooksArgs) -> bool {
516 args.pi || (args.uninstall && !args.claude && !args.codex && !args.grok)
517}
518
519fn grok_targeted(args: &cli::InstallHooksArgs) -> bool {
521 args.grok || (args.uninstall && !args.claude && !args.codex && !args.pi)
522}
523
524fn remove_legacy_pi_hooks(repo_root: &Path) -> Result<()> {
526 let path = repo_root.join(".pi/hooks.json");
527 if path.is_file() {
528 fs::remove_file(&path)?;
529 }
530 Ok(())
531}
532
533pub fn dispatch(
534 args: cli::HookDispatchArgs,
535 state_dir: &Path,
536 config_path: Option<&Path>,
537 config: &crate::config::TruthMirrorConfig,
538) -> Result<ExitCode> {
539 run_chained_hook(state_dir, args.hook, &args.args)?;
540
541 match args.hook {
542 HookName::CommitMsg => dispatch_commit_msg(state_dir, &args.args, config)?,
543 HookName::PostCommit => dispatch_post_commit(state_dir)?,
544 HookName::PrePush => dispatch_pre_push(state_dir, config_path, config, &args.args)?,
545 }
546
547 Ok(ExitCode::SUCCESS)
548}
549
550#[derive(Clone, Debug, Default, Eq, PartialEq)]
551pub struct HookInstallPlan {
552 pub repo_root: PathBuf,
553 pub hooks_path: PathBuf,
554 pub git_hooks_path: PathBuf,
557 pub uninstall: bool,
558 pub mode: HookInstallMode,
559 pub global_args: String,
563}
564
565#[derive(Clone, Debug, Default, Eq, PartialEq)]
566pub enum HookInstallMode {
567 #[default]
568 Plain,
569 LegacyTruthMirrorHooksPath {
570 configured_path: String,
571 },
572 Husky {
573 content_dir: PathBuf,
574 },
575 CustomCommitted {
576 hooks_path: PathBuf,
577 },
578}
579
580impl HookInstallPlan {
581 pub fn new(repo_root: &Path, hooks_path: &Path, uninstall: bool) -> Self {
582 Self {
583 repo_root: repo_root.to_path_buf(),
584 hooks_path: hooks_path.to_path_buf(),
585 git_hooks_path: repo_root.join(".git/hooks"),
586 uninstall,
587 mode: HookInstallMode::Plain,
588 global_args: String::new(),
589 }
590 }
591
592 pub fn render(&self) -> String {
593 let action = if self.uninstall {
594 "uninstall"
595 } else {
596 "install"
597 };
598 let mut output = format!(
599 "truth-mirror hook plan\nrepo={}\naction={action}\nmode={}\nstateHooks={}\n",
600 self.repo_root.display(),
601 self.mode.as_str(),
602 self.hooks_path.display()
603 );
604 for hook in INSTALLED_HOOKS {
605 output.push_str(&format!(
606 "\nhook={}\npath={}\nbody:\n{}",
607 hook.as_str(),
608 self.active_hook_path(*hook).display(),
609 self.render_hook_body(*hook)
610 ));
611 }
612 output
613 }
614
615 fn active_hook_path(&self, hook: HookName) -> PathBuf {
616 match &self.mode {
617 HookInstallMode::Husky { content_dir } => content_dir.join(hook.as_str()),
618 HookInstallMode::Plain | HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
619 self.git_hooks_path.join(hook.as_str())
620 }
621 HookInstallMode::CustomCommitted { hooks_path } => hooks_path.join(hook.as_str()),
622 }
623 }
624
625 fn render_hook_body(&self, hook: HookName) -> String {
626 match self.mode {
627 HookInstallMode::Husky { .. } => render_husky_block(hook, &self.global_args),
628 HookInstallMode::CustomCommitted { .. } => render_custom_forwarder_stub(hook),
629 HookInstallMode::Plain | HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
630 render_shim(hook, &self.global_args)
631 }
632 }
633 }
634}
635
636impl HookInstallMode {
637 fn as_str(&self) -> &'static str {
638 match self {
639 HookInstallMode::Plain => "plain",
640 HookInstallMode::LegacyTruthMirrorHooksPath { .. } => "plain-legacy-truth-mirror",
641 HookInstallMode::Husky { .. } => "husky",
642 HookInstallMode::CustomCommitted { .. } => "custom-committed",
643 }
644 }
645}
646
647fn hook_global_args(config_path: Option<&Path>, repo_root: &Path, state_dir: &Path) -> String {
649 let mut parts = Vec::new();
650 if let Some(config) = config_path {
651 parts.push(format!(
652 "--config {}",
653 quote_git_arg(&absolutize(repo_root, config))
654 ));
655 }
656 if state_dir != Path::new(crate::config::DEFAULT_STATE_DIR) {
657 parts.push(format!(
658 "--state-dir {}",
659 quote_git_arg(&absolutize(repo_root, state_dir))
660 ));
661 }
662 if parts.is_empty() {
663 String::new()
664 } else {
665 format!("{} ", parts.join(" "))
666 }
667}
668
669fn absolutize(repo_root: &Path, path: &Path) -> PathBuf {
670 if path.is_absolute() {
671 path.to_path_buf()
672 } else {
673 repo_root.join(path)
674 }
675}
676
677fn quote_git_arg(path: &Path) -> String {
681 let value = path.to_string_lossy();
682 format!("'{}'", value.replace('\'', "'\\''"))
683}
684
685pub fn render_shim(hook: HookName, global_args: &str) -> String {
686 format!(
687 "#!/bin/sh\n{MANAGED_MARKER}\nexec truth-mirror {global_args}hook-dispatch {} \"$@\"\n",
688 hook.as_str()
689 )
690}
691
692fn render_husky_block(hook: HookName, global_args: &str) -> String {
693 format!(
694 "{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",
695 hook.as_str()
696 )
697}
698
699fn render_custom_forwarder_stub(hook: HookName) -> String {
700 format!(
701 "#!/bin/sh\n{MANAGED_MARKER}\nexec \"$(dirname \"$0\")/{FORWARDER_NAME}\" {} \"$@\"\n",
702 hook.as_str()
703 )
704}
705
706fn detect_hook_mode(repo_root: &Path, state_dir: &Path) -> Result<HookInstallMode> {
707 let Some(configured) = git_config_get(repo_root, "core.hooksPath")? else {
708 return Ok(HookInstallMode::Plain);
709 };
710 let configured = configured.trim().to_owned();
711 if configured.is_empty() {
712 return Ok(HookInstallMode::Plain);
713 }
714 if is_managed_hooks_path(repo_root, state_dir, &configured) {
715 return Ok(HookInstallMode::LegacyTruthMirrorHooksPath {
716 configured_path: configured,
717 });
718 }
719 if configured.contains(".husky") {
720 let hooks_path = repo_relative_path(repo_root, Path::new(&configured));
721 let content_dir = if hooks_path.file_name().and_then(|name| name.to_str()) == Some("_") {
722 hooks_path
723 .parent()
724 .map_or_else(|| repo_root.join(".husky"), Path::to_path_buf)
725 } else {
726 hooks_path
727 };
728 return Ok(HookInstallMode::Husky { content_dir });
729 }
730 Ok(HookInstallMode::CustomCommitted {
731 hooks_path: repo_relative_path(repo_root, Path::new(&configured)),
732 })
733}
734
735fn repo_relative_path(repo_root: &Path, path: &Path) -> PathBuf {
736 if path.is_absolute() {
737 path.to_path_buf()
738 } else {
739 repo_root.join(path)
740 }
741}
742
743fn is_managed_hooks_path(repo_root: &Path, state_dir: &Path, configured: &str) -> bool {
744 let configured_path = normalize_path(&repo_relative_path(repo_root, Path::new(configured)));
745 [
746 state_dir.join("hooks"),
747 PathBuf::from(crate::config::DEFAULT_STATE_DIR).join("hooks"),
748 PathBuf::from(crate::config::LEGACY_STATE_DIR).join("hooks"),
749 ]
750 .iter()
751 .any(|candidate| normalize_path(&repo_relative_path(repo_root, candidate)) == configured_path)
752}
753
754fn normalize_path(path: &Path) -> PathBuf {
755 let mut normalized = PathBuf::new();
756 for component in path.components() {
757 match component {
758 Component::CurDir => {}
759 Component::ParentDir => {
760 if normalized.as_os_str().is_empty()
761 || (!normalized.has_root() && normalized.ends_with(".."))
762 {
763 normalized.push("..");
764 } else {
765 normalized.pop();
766 }
767 }
768 Component::Normal(part) => normalized.push(part),
769 Component::RootDir | Component::Prefix(_) => normalized.push(component.as_os_str()),
770 }
771 }
772 normalized
773}
774
775fn install(plan: &HookInstallPlan, inject_forwarder: bool) -> Result<()> {
776 let state_dir = state_dir_from_hooks_path(&plan.hooks_path);
777 match &plan.mode {
778 HookInstallMode::Plain => {
779 prepare_state_hooks(plan)?;
780 migrate_truth_md(&plan.repo_root, state_dir)?;
781 install_plain(plan)
782 }
783 HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
784 prepare_state_hooks(plan)?;
785 migrate_truth_md(&plan.repo_root, state_dir)?;
786 unset_core_hooks_path(&plan.repo_root)?;
787 install_plain(plan)
788 }
789 HookInstallMode::Husky { content_dir } => {
790 prepare_state_hooks(plan)?;
791 migrate_truth_md(&plan.repo_root, state_dir)?;
792 install_husky(plan, content_dir)
793 }
794 HookInstallMode::CustomCommitted { hooks_path } => {
795 install_custom(plan, hooks_path, inject_forwarder)
796 }
797 }
798}
799
800fn uninstall(plan: &HookInstallPlan) -> Result<()> {
801 match &plan.mode {
802 HookInstallMode::Plain => uninstall_plain(plan)?,
803 HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
804 unset_core_hooks_path(&plan.repo_root)?;
805 uninstall_plain(plan)?;
806 }
807 HookInstallMode::Husky { content_dir } => uninstall_husky(plan, content_dir)?,
808 HookInstallMode::CustomCommitted { hooks_path } => {
809 uninstall_plain(plan)?;
810 uninstall_custom_forwarders(hooks_path)?;
811 }
812 }
813
814 if plan.hooks_path.exists() {
815 fs::remove_dir_all(&plan.hooks_path)?;
816 }
817 Ok(())
818}
819
820fn state_dir_from_hooks_path(hooks_path: &Path) -> &Path {
821 hooks_path.parent().unwrap_or(hooks_path)
822}
823
824fn prepare_state_hooks(plan: &HookInstallPlan) -> Result<()> {
825 ensure_state_gitignore(&plan.repo_root, state_dir_from_hooks_path(&plan.hooks_path))?;
826 fs::create_dir_all(&plan.hooks_path)?;
827 fs::create_dir_all(plan.hooks_path.join("chained"))?;
828 clear_chained_hooks(plan)
829}
830
831fn install_plain(plan: &HookInstallPlan) -> Result<()> {
832 fs::create_dir_all(&plan.git_hooks_path)?;
833 for hook in INSTALLED_HOOKS {
834 install_local_hook(plan, &plan.git_hooks_path, *hook)?;
835 }
836 Ok(())
837}
838
839fn install_local_hook(plan: &HookInstallPlan, git_hooks: &Path, hook: HookName) -> Result<()> {
840 let active = git_hooks.join(hook.as_str());
841 let backup = backup_path(&active, "pre-truth-mirror");
842 let chained = plan.hooks_path.join("chained").join(hook.as_str());
843 remove_file_if_exists(&chained)?;
844
845 let source = local_hook_source(&active, &backup)?;
846 if let Some(source) = source {
847 if source.path != backup {
848 fs::copy(&source.path, &backup)?;
849 make_executable(&backup)?;
850 }
851 if !has_outer_hook_marker(&source.content) {
852 fs::copy(&source.path, &chained)?;
853 make_executable(&chained)?;
854 }
855 }
856
857 fs::write(&active, render_shim(hook, &plan.global_args))?;
858 make_executable(&active)?;
859 Ok(())
860}
861
862fn uninstall_plain(plan: &HookInstallPlan) -> Result<()> {
863 for hook in INSTALLED_HOOKS {
864 let active = plan.git_hooks_path.join(hook.as_str());
865 let backup = backup_path(&active, "pre-truth-mirror");
866 let active_content = read_to_string_if_file(&active)?;
867 if active_content
868 .as_deref()
869 .is_some_and(is_truth_mirror_managed)
870 {
871 if backup.is_file() {
872 fs::copy(&backup, &active)?;
873 make_executable(&active)?;
874 fs::remove_file(&backup)?;
875 } else {
876 remove_file_if_exists(&active)?;
877 }
878 }
879 remove_file_if_exists(&plan.hooks_path.join("chained").join(hook.as_str()))?;
880 }
881 Ok(())
882}
883
884fn install_husky(plan: &HookInstallPlan, content_dir: &Path) -> Result<()> {
885 fs::create_dir_all(content_dir)?;
886 for hook in INSTALLED_HOOKS {
887 let target = content_dir.join(hook.as_str());
888 let block = render_husky_block(*hook, &plan.global_args);
889 let next = match read_to_string_if_file(&target)? {
890 Some(content) => append_managed_block(&content, &block),
891 None => format!("#!/bin/sh\n{block}"),
892 };
893 fs::write(&target, next)?;
894 make_executable(&target)?;
895 }
896 Ok(())
897}
898
899fn uninstall_husky(_plan: &HookInstallPlan, content_dir: &Path) -> Result<()> {
900 for hook in INSTALLED_HOOKS {
901 let target = content_dir.join(hook.as_str());
902 if let Some(content) = read_to_string_if_file(&target)? {
903 let next = remove_managed_block(&content);
904 if is_empty_or_shebang_only(&next) {
905 fs::remove_file(&target)?;
906 } else {
907 fs::write(&target, next)?;
908 make_executable(&target)?;
909 }
910 }
911 }
912 Ok(())
913}
914
915fn install_custom(plan: &HookInstallPlan, hooks_path: &Path, inject_forwarder: bool) -> Result<()> {
916 let missing_forwarder = INSTALLED_HOOKS
917 .iter()
918 .any(|hook| !custom_forwarder_present(hooks_path, *hook));
919 if missing_forwarder && !inject_forwarder {
920 bail!(
921 "{} core.hooksPath is set to '{}' (a non-husky committed directory).\nTo install, either:\n (a) run `{}` 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",
922 crate::messages::diagnostic_prefix(),
923 hooks_path.display(),
924 crate::messages::command_for_cli(&plan.global_args, "install-hooks --inject-forwarder"),
925 );
926 }
927
928 if inject_forwarder {
929 inject_custom_forwarders(hooks_path)?;
930 }
931
932 prepare_state_hooks(plan)?;
933 let state_dir = state_dir_from_hooks_path(&plan.hooks_path);
934 migrate_truth_md(&plan.repo_root, state_dir)?;
935 fs::create_dir_all(&plan.git_hooks_path)?;
936 for hook in INSTALLED_HOOKS {
937 install_local_hook(plan, &plan.git_hooks_path, *hook)?;
938 }
939 Ok(())
940}
941
942fn inject_custom_forwarders(hooks_path: &Path) -> Result<()> {
943 fs::create_dir_all(hooks_path)?;
944 let forwarder = hooks_path.join(FORWARDER_NAME);
945 fs::write(&forwarder, FORWARDER_SOURCE)?;
946 make_executable(&forwarder)?;
947
948 for hook in INSTALLED_HOOKS {
949 let target = hooks_path.join(hook.as_str());
950 if custom_forwarder_present(hooks_path, *hook) {
951 continue;
952 }
953 if target.is_file() {
954 fs::copy(&target, backup_path(&target, "pre-truth-mirror"))?;
955 }
956 fs::write(&target, render_custom_forwarder_stub(*hook))?;
957 make_executable(&target)?;
958 }
959 eprintln!(
960 "hint: commit the truth-mirror forwarder changes in {}",
961 hooks_path.display()
962 );
963 Ok(())
964}
965
966fn uninstall_custom_forwarders(hooks_path: &Path) -> Result<()> {
967 for hook in INSTALLED_HOOKS {
968 let target = hooks_path.join(hook.as_str());
969 let backup = backup_path(&target, "pre-truth-mirror");
970 let content = read_to_string_if_file(&target)?;
971 if content.as_deref().is_some_and(is_truth_mirror_managed) {
972 if backup.is_file() {
973 fs::copy(&backup, &target)?;
974 make_executable(&target)?;
975 fs::remove_file(&backup)?;
976 } else {
977 remove_file_if_exists(&target)?;
978 }
979 }
980 }
981 let forwarder = hooks_path.join(FORWARDER_NAME);
982 if read_to_string_if_file(&forwarder)?.as_deref() == Some(FORWARDER_SOURCE) {
983 fs::remove_file(forwarder)?;
984 }
985 Ok(())
986}
987
988fn append_managed_block(content: &str, block: &str) -> String {
989 let mut next = remove_managed_block(content);
990 if !next.ends_with('\n') {
991 next.push('\n');
992 }
993 next.push_str(block);
994 next
995}
996
997fn remove_managed_block(content: &str) -> String {
998 let mut out = Vec::new();
999 let mut skipping = false;
1000 for line in content.lines() {
1001 if line == MANAGED_MARKER {
1002 skipping = true;
1003 continue;
1004 }
1005 if skipping {
1006 if line == MANAGED_END_MARKER {
1007 skipping = false;
1008 }
1009 continue;
1010 }
1011 out.push(line);
1012 }
1013 if out.is_empty() {
1014 String::new()
1015 } else {
1016 format!("{}\n", out.join("\n"))
1017 }
1018}
1019
1020fn is_empty_or_shebang_only(content: &str) -> bool {
1021 let lines = content
1022 .lines()
1023 .map(str::trim)
1024 .filter(|line| !line.is_empty())
1025 .collect::<Vec<_>>();
1026 lines.is_empty() || lines == ["#!/bin/sh"]
1027}
1028
1029fn shell_syntax_ok(path: &Path) -> bool {
1031 std::process::Command::new("sh")
1032 .args(["-n"])
1033 .arg(path)
1034 .status()
1035 .map(|status| status.success())
1036 .unwrap_or(false)
1037}
1038
1039fn is_effectively_empty_shell(content: &str) -> bool {
1043 content
1044 .lines()
1045 .map(str::trim)
1046 .filter(|line| !line.is_empty() && !line.starts_with('#'))
1047 .all(|line| line == "#!/bin/sh" || line == "#!/usr/bin/env sh" || line.starts_with("set "))
1048}
1049
1050#[derive(Debug)]
1051struct LocalHookSource {
1052 path: PathBuf,
1053 content: String,
1054}
1055
1056fn local_hook_source(active: &Path, backup: &Path) -> Result<Option<LocalHookSource>> {
1057 let Some(active_content) = read_to_string_if_file(active)? else {
1058 return Ok(None);
1059 };
1060 if is_truth_mirror_managed(&active_content) {
1061 return read_to_string_if_file(backup)?.map_or(Ok(None), |content| {
1062 Ok(Some(LocalHookSource {
1063 path: backup.to_path_buf(),
1064 content,
1065 }))
1066 });
1067 }
1068 Ok(Some(LocalHookSource {
1069 path: active.to_path_buf(),
1070 content: active_content,
1071 }))
1072}
1073
1074fn custom_forwarder_present(hooks_path: &Path, hook: HookName) -> bool {
1075 read_to_string_if_file(&hooks_path.join(hook.as_str()))
1076 .ok()
1077 .flatten()
1078 .is_some_and(|content| {
1079 active_hook_lines(&content).any(|line| line.contains(FORWARDER_NAME))
1080 || content_forwards_to_local_git_hook(&content, hook)
1081 || is_truth_mirror_managed(&content)
1082 })
1083}
1084
1085fn ensure_state_gitignore(repo_root: &Path, state_dir: &Path) -> Result<()> {
1086 let state_dir = absolutize(repo_root, state_dir);
1087 fs::create_dir_all(&state_dir)?;
1088 let gitignore = state_dir.join(".gitignore");
1089 let existing = read_to_string_if_file(&gitignore)?.unwrap_or_default();
1090 let content = render_state_gitignore(&existing);
1091 fs::write(&gitignore, content)?;
1092 print_tracked_runtime_hints(repo_root, &state_dir)?;
1093 Ok(())
1094}
1095
1096fn render_state_gitignore(existing: &str) -> String {
1097 let mut content = String::new();
1098 content.push_str(STATE_GITIGNORE_HEADER);
1099 content.push('\n');
1100 for line in STATE_GITIGNORE_LINES {
1101 content.push_str(line);
1102 content.push('\n');
1103 }
1104
1105 let standard = |line: &str| {
1106 let trimmed = line.trim();
1107 trimmed == STATE_GITIGNORE_HEADER || STATE_GITIGNORE_LINES.contains(&trimmed)
1108 };
1109
1110 let mut wrote_separator = false;
1111 for line in existing.lines().filter(|line| !standard(line)) {
1112 if !wrote_separator {
1113 content.push('\n');
1114 wrote_separator = true;
1115 }
1116 content.push_str(line);
1117 content.push('\n');
1118 }
1119
1120 content
1121}
1122
1123fn print_tracked_runtime_hints(repo_root: &Path, state_dir: &Path) -> Result<()> {
1124 let rel_state = state_dir.strip_prefix(repo_root).unwrap_or(state_dir);
1125 for path in [
1126 rel_state.join("ledger.jsonl"),
1127 rel_state.join("ledger.md"),
1128 rel_state.join("review-queue.jsonl"),
1129 rel_state.join("runs"),
1130 rel_state.join("tmp"),
1131 rel_state.join("logs"),
1132 ] {
1133 let output = Command::new("git")
1134 .args(["ls-files", "--"])
1135 .arg(&path)
1136 .current_dir(repo_root)
1137 .output()?;
1138 if output.status.success() {
1139 for tracked in String::from_utf8_lossy(&output.stdout)
1140 .lines()
1141 .filter(|line| !line.trim().is_empty())
1142 {
1143 eprintln!("hint: run: git rm --cached {tracked}");
1144 }
1145 }
1146 }
1147 Ok(())
1148}
1149
1150fn clear_chained_hooks(plan: &HookInstallPlan) -> Result<()> {
1151 for hook in INSTALLED_HOOKS {
1152 remove_file_if_exists(&plan.hooks_path.join("chained").join(hook.as_str()))?;
1153 }
1154 Ok(())
1155}
1156
1157fn has_outer_hook_marker(content: &str) -> bool {
1158 content.lines().take(20).any(|line| {
1159 OUTER_HOOK_MARKERS
1160 .iter()
1161 .any(|marker| line.contains(marker))
1162 || (!line.trim_start().starts_with('#') && line_invokes_truth_hook_dispatch(line, None))
1163 })
1164}
1165
1166fn is_truth_mirror_managed(content: &str) -> bool {
1167 content.contains(MANAGED_MARKER)
1168 || active_hook_lines(content).any(|line| line_invokes_truth_hook_dispatch(line, None))
1169}
1170
1171const ENTIRE_MARKERS: &[&str] = &[
1174 "# Entire CLI hooks",
1175 "entire hooks git",
1176 "command -v entire",
1177];
1178
1179fn is_legacy_truth_reference(content: &str) -> bool {
1184 content.contains("truth-mirror")
1185 || content.contains("truth_mirror_bin")
1186 || is_truth_mirror_managed(content)
1187}
1188
1189fn is_simple_pre_entire_bridge(content: &str, hook_name: &str) -> bool {
1192 let normalized: String = content
1194 .lines()
1195 .map(str::trim)
1196 .filter(|line| !line.is_empty() && !line.starts_with('#'))
1197 .collect::<Vec<_>>()
1198 .join(" ");
1199 normalized == format!(r#"exec "$(dirname "$0")/{hook_name}" "$@""#)
1200}
1201
1202fn strip_legacy_truth_lines(content: &str) -> String {
1205 let mut out = Vec::new();
1206 for line in content.lines() {
1207 let trimmed = line.trim();
1208 if trimmed.contains("truth-mirror")
1209 || trimmed.contains("truth_mirror_bin")
1210 || trimmed.starts_with("# Truthfulness gate")
1211 || trimmed.starts_with("# truth-mirror's")
1212 {
1213 continue;
1214 }
1215 out.push(line);
1216 }
1217 if out.is_empty() {
1218 String::new()
1219 } else {
1220 format!("{}\n", out.join("\n"))
1221 }
1222}
1223
1224#[cfg(test)]
1232fn uninstall_legacy_hook_references(repo_root: &Path) -> Result<()> {
1233 uninstall_legacy_hook_references_at(repo_root, &repo_root.join(".git/hooks"))
1234}
1235
1236fn uninstall_legacy_hook_references_at(repo_root: &Path, git_hooks: &Path) -> Result<()> {
1237 let husky_dir = repo_root.join(".husky");
1238 let legacy_hooks = repo_root
1239 .join(crate::config::LEGACY_STATE_DIR)
1240 .join("hooks");
1241
1242 for hook in INSTALLED_HOOKS {
1243 let hook_name = hook.as_str();
1244 let names: Vec<String> = vec![hook_name.to_owned(), format!("{hook_name}.pre-entire")];
1245 for dir in [&husky_dir, git_hooks, &legacy_hooks] {
1246 for name in &names {
1247 let path = dir.join(name);
1248 let Some(content) = read_to_string_if_file(&path)? else {
1249 continue;
1250 };
1251 if content.contains(MANAGED_MARKER) {
1252 continue;
1253 }
1254 if name.ends_with(".pre-entire") {
1255 if is_simple_pre_entire_bridge(&content, hook_name) {
1256 continue;
1257 }
1258 if is_legacy_truth_reference(&content) || !shell_syntax_ok(&path) {
1259 let bridge =
1262 format!("#!/bin/sh\nexec \"$(dirname \"$0\")/{hook_name}\" \"$@\"\n");
1263 fs::write(&path, bridge)?;
1264 make_executable(&path)?;
1265 }
1266 continue;
1267 }
1268 if !is_legacy_truth_reference(&content) {
1269 continue;
1270 }
1271 if ENTIRE_MARKERS.iter().any(|marker| content.contains(marker)) {
1272 let stripped = strip_legacy_truth_lines(&content);
1273 if is_effectively_empty_shell(&stripped) {
1274 fs::remove_file(&path)?;
1275 } else {
1276 fs::write(&path, stripped)?;
1277 make_executable(&path)?;
1278 }
1279 } else {
1280 fs::remove_file(&path)?;
1281 }
1282 }
1283 }
1284 }
1285 Ok(())
1286}
1287
1288fn migrate_truth_md(repo_root: &Path, state_dir: &Path) -> Result<()> {
1291 let target = state_dir.join("TRUTH.md");
1292 if target.is_file() {
1293 return Ok(());
1294 }
1295 let legacy = repo_root
1296 .join(crate::config::LEGACY_STATE_DIR)
1297 .join("TRUTH.md");
1298 if legacy.is_file() {
1299 fs::copy(&legacy, &target)?;
1300 fs::remove_file(&legacy)?;
1301 return Ok(());
1302 }
1303 let root = repo_root.join("TRUTH.md");
1304 if root.is_file() {
1305 fs::rename(&root, &target)?;
1306 }
1307 Ok(())
1308}
1309
1310fn active_hook_lines(content: &str) -> impl Iterator<Item = &str> {
1311 content
1312 .lines()
1313 .map(str::trim_start)
1314 .filter(|line| !line.trim().is_empty() && !line.starts_with('#'))
1315}
1316
1317fn content_forwards_to_local_git_hook(content: &str, hook: HookName) -> bool {
1318 let resolves_git_dir =
1319 content.contains("--git-common-dir") || content.contains("git rev-parse --git-common-dir");
1320 resolves_git_dir
1321 && active_hook_lines(content)
1322 .any(|line| line_forwards_to_local_git_hook_with_args(line, hook))
1323}
1324
1325fn line_forwards_to_local_git_hook_with_args(line: &str, hook: HookName) -> bool {
1326 line.contains("$@")
1327 && (line.contains(&format!("hooks/{}", hook.as_str()))
1328 || line.contains("hooks/$name")
1329 || line.contains("hooks/${name}")
1330 || line.contains("hooks/$hook")
1331 || line.contains("hooks/${hook}"))
1332}
1333
1334fn line_invokes_truth_hook_dispatch(line: &str, expected_hook: Option<HookName>) -> bool {
1335 crate::shell::shellish_token_segments(line)
1336 .iter()
1337 .any(|tokens| token_segment_invokes_truth_hook_dispatch(tokens, expected_hook))
1338}
1339
1340fn token_segment_invokes_truth_hook_dispatch(
1341 tokens: &[&str],
1342 expected_hook: Option<HookName>,
1343) -> bool {
1344 for (index, token) in tokens.iter().enumerate() {
1345 if !is_truth_binary(token) {
1346 continue;
1347 }
1348 let Some(dispatch_offset) = tokens[index + 1..]
1349 .iter()
1350 .position(|candidate| *candidate == "hook-dispatch")
1351 else {
1352 continue;
1353 };
1354 let hook_index = index + 1 + dispatch_offset + 1;
1355 let Some(hook) = tokens.get(hook_index) else {
1356 continue;
1357 };
1358 if expected_hook.is_some_and(|expected| hook == &expected.as_str())
1359 || expected_hook.is_none() && token_is_installed_hook(hook)
1360 {
1361 return true;
1362 }
1363 }
1364 false
1365}
1366
1367fn is_truth_binary(token: &str) -> bool {
1368 token == "truth"
1369 || token == "truth-mirror"
1370 || token.ends_with("/truth")
1371 || token.ends_with("/truth-mirror")
1372}
1373
1374fn token_is_installed_hook(token: &str) -> bool {
1375 INSTALLED_HOOKS.iter().any(|hook| token == hook.as_str())
1376}
1377
1378fn backup_path(path: &Path, suffix: &str) -> PathBuf {
1379 let file_name = path
1380 .file_name()
1381 .and_then(|name| name.to_str())
1382 .unwrap_or("hook");
1383 path.with_file_name(format!("{file_name}.{suffix}"))
1384}
1385
1386fn read_to_string_if_file(path: &Path) -> Result<Option<String>> {
1387 match fs::read_to_string(path) {
1388 Ok(content) => Ok(Some(content)),
1389 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
1390 Err(error) => Err(error.into()),
1391 }
1392}
1393
1394fn read_bytes_if_file(path: &Path) -> Result<Option<Vec<u8>>> {
1396 match fs::read(path) {
1397 Ok(bytes) => Ok(Some(bytes)),
1398 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
1399 Err(error) => Err(error.into()),
1400 }
1401}
1402
1403fn remove_file_if_exists(path: &Path) -> Result<()> {
1404 match fs::remove_file(path) {
1405 Ok(()) => Ok(()),
1406 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
1407 Err(error) => Err(error.into()),
1408 }
1409}
1410
1411fn unset_core_hooks_path(repo_root: &Path) -> Result<()> {
1412 let status = Command::new("git")
1413 .args(["config", "--unset", "core.hooksPath"])
1414 .current_dir(repo_root)
1415 .status()?;
1416 if !status.success() {
1417 return Ok(());
1419 }
1420 Ok(())
1421}
1422
1423fn dispatch_commit_msg(
1424 state_dir: &Path,
1425 args: &[String],
1426 config: &crate::config::TruthMirrorConfig,
1427) -> Result<()> {
1428 let commit_msg_path = args
1429 .first()
1430 .context("commit-msg hook requires COMMIT_EDITMSG path")?;
1431 let commit_message = fs::read_to_string(commit_msg_path)?;
1432 let diff = git_stdout(&["diff", "--cached"])?;
1433 let claim_file = fs::read_to_string(state_dir.join("claim.txt")).ok();
1434 let policy = config.gates.to_policy();
1435 claim::evaluate_commit_message(&commit_message, claim_file.as_deref(), Some(&diff), &policy)?;
1436 Ok(())
1437}
1438
1439fn dispatch_post_commit(state_dir: &Path) -> Result<()> {
1440 let sha = git_stdout(&["rev-parse", "HEAD"])?;
1441 ReviewQueue::new(state_dir).enqueue(sha.trim())?;
1442 if let Err(error) = crate::watcher::ensure_watcher(state_dir) {
1447 tracing::warn!(%error, "post-commit: failed to ensure a review watcher");
1448 }
1449 Ok(())
1450}
1451
1452fn dispatch_pre_push(
1453 state_dir: &Path,
1454 config_path: Option<&Path>,
1455 config: &crate::config::TruthMirrorConfig,
1456 args: &[String],
1457) -> Result<()> {
1458 let remote_name = pre_push_remote_name_from_args(args);
1459 let mut stdin = String::new();
1460 io::stdin().read_to_string(&mut stdin)?;
1461 for line in stdin.lines() {
1462 if let Some(range) = pre_push_range_from_line_for_remote(line, remote_name) {
1463 gate::run(
1464 cli::GateArgs {
1465 pre_push: Some(range),
1466 commit_msg: None,
1467 claim_file: None,
1468 diff_file: None,
1469 fake_markers: Vec::new(),
1470 pre_tool_use: false,
1471 tool: None,
1472 },
1473 state_dir,
1474 config_path,
1475 config,
1476 )?;
1477 }
1478 }
1479 Ok(())
1480}
1481
1482fn pre_push_range_from_line_for_remote(line: &str, remote_name: Option<&str>) -> Option<String> {
1483 let parts = line.split_whitespace().collect::<Vec<_>>();
1484 let local_sha = parts.get(1)?;
1485 let remote_sha = parts.get(3)?;
1486 if is_zero_sha(local_sha) {
1487 return None;
1488 }
1489
1490 if is_zero_sha(remote_sha) {
1491 match remote_name.filter(|name| safe_pre_push_remote_name(name)) {
1492 Some(remote_name) => Some(format!("new:{remote_name}:{local_sha}")),
1493 None => Some(format!("new:{local_sha}")),
1494 }
1495 } else {
1496 Some(format!("{remote_sha}..{local_sha}"))
1497 }
1498}
1499
1500fn pre_push_remote_name_from_args(args: &[String]) -> Option<&str> {
1501 let remote_name = args.first()?.as_str();
1502 let remote_url = args.get(1).map(String::as_str);
1503 if remote_url == Some(remote_name) || !safe_pre_push_remote_name(remote_name) {
1504 return None;
1505 }
1506 Some(remote_name)
1507}
1508
1509fn safe_pre_push_remote_name(value: &str) -> bool {
1510 !value.is_empty()
1511 && !value.starts_with('-')
1512 && !value.contains(':')
1513 && !value.contains("..")
1514 && !value
1515 .chars()
1516 .any(|character| character.is_whitespace() || character.is_control())
1517 && !value
1518 .chars()
1519 .any(|character| matches!(character, '*' | '?' | '['))
1520}
1521
1522fn is_zero_sha(value: &str) -> bool {
1523 value.chars().all(|character| character == '0')
1524}
1525
1526fn run_chained_hook(state_dir: &Path, hook: HookName, args: &[String]) -> Result<()> {
1527 let chained = state_dir.join("hooks/chained").join(hook.as_str());
1528 if !chained.is_file() {
1529 return Ok(());
1530 }
1531 let content = fs::read_to_string(&chained)?;
1532 if has_outer_hook_marker(&content) {
1533 eprintln!(
1534 "{} skipping unsafe chained hook {} because it appears to invoke an outer hook manager",
1535 crate::messages::diagnostic_prefix(),
1536 chained.display()
1537 );
1538 return Ok(());
1539 }
1540
1541 let status = Command::new(&chained).args(args).status()?;
1542 if !status.success() {
1543 bail!(
1544 "chained hook {} failed with status {status}",
1545 chained.display()
1546 );
1547 }
1548 Ok(())
1549}
1550
1551fn git_root() -> Result<PathBuf> {
1552 Ok(PathBuf::from(
1553 git_stdout(&["rev-parse", "--show-toplevel"])?.trim(),
1554 ))
1555}
1556
1557fn git_common_dir() -> Result<PathBuf> {
1558 let common_dir = git_stdout(&["rev-parse", "--git-common-dir"])?;
1559 let common_dir = Path::new(common_dir.trim());
1560 if common_dir.is_absolute() {
1561 Ok(common_dir.to_path_buf())
1562 } else {
1563 Ok(std::env::current_dir()?.join(common_dir))
1564 }
1565}
1566
1567fn git_stdout(args: &[&str]) -> Result<String> {
1568 let output = Command::new("git").args(args).output()?;
1569 if !output.status.success() {
1570 bail!(
1571 "git {} failed: {}",
1572 args.join(" "),
1573 String::from_utf8_lossy(&output.stderr)
1574 );
1575 }
1576 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1577}
1578
1579fn git_config_get(repo_root: &Path, key: &str) -> Result<Option<String>> {
1580 let output = Command::new("git")
1581 .args(["config", "--get", key])
1582 .current_dir(repo_root)
1583 .output()?;
1584 if output.status.success() {
1585 return Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()));
1586 }
1587 if output.status.code() == Some(1) {
1588 return Ok(None);
1589 }
1590 bail!(
1591 "git config --get {key} failed: {}",
1592 String::from_utf8_lossy(&output.stderr)
1593 );
1594}
1595
1596#[cfg(unix)]
1597fn make_executable(path: &Path) -> Result<()> {
1598 use std::os::unix::fs::PermissionsExt;
1599
1600 let mut permissions = fs::metadata(path)?.permissions();
1601 permissions.set_mode(0o755);
1602 fs::set_permissions(path, permissions)?;
1603 Ok(())
1604}
1605
1606#[cfg(not(unix))]
1607fn make_executable(_path: &Path) -> Result<()> {
1608 Ok(())
1609}
1610
1611#[cfg(test)]
1612mod tests {
1613 use std::{fs, process::Command};
1614
1615 use proptest::prelude::*;
1616
1617 use super::{HookInstallPlan, pre_push_range_from_line_for_remote, render_shim};
1618 use crate::cli::HookName;
1619
1620 #[test]
1621 fn hook_shim_is_only_exec_delegation() {
1622 let shim = render_shim(HookName::CommitMsg, "");
1623 let lines = shim.lines().collect::<Vec<_>>();
1624
1625 assert_eq!(lines.len(), 3);
1626 assert_eq!(lines[0], "#!/bin/sh");
1627 assert_eq!(lines[1], super::MANAGED_MARKER);
1628 assert_eq!(
1629 lines[2],
1630 "exec truth-mirror hook-dispatch commit-msg \"$@\""
1631 );
1632 }
1633
1634 #[test]
1635 fn quote_git_arg_escapes_shell_metacharacters() {
1636 use std::path::Path;
1637 assert_eq!(super::quote_git_arg(Path::new("/a/b.toml")), "'/a/b.toml'");
1638 assert_eq!(
1640 super::quote_git_arg(Path::new("/a/c;$(touch x).toml")),
1641 "'/a/c;$(touch x).toml'"
1642 );
1643 assert_eq!(
1645 super::quote_git_arg(Path::new("/a/it's.toml")),
1646 "'/a/it'\\''s.toml'"
1647 );
1648 }
1649
1650 #[test]
1651 fn normalize_path_preserves_unmatched_relative_parents() {
1652 use std::path::{Path, PathBuf};
1653
1654 assert_eq!(
1655 super::normalize_path(Path::new("../hooks")),
1656 PathBuf::from("../hooks")
1657 );
1658 assert_eq!(
1659 super::normalize_path(Path::new("a/../../hooks")),
1660 PathBuf::from("../hooks")
1661 );
1662 }
1663
1664 #[test]
1665 fn hook_shim_preserves_global_args() {
1666 let shim = render_shim(HookName::PrePush, "--config /abs/enforce.toml ");
1667 let lines = shim.lines().collect::<Vec<_>>();
1668
1669 assert_eq!(lines.len(), 3, "shim stays <=3 lines + exec-only");
1670 assert_eq!(
1671 lines[2],
1672 "exec truth-mirror --config /abs/enforce.toml hook-dispatch pre-push \"$@\""
1673 );
1674 }
1675
1676 #[test]
1677 fn dry_run_plan_names_hooks_and_hooks_path() {
1678 let plan = HookInstallPlan::new(
1679 std::path::Path::new("/repo"),
1680 std::path::Path::new("/repo/.truth/hooks"),
1681 false,
1682 );
1683 let rendered = plan.render();
1684
1685 assert!(rendered.contains("commit-msg"));
1686 assert!(rendered.contains("post-commit"));
1687 assert!(rendered.contains("pre-push"));
1688 assert!(rendered.contains("stateHooks=/repo/.truth/hooks"));
1689 assert!(rendered.contains("path=/repo/.git/hooks/commit-msg"));
1690 assert!(rendered.contains("body:\n#!/bin/sh\n# truth-mirror managed hook"));
1691 }
1692
1693 #[test]
1694 fn managed_block_replace_is_idempotent() {
1695 let block = super::render_husky_block(HookName::CommitMsg, "");
1696 let original = "#!/bin/sh\necho before\n";
1697 let once = super::append_managed_block(original, &block);
1698 let twice = super::append_managed_block(&once, &block);
1699
1700 assert_eq!(twice.matches(super::MANAGED_MARKER).count(), 1);
1701 assert!(twice.contains("echo before"));
1702 }
1703
1704 #[test]
1705 fn chained_hook_safety_filter_detects_outer_tools() {
1706 assert!(super::has_outer_hook_marker(
1707 "#!/bin/sh\n# Entire CLI hooks\nentire hooks git commit-msg\n"
1708 ));
1709 assert!(super::has_outer_hook_marker(
1710 "#!/bin/sh\n# truth-mirror managed hook\nexec truth-mirror hook-dispatch commit-msg \"$@\"\n"
1711 ));
1712 assert!(super::has_outer_hook_marker(
1713 "#!/bin/sh\nexec truth hook-dispatch commit-msg \"$@\"\n"
1714 ));
1715 assert!(super::has_outer_hook_marker(
1716 "#!/bin/sh\nexec /usr/local/bin/truth hook-dispatch commit-msg \"$@\"\n"
1717 ));
1718 assert!(!super::has_outer_hook_marker(
1719 "#!/bin/sh\n# exec truth hook-dispatch commit-msg \"$@\"\n"
1720 ));
1721 assert!(!super::has_outer_hook_marker("#!/bin/sh\necho safe\n"));
1722 }
1723
1724 #[test]
1725 fn managed_detection_ignores_commented_and_unrelated_truth_lines() {
1726 assert!(!super::is_truth_mirror_managed(
1727 "#!/bin/sh\n# exec truth hook-dispatch commit-msg \"$@\"\n"
1728 ));
1729 assert!(!super::is_truth_mirror_managed(
1730 "#!/bin/sh\nexec truth unrelated \"$@\"\n"
1731 ));
1732 assert!(!super::is_truth_mirror_managed(
1733 "#!/bin/sh\nexec truth --version && hook-dispatch commit-msg \"$@\"\n"
1734 ));
1735 assert!(!super::is_truth_mirror_managed(
1736 "#!/bin/sh\nhook-dispatch commit-msg \"$@\"; exec truth --version\n"
1737 ));
1738 assert!(super::is_truth_mirror_managed(
1739 "#!/bin/sh\nexec /opt/bin/truth hook-dispatch pre-push \"$@\"\n"
1740 ));
1741 }
1742
1743 #[test]
1744 fn run_chained_hook_skips_marker_bearing_stale_hook() {
1745 let temp = tempfile::tempdir().unwrap();
1746 let chained = temp.path().join("hooks/chained/commit-msg");
1747 std::fs::create_dir_all(chained.parent().unwrap()).unwrap();
1748 std::fs::write(&chained, "#!/bin/sh\n# Entire CLI hooks\nexit 42\n").unwrap();
1749 super::make_executable(&chained).unwrap();
1750
1751 super::run_chained_hook(temp.path(), HookName::CommitMsg, &[]).unwrap();
1752 }
1753
1754 #[test]
1755 fn pre_push_line_maps_to_git_range() {
1756 let line = "refs/heads/main abc123 refs/heads/main def456";
1757
1758 assert_eq!(
1759 pre_push_range_from_line_for_remote(line, None),
1760 Some("def456..abc123".to_owned())
1761 );
1762 }
1763
1764 #[test]
1765 fn pre_push_new_branch_maps_to_new_branch_token() {
1766 let line =
1767 "refs/heads/topic abc123 refs/heads/topic 0000000000000000000000000000000000000000";
1768
1769 assert_eq!(
1770 pre_push_range_from_line_for_remote(line, None),
1771 Some("new:abc123".to_owned())
1772 );
1773 }
1774
1775 #[test]
1776 fn pre_push_new_branch_includes_safe_remote_name_when_available() {
1777 let line =
1778 "refs/heads/topic abc123 refs/heads/topic 0000000000000000000000000000000000000000";
1779
1780 assert_eq!(
1781 pre_push_range_from_line_for_remote(line, Some("origin")),
1782 Some("new:origin:abc123".to_owned())
1783 );
1784 assert_eq!(
1785 pre_push_range_from_line_for_remote(line, Some("../bad")),
1786 Some("new:abc123".to_owned())
1787 );
1788 }
1789
1790 #[test]
1791 fn pre_push_args_omit_remote_name_for_direct_path_pushes() {
1792 assert_eq!(
1793 super::pre_push_remote_name_from_args(&[
1794 "origin".to_owned(),
1795 "git@example/repo".to_owned()
1796 ]),
1797 Some("origin")
1798 );
1799 assert_eq!(
1800 super::pre_push_remote_name_from_args(&[
1801 "../repo.git".to_owned(),
1802 "../repo.git".to_owned()
1803 ]),
1804 None
1805 );
1806 }
1807
1808 #[test]
1809 fn pre_push_args_omit_glob_like_remote_names() {
1810 for remote_name in ["bad*remote", "bad?remote", "bad[remote"] {
1811 assert_eq!(
1812 super::pre_push_remote_name_from_args(&[
1813 remote_name.to_owned(),
1814 "git@example/repo".to_owned()
1815 ]),
1816 None
1817 );
1818 }
1819 }
1820
1821 #[test]
1822 fn legacy_truth_only_husky_hook_is_removed() {
1823 let temp = tempfile::tempdir().unwrap();
1824 let husky = temp.path().join(".husky");
1825 fs::create_dir_all(&husky).unwrap();
1826 let hook = husky.join("commit-msg");
1827 fs::write(
1828 &hook,
1829 "#!/usr/bin/env sh\nset -e\n# Truthfulness gate\nif command -v truth-mirror >/dev/null 2>&1; then truth-mirror hook-dispatch commit-msg \"$@\"; else :; fi\n",
1830 )
1831 .unwrap();
1832 super::make_executable(&hook).unwrap();
1833 super::uninstall_legacy_hook_references(temp.path()).unwrap();
1834 assert!(!hook.exists());
1835 }
1836
1837 #[test]
1838 fn legacy_truth_hook_with_entire_is_stripped_not_removed() {
1839 let temp = tempfile::tempdir().unwrap();
1840 let husky = temp.path().join(".husky");
1841 fs::create_dir_all(&husky).unwrap();
1842 let hook = husky.join("pre-push");
1843 fs::write(
1844 &hook,
1845 "#!/bin/sh\nif command -v entire >/dev/null 2>&1; then entire hooks git pre-push \"$1\"; fi\nexec truth-mirror hook-dispatch pre-push \"$@\"\n",
1846 )
1847 .unwrap();
1848 super::make_executable(&hook).unwrap();
1849 super::uninstall_legacy_hook_references(temp.path()).unwrap();
1850 let content = fs::read_to_string(&hook).unwrap();
1851 assert!(!content.contains("truth-mirror"));
1852 assert!(content.contains("entire hooks git pre-push"));
1853 }
1854
1855 #[test]
1856 fn legacy_truth_mirror_bin_pre_entire_bridge_is_rewritten_to_simple_bridge() {
1857 let temp = tempfile::tempdir().unwrap();
1858 let husky = temp.path().join(".husky");
1859 fs::create_dir_all(&husky).unwrap();
1860 let bridge = husky.join("pre-push.pre-entire");
1861 fs::write(
1862 &bridge,
1863 "#!/usr/bin/env sh\ntruth_mirror_bin=\"$(command -v truth-mirror || true)\"\n[ -n \"$truth_mirror_bin\" ] || exit 0\n\"$truth_mirror_bin\" hook-dispatch pre-push \"$@\"\n",
1864 )
1865 .unwrap();
1866 super::make_executable(&bridge).unwrap();
1867 super::uninstall_legacy_hook_references(temp.path()).unwrap();
1868 let content = fs::read_to_string(&bridge).unwrap();
1869 assert!(!content.contains("truth-mirror"));
1870 assert!(content.contains(r#"exec "$(dirname "$0")/pre-push" "$@""#));
1871 }
1872
1873 #[test]
1874 fn broken_pre_entire_bridge_is_rewritten_even_without_truth_reference() {
1875 let temp = tempfile::tempdir().unwrap();
1876 let husky = temp.path().join(".husky");
1877 fs::create_dir_all(&husky).unwrap();
1878 let bridge = husky.join("pre-push.pre-entire");
1879 fs::write(
1882 &bridge,
1883 "#!/usr/bin/env sh\nset -e\nif command -v entire >/dev/null; then\n echo ok\nelse\nfi\n",
1884 )
1885 .unwrap();
1886 super::make_executable(&bridge).unwrap();
1887 super::uninstall_legacy_hook_references(temp.path()).unwrap();
1888 let content = fs::read_to_string(&bridge).unwrap();
1889 assert!(content.contains(r#"exec "$(dirname "$0")/pre-push" "$@""#));
1890 }
1891
1892 #[test]
1893 fn simple_pre_entire_bridge_is_preserved() {
1894 let temp = tempfile::tempdir().unwrap();
1895 let husky = temp.path().join(".husky");
1896 fs::create_dir_all(&husky).unwrap();
1897 let bridge = husky.join("commit-msg.pre-entire");
1898 fs::write(
1899 &bridge,
1900 "#!/bin/sh\nexec \"$(dirname \"$0\")/commit-msg\" \"$@\"\n",
1901 )
1902 .unwrap();
1903 super::make_executable(&bridge).unwrap();
1904 super::uninstall_legacy_hook_references(temp.path()).unwrap();
1905 let content = fs::read_to_string(&bridge).unwrap();
1906 assert!(content.contains(r#"exec "$(dirname "$0")/commit-msg" "$@""#));
1907 }
1908
1909 #[test]
1910 fn install_migrates_root_truth_md_to_state_dir() {
1911 let temp = tempfile::tempdir().unwrap();
1912 let repo_root = temp.path().join("repo");
1913 fs::create_dir_all(&repo_root).unwrap();
1914 Command::new("git")
1915 .args(["init"])
1916 .current_dir(&repo_root)
1917 .output()
1918 .unwrap();
1919 let root_truth = repo_root.join("TRUTH.md");
1920 fs::write(&root_truth, "# truth\n").unwrap();
1921 let state_dir = repo_root.join(".truth");
1922 fs::create_dir_all(&state_dir).unwrap();
1923 super::migrate_truth_md(&repo_root, &state_dir).unwrap();
1924 assert!(state_dir.join("TRUTH.md").is_file());
1925 assert!(!root_truth.exists());
1926 assert_eq!(
1927 fs::read_to_string(state_dir.join("TRUTH.md")).unwrap(),
1928 "# truth\n"
1929 );
1930 }
1931
1932 #[test]
1933 fn install_migrates_legacy_truth_mirror_md_to_state_dir() {
1934 let temp = tempfile::tempdir().unwrap();
1935 let repo_root = temp.path().join("repo");
1936 fs::create_dir_all(&repo_root).unwrap();
1937 let legacy_dir = repo_root.join(".truth-mirror");
1938 fs::create_dir_all(&legacy_dir).unwrap();
1939 fs::write(legacy_dir.join("TRUTH.md"), "# legacy truth\n").unwrap();
1940 let state_dir = repo_root.join(".truth");
1941 fs::create_dir_all(&state_dir).unwrap();
1942 super::migrate_truth_md(&repo_root, &state_dir).unwrap();
1943 assert!(state_dir.join("TRUTH.md").is_file());
1944 assert!(!legacy_dir.join("TRUTH.md").exists());
1945 }
1946
1947 proptest! {
1948 #[test]
1949 fn hook_shim_rendering_stays_tiny_exec_only(index in 0usize..3) {
1950 let hook = [HookName::CommitMsg, HookName::PostCommit, HookName::PrePush][index];
1951 let shim = render_shim(hook, "");
1952 let lines = shim.lines().collect::<Vec<_>>();
1953
1954 prop_assert_eq!(lines.len(), 3);
1955 prop_assert_eq!(lines[0], "#!/bin/sh");
1956 prop_assert_eq!(lines[1], super::MANAGED_MARKER);
1957 prop_assert!(lines[2].starts_with("exec truth-mirror hook-dispatch "));
1958 prop_assert!(lines[2].contains(hook.as_str()));
1959 prop_assert_eq!(shim.matches(super::MANAGED_MARKER).count(), 1);
1960 prop_assert_eq!(shim.matches("exec truth-mirror").count(), 1);
1961 }
1962 }
1963}