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