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 is_simple_pre_entire_bridge(content: &str, hook_name: &str) -> bool {
1135 let normalized: String = content
1137 .lines()
1138 .map(str::trim)
1139 .filter(|line| !line.is_empty() && !line.starts_with('#'))
1140 .collect::<Vec<_>>()
1141 .join(" ");
1142 normalized == format!(r#"exec "$(dirname "$0")/{hook_name}" "$@""#)
1143}
1144
1145fn strip_legacy_truth_lines(content: &str) -> String {
1148 let mut out = Vec::new();
1149 for line in content.lines() {
1150 let trimmed = line.trim();
1151 if trimmed.contains("truth-mirror")
1152 || trimmed.contains("truth_mirror_bin")
1153 || trimmed.starts_with("# Truthfulness gate")
1154 || trimmed.starts_with("# truth-mirror's")
1155 {
1156 continue;
1157 }
1158 out.push(line);
1159 }
1160 if out.is_empty() {
1161 String::new()
1162 } else {
1163 format!("{}\n", out.join("\n"))
1164 }
1165}
1166
1167fn uninstall_legacy_hook_references(repo_root: &Path) -> Result<()> {
1175 let husky_dir = repo_root.join(".husky");
1176 let git_hooks = repo_root.join(".git/hooks");
1177 let legacy_hooks = repo_root
1178 .join(crate::config::LEGACY_STATE_DIR)
1179 .join("hooks");
1180
1181 for hook in INSTALLED_HOOKS {
1182 let hook_name = hook.as_str();
1183 let names: Vec<String> = vec![hook_name.to_owned(), format!("{hook_name}.pre-entire")];
1184 for dir in [&husky_dir, &git_hooks, &legacy_hooks] {
1185 for name in &names {
1186 let path = dir.join(name);
1187 let Some(content) = read_to_string_if_file(&path)? else {
1188 continue;
1189 };
1190 if content.contains(MANAGED_MARKER) {
1191 continue;
1192 }
1193 if name.ends_with(".pre-entire") {
1194 if is_simple_pre_entire_bridge(&content, hook_name) {
1195 continue;
1196 }
1197 if is_legacy_truth_reference(&content) || !shell_syntax_ok(&path) {
1198 let bridge =
1201 format!("#!/bin/sh\nexec \"$(dirname \"$0\")/{hook_name}\" \"$@\"\n");
1202 fs::write(&path, bridge)?;
1203 make_executable(&path)?;
1204 }
1205 continue;
1206 }
1207 if !is_legacy_truth_reference(&content) {
1208 continue;
1209 }
1210 if ENTIRE_MARKERS.iter().any(|marker| content.contains(marker)) {
1211 let stripped = strip_legacy_truth_lines(&content);
1212 if is_effectively_empty_shell(&stripped) {
1213 fs::remove_file(&path)?;
1214 } else {
1215 fs::write(&path, stripped)?;
1216 make_executable(&path)?;
1217 }
1218 } else {
1219 fs::remove_file(&path)?;
1220 }
1221 }
1222 }
1223 }
1224 Ok(())
1225}
1226
1227fn migrate_truth_md(repo_root: &Path, state_dir: &Path) -> Result<()> {
1230 let target = state_dir.join("TRUTH.md");
1231 if target.is_file() {
1232 return Ok(());
1233 }
1234 let legacy = repo_root
1235 .join(crate::config::LEGACY_STATE_DIR)
1236 .join("TRUTH.md");
1237 if legacy.is_file() {
1238 fs::copy(&legacy, &target)?;
1239 fs::remove_file(&legacy)?;
1240 return Ok(());
1241 }
1242 let root = repo_root.join("TRUTH.md");
1243 if root.is_file() {
1244 fs::rename(&root, &target)?;
1245 }
1246 Ok(())
1247}
1248
1249fn active_hook_lines(content: &str) -> impl Iterator<Item = &str> {
1250 content
1251 .lines()
1252 .map(str::trim_start)
1253 .filter(|line| !line.trim().is_empty() && !line.starts_with('#'))
1254}
1255
1256fn content_forwards_to_local_git_hook(content: &str, hook: HookName) -> bool {
1257 let resolves_git_dir =
1258 content.contains("--git-common-dir") || content.contains("git rev-parse --git-common-dir");
1259 resolves_git_dir
1260 && active_hook_lines(content)
1261 .any(|line| line_forwards_to_local_git_hook_with_args(line, hook))
1262}
1263
1264fn line_forwards_to_local_git_hook_with_args(line: &str, hook: HookName) -> bool {
1265 line.contains("$@")
1266 && (line.contains(&format!("hooks/{}", hook.as_str()))
1267 || line.contains("hooks/$name")
1268 || line.contains("hooks/${name}")
1269 || line.contains("hooks/$hook")
1270 || line.contains("hooks/${hook}"))
1271}
1272
1273fn line_invokes_truth_hook_dispatch(line: &str, expected_hook: Option<HookName>) -> bool {
1274 crate::shell::shellish_token_segments(line)
1275 .iter()
1276 .any(|tokens| token_segment_invokes_truth_hook_dispatch(tokens, expected_hook))
1277}
1278
1279fn token_segment_invokes_truth_hook_dispatch(
1280 tokens: &[&str],
1281 expected_hook: Option<HookName>,
1282) -> bool {
1283 for (index, token) in tokens.iter().enumerate() {
1284 if !is_truth_binary(token) {
1285 continue;
1286 }
1287 let Some(dispatch_offset) = tokens[index + 1..]
1288 .iter()
1289 .position(|candidate| *candidate == "hook-dispatch")
1290 else {
1291 continue;
1292 };
1293 let hook_index = index + 1 + dispatch_offset + 1;
1294 let Some(hook) = tokens.get(hook_index) else {
1295 continue;
1296 };
1297 if expected_hook.is_some_and(|expected| hook == &expected.as_str())
1298 || expected_hook.is_none() && token_is_installed_hook(hook)
1299 {
1300 return true;
1301 }
1302 }
1303 false
1304}
1305
1306fn is_truth_binary(token: &str) -> bool {
1307 token == "truth"
1308 || token == "truth-mirror"
1309 || token.ends_with("/truth")
1310 || token.ends_with("/truth-mirror")
1311}
1312
1313fn token_is_installed_hook(token: &str) -> bool {
1314 INSTALLED_HOOKS.iter().any(|hook| token == hook.as_str())
1315}
1316
1317fn backup_path(path: &Path, suffix: &str) -> PathBuf {
1318 let file_name = path
1319 .file_name()
1320 .and_then(|name| name.to_str())
1321 .unwrap_or("hook");
1322 path.with_file_name(format!("{file_name}.{suffix}"))
1323}
1324
1325fn read_to_string_if_file(path: &Path) -> Result<Option<String>> {
1326 match fs::read_to_string(path) {
1327 Ok(content) => Ok(Some(content)),
1328 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
1329 Err(error) => Err(error.into()),
1330 }
1331}
1332
1333fn read_bytes_if_file(path: &Path) -> Result<Option<Vec<u8>>> {
1335 match fs::read(path) {
1336 Ok(bytes) => Ok(Some(bytes)),
1337 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
1338 Err(error) => Err(error.into()),
1339 }
1340}
1341
1342fn remove_file_if_exists(path: &Path) -> Result<()> {
1343 match fs::remove_file(path) {
1344 Ok(()) => Ok(()),
1345 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
1346 Err(error) => Err(error.into()),
1347 }
1348}
1349
1350fn unset_core_hooks_path(repo_root: &Path) -> Result<()> {
1351 let status = Command::new("git")
1352 .args(["config", "--unset", "core.hooksPath"])
1353 .current_dir(repo_root)
1354 .status()?;
1355 if !status.success() {
1356 return Ok(());
1358 }
1359 Ok(())
1360}
1361
1362fn dispatch_commit_msg(
1363 state_dir: &Path,
1364 args: &[String],
1365 config: &crate::config::TruthMirrorConfig,
1366) -> Result<()> {
1367 let commit_msg_path = args
1368 .first()
1369 .context("commit-msg hook requires COMMIT_EDITMSG path")?;
1370 let commit_message = fs::read_to_string(commit_msg_path)?;
1371 let diff = git_stdout(&["diff", "--cached"])?;
1372 let claim_file = fs::read_to_string(state_dir.join("claim.txt")).ok();
1373 let policy = config.gates.to_policy();
1374 claim::evaluate_commit_message(&commit_message, claim_file.as_deref(), Some(&diff), &policy)?;
1375 Ok(())
1376}
1377
1378fn dispatch_post_commit(state_dir: &Path) -> Result<()> {
1379 let sha = git_stdout(&["rev-parse", "HEAD"])?;
1380 ReviewQueue::new(state_dir).enqueue(sha.trim())?;
1381 Ok(())
1382}
1383
1384fn dispatch_pre_push(
1385 state_dir: &Path,
1386 config_path: Option<&Path>,
1387 config: &crate::config::TruthMirrorConfig,
1388 args: &[String],
1389) -> Result<()> {
1390 let remote_name = pre_push_remote_name_from_args(args);
1391 let mut stdin = String::new();
1392 io::stdin().read_to_string(&mut stdin)?;
1393 for line in stdin.lines() {
1394 if let Some(range) = pre_push_range_from_line_for_remote(line, remote_name) {
1395 gate::run(
1396 cli::GateArgs {
1397 pre_push: Some(range),
1398 commit_msg: None,
1399 claim_file: None,
1400 diff_file: None,
1401 fake_markers: Vec::new(),
1402 pre_tool_use: false,
1403 tool: None,
1404 },
1405 state_dir,
1406 config_path,
1407 config,
1408 )?;
1409 }
1410 }
1411 Ok(())
1412}
1413
1414fn pre_push_range_from_line_for_remote(line: &str, remote_name: Option<&str>) -> Option<String> {
1415 let parts = line.split_whitespace().collect::<Vec<_>>();
1416 let local_sha = parts.get(1)?;
1417 let remote_sha = parts.get(3)?;
1418 if is_zero_sha(local_sha) {
1419 return None;
1420 }
1421
1422 if is_zero_sha(remote_sha) {
1423 match remote_name.filter(|name| safe_pre_push_remote_name(name)) {
1424 Some(remote_name) => Some(format!("new:{remote_name}:{local_sha}")),
1425 None => Some(format!("new:{local_sha}")),
1426 }
1427 } else {
1428 Some(format!("{remote_sha}..{local_sha}"))
1429 }
1430}
1431
1432fn pre_push_remote_name_from_args(args: &[String]) -> Option<&str> {
1433 let remote_name = args.first()?.as_str();
1434 let remote_url = args.get(1).map(String::as_str);
1435 if remote_url == Some(remote_name) || !safe_pre_push_remote_name(remote_name) {
1436 return None;
1437 }
1438 Some(remote_name)
1439}
1440
1441fn safe_pre_push_remote_name(value: &str) -> bool {
1442 !value.is_empty()
1443 && !value.starts_with('-')
1444 && !value.contains(':')
1445 && !value.contains("..")
1446 && !value
1447 .chars()
1448 .any(|character| character.is_whitespace() || character.is_control())
1449 && !value
1450 .chars()
1451 .any(|character| matches!(character, '*' | '?' | '['))
1452}
1453
1454fn is_zero_sha(value: &str) -> bool {
1455 value.chars().all(|character| character == '0')
1456}
1457
1458fn run_chained_hook(state_dir: &Path, hook: HookName, args: &[String]) -> Result<()> {
1459 let chained = state_dir.join("hooks/chained").join(hook.as_str());
1460 if !chained.is_file() {
1461 return Ok(());
1462 }
1463 let content = fs::read_to_string(&chained)?;
1464 if has_outer_hook_marker(&content) {
1465 eprintln!(
1466 "{} skipping unsafe chained hook {} because it appears to invoke an outer hook manager",
1467 crate::messages::diagnostic_prefix(),
1468 chained.display()
1469 );
1470 return Ok(());
1471 }
1472
1473 let status = Command::new(&chained).args(args).status()?;
1474 if !status.success() {
1475 bail!(
1476 "chained hook {} failed with status {status}",
1477 chained.display()
1478 );
1479 }
1480 Ok(())
1481}
1482
1483fn git_root() -> Result<PathBuf> {
1484 Ok(PathBuf::from(
1485 git_stdout(&["rev-parse", "--show-toplevel"])?.trim(),
1486 ))
1487}
1488
1489fn git_stdout(args: &[&str]) -> Result<String> {
1490 let output = Command::new("git").args(args).output()?;
1491 if !output.status.success() {
1492 bail!(
1493 "git {} failed: {}",
1494 args.join(" "),
1495 String::from_utf8_lossy(&output.stderr)
1496 );
1497 }
1498 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1499}
1500
1501fn git_config_get(repo_root: &Path, key: &str) -> Result<Option<String>> {
1502 let output = Command::new("git")
1503 .args(["config", "--get", key])
1504 .current_dir(repo_root)
1505 .output()?;
1506 if output.status.success() {
1507 return Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()));
1508 }
1509 if output.status.code() == Some(1) {
1510 return Ok(None);
1511 }
1512 bail!(
1513 "git config --get {key} failed: {}",
1514 String::from_utf8_lossy(&output.stderr)
1515 );
1516}
1517
1518#[cfg(unix)]
1519fn make_executable(path: &Path) -> Result<()> {
1520 use std::os::unix::fs::PermissionsExt;
1521
1522 let mut permissions = fs::metadata(path)?.permissions();
1523 permissions.set_mode(0o755);
1524 fs::set_permissions(path, permissions)?;
1525 Ok(())
1526}
1527
1528#[cfg(not(unix))]
1529fn make_executable(_path: &Path) -> Result<()> {
1530 Ok(())
1531}
1532
1533#[cfg(test)]
1534mod tests {
1535 use std::{fs, process::Command};
1536
1537 use proptest::prelude::*;
1538
1539 use super::{HookInstallPlan, pre_push_range_from_line_for_remote, render_shim};
1540 use crate::cli::HookName;
1541
1542 #[test]
1543 fn hook_shim_is_only_exec_delegation() {
1544 let shim = render_shim(HookName::CommitMsg, "");
1545 let lines = shim.lines().collect::<Vec<_>>();
1546
1547 assert_eq!(lines.len(), 3);
1548 assert_eq!(lines[0], "#!/bin/sh");
1549 assert_eq!(lines[1], super::MANAGED_MARKER);
1550 assert_eq!(
1551 lines[2],
1552 "exec truth-mirror hook-dispatch commit-msg \"$@\""
1553 );
1554 }
1555
1556 #[test]
1557 fn quote_git_arg_escapes_shell_metacharacters() {
1558 use std::path::Path;
1559 assert_eq!(super::quote_git_arg(Path::new("/a/b.toml")), "'/a/b.toml'");
1560 assert_eq!(
1562 super::quote_git_arg(Path::new("/a/c;$(touch x).toml")),
1563 "'/a/c;$(touch x).toml'"
1564 );
1565 assert_eq!(
1567 super::quote_git_arg(Path::new("/a/it's.toml")),
1568 "'/a/it'\\''s.toml'"
1569 );
1570 }
1571
1572 #[test]
1573 fn normalize_path_preserves_unmatched_relative_parents() {
1574 use std::path::{Path, PathBuf};
1575
1576 assert_eq!(
1577 super::normalize_path(Path::new("../hooks")),
1578 PathBuf::from("../hooks")
1579 );
1580 assert_eq!(
1581 super::normalize_path(Path::new("a/../../hooks")),
1582 PathBuf::from("../hooks")
1583 );
1584 }
1585
1586 #[test]
1587 fn hook_shim_preserves_global_args() {
1588 let shim = render_shim(HookName::PrePush, "--config /abs/enforce.toml ");
1589 let lines = shim.lines().collect::<Vec<_>>();
1590
1591 assert_eq!(lines.len(), 3, "shim stays <=3 lines + exec-only");
1592 assert_eq!(
1593 lines[2],
1594 "exec truth-mirror --config /abs/enforce.toml hook-dispatch pre-push \"$@\""
1595 );
1596 }
1597
1598 #[test]
1599 fn dry_run_plan_names_hooks_and_hooks_path() {
1600 let plan = HookInstallPlan::new(
1601 std::path::Path::new("/repo"),
1602 std::path::Path::new("/repo/.truth/hooks"),
1603 false,
1604 );
1605 let rendered = plan.render();
1606
1607 assert!(rendered.contains("commit-msg"));
1608 assert!(rendered.contains("post-commit"));
1609 assert!(rendered.contains("pre-push"));
1610 assert!(rendered.contains("stateHooks=/repo/.truth/hooks"));
1611 assert!(rendered.contains("path=/repo/.git/hooks/commit-msg"));
1612 assert!(rendered.contains("body:\n#!/bin/sh\n# truth-mirror managed hook"));
1613 }
1614
1615 #[test]
1616 fn managed_block_replace_is_idempotent() {
1617 let block = super::render_husky_block(HookName::CommitMsg, "");
1618 let original = "#!/bin/sh\necho before\n";
1619 let once = super::append_managed_block(original, &block);
1620 let twice = super::append_managed_block(&once, &block);
1621
1622 assert_eq!(twice.matches(super::MANAGED_MARKER).count(), 1);
1623 assert!(twice.contains("echo before"));
1624 }
1625
1626 #[test]
1627 fn chained_hook_safety_filter_detects_outer_tools() {
1628 assert!(super::has_outer_hook_marker(
1629 "#!/bin/sh\n# Entire CLI hooks\nentire hooks git commit-msg\n"
1630 ));
1631 assert!(super::has_outer_hook_marker(
1632 "#!/bin/sh\n# truth-mirror managed hook\nexec truth-mirror hook-dispatch commit-msg \"$@\"\n"
1633 ));
1634 assert!(super::has_outer_hook_marker(
1635 "#!/bin/sh\nexec truth hook-dispatch commit-msg \"$@\"\n"
1636 ));
1637 assert!(super::has_outer_hook_marker(
1638 "#!/bin/sh\nexec /usr/local/bin/truth hook-dispatch commit-msg \"$@\"\n"
1639 ));
1640 assert!(!super::has_outer_hook_marker(
1641 "#!/bin/sh\n# exec truth hook-dispatch commit-msg \"$@\"\n"
1642 ));
1643 assert!(!super::has_outer_hook_marker("#!/bin/sh\necho safe\n"));
1644 }
1645
1646 #[test]
1647 fn managed_detection_ignores_commented_and_unrelated_truth_lines() {
1648 assert!(!super::is_truth_mirror_managed(
1649 "#!/bin/sh\n# exec truth hook-dispatch commit-msg \"$@\"\n"
1650 ));
1651 assert!(!super::is_truth_mirror_managed(
1652 "#!/bin/sh\nexec truth unrelated \"$@\"\n"
1653 ));
1654 assert!(!super::is_truth_mirror_managed(
1655 "#!/bin/sh\nexec truth --version && hook-dispatch commit-msg \"$@\"\n"
1656 ));
1657 assert!(!super::is_truth_mirror_managed(
1658 "#!/bin/sh\nhook-dispatch commit-msg \"$@\"; exec truth --version\n"
1659 ));
1660 assert!(super::is_truth_mirror_managed(
1661 "#!/bin/sh\nexec /opt/bin/truth hook-dispatch pre-push \"$@\"\n"
1662 ));
1663 }
1664
1665 #[test]
1666 fn run_chained_hook_skips_marker_bearing_stale_hook() {
1667 let temp = tempfile::tempdir().unwrap();
1668 let chained = temp.path().join("hooks/chained/commit-msg");
1669 std::fs::create_dir_all(chained.parent().unwrap()).unwrap();
1670 std::fs::write(&chained, "#!/bin/sh\n# Entire CLI hooks\nexit 42\n").unwrap();
1671 super::make_executable(&chained).unwrap();
1672
1673 super::run_chained_hook(temp.path(), HookName::CommitMsg, &[]).unwrap();
1674 }
1675
1676 #[test]
1677 fn pre_push_line_maps_to_git_range() {
1678 let line = "refs/heads/main abc123 refs/heads/main def456";
1679
1680 assert_eq!(
1681 pre_push_range_from_line_for_remote(line, None),
1682 Some("def456..abc123".to_owned())
1683 );
1684 }
1685
1686 #[test]
1687 fn pre_push_new_branch_maps_to_new_branch_token() {
1688 let line =
1689 "refs/heads/topic abc123 refs/heads/topic 0000000000000000000000000000000000000000";
1690
1691 assert_eq!(
1692 pre_push_range_from_line_for_remote(line, None),
1693 Some("new:abc123".to_owned())
1694 );
1695 }
1696
1697 #[test]
1698 fn pre_push_new_branch_includes_safe_remote_name_when_available() {
1699 let line =
1700 "refs/heads/topic abc123 refs/heads/topic 0000000000000000000000000000000000000000";
1701
1702 assert_eq!(
1703 pre_push_range_from_line_for_remote(line, Some("origin")),
1704 Some("new:origin:abc123".to_owned())
1705 );
1706 assert_eq!(
1707 pre_push_range_from_line_for_remote(line, Some("../bad")),
1708 Some("new:abc123".to_owned())
1709 );
1710 }
1711
1712 #[test]
1713 fn pre_push_args_omit_remote_name_for_direct_path_pushes() {
1714 assert_eq!(
1715 super::pre_push_remote_name_from_args(&[
1716 "origin".to_owned(),
1717 "git@example/repo".to_owned()
1718 ]),
1719 Some("origin")
1720 );
1721 assert_eq!(
1722 super::pre_push_remote_name_from_args(&[
1723 "../repo.git".to_owned(),
1724 "../repo.git".to_owned()
1725 ]),
1726 None
1727 );
1728 }
1729
1730 #[test]
1731 fn pre_push_args_omit_glob_like_remote_names() {
1732 for remote_name in ["bad*remote", "bad?remote", "bad[remote"] {
1733 assert_eq!(
1734 super::pre_push_remote_name_from_args(&[
1735 remote_name.to_owned(),
1736 "git@example/repo".to_owned()
1737 ]),
1738 None
1739 );
1740 }
1741 }
1742
1743 #[test]
1744 fn legacy_truth_only_husky_hook_is_removed() {
1745 let temp = tempfile::tempdir().unwrap();
1746 let husky = temp.path().join(".husky");
1747 fs::create_dir_all(&husky).unwrap();
1748 let hook = husky.join("commit-msg");
1749 fs::write(
1750 &hook,
1751 "#!/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",
1752 )
1753 .unwrap();
1754 super::make_executable(&hook).unwrap();
1755 super::uninstall_legacy_hook_references(temp.path()).unwrap();
1756 assert!(!hook.exists());
1757 }
1758
1759 #[test]
1760 fn legacy_truth_hook_with_entire_is_stripped_not_removed() {
1761 let temp = tempfile::tempdir().unwrap();
1762 let husky = temp.path().join(".husky");
1763 fs::create_dir_all(&husky).unwrap();
1764 let hook = husky.join("pre-push");
1765 fs::write(
1766 &hook,
1767 "#!/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",
1768 )
1769 .unwrap();
1770 super::make_executable(&hook).unwrap();
1771 super::uninstall_legacy_hook_references(temp.path()).unwrap();
1772 let content = fs::read_to_string(&hook).unwrap();
1773 assert!(!content.contains("truth-mirror"));
1774 assert!(content.contains("entire hooks git pre-push"));
1775 }
1776
1777 #[test]
1778 fn legacy_truth_mirror_bin_pre_entire_bridge_is_rewritten_to_simple_bridge() {
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(
1784 &bridge,
1785 "#!/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",
1786 )
1787 .unwrap();
1788 super::make_executable(&bridge).unwrap();
1789 super::uninstall_legacy_hook_references(temp.path()).unwrap();
1790 let content = fs::read_to_string(&bridge).unwrap();
1791 assert!(!content.contains("truth-mirror"));
1792 assert!(content.contains(r#"exec "$(dirname "$0")/pre-push" "$@""#));
1793 }
1794
1795 #[test]
1796 fn broken_pre_entire_bridge_is_rewritten_even_without_truth_reference() {
1797 let temp = tempfile::tempdir().unwrap();
1798 let husky = temp.path().join(".husky");
1799 fs::create_dir_all(&husky).unwrap();
1800 let bridge = husky.join("pre-push.pre-entire");
1801 fs::write(
1804 &bridge,
1805 "#!/usr/bin/env sh\nset -e\nif command -v entire >/dev/null; then\n echo ok\nelse\nfi\n",
1806 )
1807 .unwrap();
1808 super::make_executable(&bridge).unwrap();
1809 super::uninstall_legacy_hook_references(temp.path()).unwrap();
1810 let content = fs::read_to_string(&bridge).unwrap();
1811 assert!(content.contains(r#"exec "$(dirname "$0")/pre-push" "$@""#));
1812 }
1813
1814 #[test]
1815 fn simple_pre_entire_bridge_is_preserved() {
1816 let temp = tempfile::tempdir().unwrap();
1817 let husky = temp.path().join(".husky");
1818 fs::create_dir_all(&husky).unwrap();
1819 let bridge = husky.join("commit-msg.pre-entire");
1820 fs::write(
1821 &bridge,
1822 "#!/bin/sh\nexec \"$(dirname \"$0\")/commit-msg\" \"$@\"\n",
1823 )
1824 .unwrap();
1825 super::make_executable(&bridge).unwrap();
1826 super::uninstall_legacy_hook_references(temp.path()).unwrap();
1827 let content = fs::read_to_string(&bridge).unwrap();
1828 assert!(content.contains(r#"exec "$(dirname "$0")/commit-msg" "$@""#));
1829 }
1830
1831 #[test]
1832 fn install_migrates_root_truth_md_to_state_dir() {
1833 let temp = tempfile::tempdir().unwrap();
1834 let repo_root = temp.path().join("repo");
1835 fs::create_dir_all(&repo_root).unwrap();
1836 Command::new("git")
1837 .args(["init"])
1838 .current_dir(&repo_root)
1839 .output()
1840 .unwrap();
1841 let root_truth = repo_root.join("TRUTH.md");
1842 fs::write(&root_truth, "# truth\n").unwrap();
1843 let state_dir = repo_root.join(".truth");
1844 fs::create_dir_all(&state_dir).unwrap();
1845 super::migrate_truth_md(&repo_root, &state_dir).unwrap();
1846 assert!(state_dir.join("TRUTH.md").is_file());
1847 assert!(!root_truth.exists());
1848 assert_eq!(
1849 fs::read_to_string(state_dir.join("TRUTH.md")).unwrap(),
1850 "# truth\n"
1851 );
1852 }
1853
1854 #[test]
1855 fn install_migrates_legacy_truth_mirror_md_to_state_dir() {
1856 let temp = tempfile::tempdir().unwrap();
1857 let repo_root = temp.path().join("repo");
1858 fs::create_dir_all(&repo_root).unwrap();
1859 let legacy_dir = repo_root.join(".truth-mirror");
1860 fs::create_dir_all(&legacy_dir).unwrap();
1861 fs::write(legacy_dir.join("TRUTH.md"), "# legacy truth\n").unwrap();
1862 let state_dir = repo_root.join(".truth");
1863 fs::create_dir_all(&state_dir).unwrap();
1864 super::migrate_truth_md(&repo_root, &state_dir).unwrap();
1865 assert!(state_dir.join("TRUTH.md").is_file());
1866 assert!(!legacy_dir.join("TRUTH.md").exists());
1867 }
1868
1869 proptest! {
1870 #[test]
1871 fn hook_shim_rendering_stays_tiny_exec_only(index in 0usize..3) {
1872 let hook = [HookName::CommitMsg, HookName::PostCommit, HookName::PrePush][index];
1873 let shim = render_shim(hook, "");
1874 let lines = shim.lines().collect::<Vec<_>>();
1875
1876 prop_assert_eq!(lines.len(), 3);
1877 prop_assert_eq!(lines[0], "#!/bin/sh");
1878 prop_assert_eq!(lines[1], super::MANAGED_MARKER);
1879 prop_assert!(lines[2].starts_with("exec truth-mirror hook-dispatch "));
1880 prop_assert!(lines[2].contains(hook.as_str()));
1881 prop_assert_eq!(shim.matches(super::MANAGED_MARKER).count(), 1);
1882 prop_assert_eq!(shim.matches("exec truth-mirror").count(), 1);
1883 }
1884 }
1885}