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 is_effectively_empty_shell(content: &str) -> bool {
976 content
977 .lines()
978 .map(str::trim)
979 .filter(|line| !line.is_empty() && !line.starts_with('#'))
980 .all(|line| line == "#!/bin/sh" || line == "#!/usr/bin/env sh" || line.starts_with("set "))
981}
982
983#[derive(Debug)]
984struct LocalHookSource {
985 path: PathBuf,
986 content: String,
987}
988
989fn local_hook_source(active: &Path, backup: &Path) -> Result<Option<LocalHookSource>> {
990 let Some(active_content) = read_to_string_if_file(active)? else {
991 return Ok(None);
992 };
993 if is_truth_mirror_managed(&active_content) {
994 return read_to_string_if_file(backup)?.map_or(Ok(None), |content| {
995 Ok(Some(LocalHookSource {
996 path: backup.to_path_buf(),
997 content,
998 }))
999 });
1000 }
1001 Ok(Some(LocalHookSource {
1002 path: active.to_path_buf(),
1003 content: active_content,
1004 }))
1005}
1006
1007fn custom_forwarder_present(hooks_path: &Path, hook: HookName) -> bool {
1008 read_to_string_if_file(&hooks_path.join(hook.as_str()))
1009 .ok()
1010 .flatten()
1011 .is_some_and(|content| {
1012 active_hook_lines(&content).any(|line| line.contains(FORWARDER_NAME))
1013 || content_forwards_to_local_git_hook(&content, hook)
1014 || is_truth_mirror_managed(&content)
1015 })
1016}
1017
1018fn ensure_state_gitignore(repo_root: &Path, state_dir: &Path) -> Result<()> {
1019 let state_dir = absolutize(repo_root, state_dir);
1020 fs::create_dir_all(&state_dir)?;
1021 let gitignore = state_dir.join(".gitignore");
1022 let existing = read_to_string_if_file(&gitignore)?.unwrap_or_default();
1023 let content = render_state_gitignore(&existing);
1024 fs::write(&gitignore, content)?;
1025 print_tracked_runtime_hints(repo_root, &state_dir)?;
1026 Ok(())
1027}
1028
1029fn render_state_gitignore(existing: &str) -> String {
1030 let mut content = String::new();
1031 content.push_str(STATE_GITIGNORE_HEADER);
1032 content.push('\n');
1033 for line in STATE_GITIGNORE_LINES {
1034 content.push_str(line);
1035 content.push('\n');
1036 }
1037
1038 let standard = |line: &str| {
1039 let trimmed = line.trim();
1040 trimmed == STATE_GITIGNORE_HEADER || STATE_GITIGNORE_LINES.contains(&trimmed)
1041 };
1042
1043 let mut wrote_separator = false;
1044 for line in existing.lines().filter(|line| !standard(line)) {
1045 if !wrote_separator {
1046 content.push('\n');
1047 wrote_separator = true;
1048 }
1049 content.push_str(line);
1050 content.push('\n');
1051 }
1052
1053 content
1054}
1055
1056fn print_tracked_runtime_hints(repo_root: &Path, state_dir: &Path) -> Result<()> {
1057 let rel_state = state_dir.strip_prefix(repo_root).unwrap_or(state_dir);
1058 for path in [
1059 rel_state.join("ledger.jsonl"),
1060 rel_state.join("ledger.md"),
1061 rel_state.join("review-queue.jsonl"),
1062 rel_state.join("runs"),
1063 rel_state.join("tmp"),
1064 rel_state.join("logs"),
1065 ] {
1066 let output = Command::new("git")
1067 .args(["ls-files", "--"])
1068 .arg(&path)
1069 .current_dir(repo_root)
1070 .output()?;
1071 if output.status.success() {
1072 for tracked in String::from_utf8_lossy(&output.stdout)
1073 .lines()
1074 .filter(|line| !line.trim().is_empty())
1075 {
1076 eprintln!("hint: run: git rm --cached {tracked}");
1077 }
1078 }
1079 }
1080 Ok(())
1081}
1082
1083fn clear_chained_hooks(plan: &HookInstallPlan) -> Result<()> {
1084 for hook in INSTALLED_HOOKS {
1085 remove_file_if_exists(&plan.hooks_path.join("chained").join(hook.as_str()))?;
1086 }
1087 Ok(())
1088}
1089
1090fn has_outer_hook_marker(content: &str) -> bool {
1091 content.lines().take(20).any(|line| {
1092 OUTER_HOOK_MARKERS
1093 .iter()
1094 .any(|marker| line.contains(marker))
1095 || (!line.trim_start().starts_with('#') && line_invokes_truth_hook_dispatch(line, None))
1096 })
1097}
1098
1099fn is_truth_mirror_managed(content: &str) -> bool {
1100 content.contains(MANAGED_MARKER)
1101 || active_hook_lines(content).any(|line| line_invokes_truth_hook_dispatch(line, None))
1102}
1103
1104const ENTIRE_MARKERS: &[&str] = &[
1107 "# Entire CLI hooks",
1108 "entire hooks git",
1109 "command -v entire",
1110];
1111
1112fn is_legacy_truth_reference(content: &str) -> bool {
1117 content.contains("truth-mirror")
1118 || content.contains("truth_mirror_bin")
1119 || is_truth_mirror_managed(content)
1120}
1121
1122fn strip_legacy_truth_lines(content: &str) -> String {
1125 let mut out = Vec::new();
1126 for line in content.lines() {
1127 let trimmed = line.trim();
1128 if trimmed.contains("truth-mirror")
1129 || trimmed.contains("truth_mirror_bin")
1130 || trimmed.starts_with("# Truthfulness gate")
1131 || trimmed.starts_with("# truth-mirror's")
1132 {
1133 continue;
1134 }
1135 out.push(line);
1136 }
1137 if out.is_empty() {
1138 String::new()
1139 } else {
1140 format!("{}\n", out.join("\n"))
1141 }
1142}
1143
1144fn uninstall_legacy_hook_references(repo_root: &Path) -> Result<()> {
1149 let husky_dir = repo_root.join(".husky");
1150 let git_hooks = repo_root.join(".git/hooks");
1151 let legacy_hooks = repo_root
1152 .join(crate::config::LEGACY_STATE_DIR)
1153 .join("hooks");
1154
1155 for hook in INSTALLED_HOOKS {
1156 let hook_name = hook.as_str();
1157 let names: Vec<String> = vec![hook_name.to_owned(), format!("{hook_name}.pre-entire")];
1158 for dir in [&husky_dir, &git_hooks, &legacy_hooks] {
1159 for name in &names {
1160 let path = dir.join(name);
1161 let Some(content) = read_to_string_if_file(&path)? else {
1162 continue;
1163 };
1164 if content.contains(MANAGED_MARKER) {
1165 continue;
1166 }
1167 if !is_legacy_truth_reference(&content) {
1168 continue;
1169 }
1170 if ENTIRE_MARKERS.iter().any(|marker| content.contains(marker)) {
1171 let stripped = strip_legacy_truth_lines(&content);
1172 if is_effectively_empty_shell(&stripped) {
1173 fs::remove_file(&path)?;
1174 } else {
1175 fs::write(&path, stripped)?;
1176 make_executable(&path)?;
1177 }
1178 } else {
1179 fs::remove_file(&path)?;
1180 }
1181 }
1182 }
1183 }
1184 Ok(())
1185}
1186
1187fn migrate_truth_md(repo_root: &Path, state_dir: &Path) -> Result<()> {
1190 let target = state_dir.join("TRUTH.md");
1191 if target.is_file() {
1192 return Ok(());
1193 }
1194 let legacy = repo_root
1195 .join(crate::config::LEGACY_STATE_DIR)
1196 .join("TRUTH.md");
1197 if legacy.is_file() {
1198 fs::copy(&legacy, &target)?;
1199 fs::remove_file(&legacy)?;
1200 return Ok(());
1201 }
1202 let root = repo_root.join("TRUTH.md");
1203 if root.is_file() {
1204 fs::rename(&root, &target)?;
1205 }
1206 Ok(())
1207}
1208
1209fn active_hook_lines(content: &str) -> impl Iterator<Item = &str> {
1210 content
1211 .lines()
1212 .map(str::trim_start)
1213 .filter(|line| !line.trim().is_empty() && !line.starts_with('#'))
1214}
1215
1216fn content_forwards_to_local_git_hook(content: &str, hook: HookName) -> bool {
1217 let resolves_git_dir =
1218 content.contains("--git-common-dir") || content.contains("git rev-parse --git-common-dir");
1219 resolves_git_dir
1220 && active_hook_lines(content)
1221 .any(|line| line_forwards_to_local_git_hook_with_args(line, hook))
1222}
1223
1224fn line_forwards_to_local_git_hook_with_args(line: &str, hook: HookName) -> bool {
1225 line.contains("$@")
1226 && (line.contains(&format!("hooks/{}", hook.as_str()))
1227 || line.contains("hooks/$name")
1228 || line.contains("hooks/${name}")
1229 || line.contains("hooks/$hook")
1230 || line.contains("hooks/${hook}"))
1231}
1232
1233fn line_invokes_truth_hook_dispatch(line: &str, expected_hook: Option<HookName>) -> bool {
1234 crate::shell::shellish_token_segments(line)
1235 .iter()
1236 .any(|tokens| token_segment_invokes_truth_hook_dispatch(tokens, expected_hook))
1237}
1238
1239fn token_segment_invokes_truth_hook_dispatch(
1240 tokens: &[&str],
1241 expected_hook: Option<HookName>,
1242) -> bool {
1243 for (index, token) in tokens.iter().enumerate() {
1244 if !is_truth_binary(token) {
1245 continue;
1246 }
1247 let Some(dispatch_offset) = tokens[index + 1..]
1248 .iter()
1249 .position(|candidate| *candidate == "hook-dispatch")
1250 else {
1251 continue;
1252 };
1253 let hook_index = index + 1 + dispatch_offset + 1;
1254 let Some(hook) = tokens.get(hook_index) else {
1255 continue;
1256 };
1257 if expected_hook.is_some_and(|expected| hook == &expected.as_str())
1258 || expected_hook.is_none() && token_is_installed_hook(hook)
1259 {
1260 return true;
1261 }
1262 }
1263 false
1264}
1265
1266fn is_truth_binary(token: &str) -> bool {
1267 token == "truth"
1268 || token == "truth-mirror"
1269 || token.ends_with("/truth")
1270 || token.ends_with("/truth-mirror")
1271}
1272
1273fn token_is_installed_hook(token: &str) -> bool {
1274 INSTALLED_HOOKS.iter().any(|hook| token == hook.as_str())
1275}
1276
1277fn backup_path(path: &Path, suffix: &str) -> PathBuf {
1278 let file_name = path
1279 .file_name()
1280 .and_then(|name| name.to_str())
1281 .unwrap_or("hook");
1282 path.with_file_name(format!("{file_name}.{suffix}"))
1283}
1284
1285fn read_to_string_if_file(path: &Path) -> Result<Option<String>> {
1286 match fs::read_to_string(path) {
1287 Ok(content) => Ok(Some(content)),
1288 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
1289 Err(error) => Err(error.into()),
1290 }
1291}
1292
1293fn read_bytes_if_file(path: &Path) -> Result<Option<Vec<u8>>> {
1295 match fs::read(path) {
1296 Ok(bytes) => Ok(Some(bytes)),
1297 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
1298 Err(error) => Err(error.into()),
1299 }
1300}
1301
1302fn remove_file_if_exists(path: &Path) -> Result<()> {
1303 match fs::remove_file(path) {
1304 Ok(()) => Ok(()),
1305 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
1306 Err(error) => Err(error.into()),
1307 }
1308}
1309
1310fn unset_core_hooks_path(repo_root: &Path) -> Result<()> {
1311 let status = Command::new("git")
1312 .args(["config", "--unset", "core.hooksPath"])
1313 .current_dir(repo_root)
1314 .status()?;
1315 if !status.success() {
1316 return Ok(());
1318 }
1319 Ok(())
1320}
1321
1322fn dispatch_commit_msg(
1323 state_dir: &Path,
1324 args: &[String],
1325 config: &crate::config::TruthMirrorConfig,
1326) -> Result<()> {
1327 let commit_msg_path = args
1328 .first()
1329 .context("commit-msg hook requires COMMIT_EDITMSG path")?;
1330 let commit_message = fs::read_to_string(commit_msg_path)?;
1331 let diff = git_stdout(&["diff", "--cached"])?;
1332 let claim_file = fs::read_to_string(state_dir.join("claim.txt")).ok();
1333 let policy = config.gates.to_policy();
1334 claim::evaluate_commit_message(&commit_message, claim_file.as_deref(), Some(&diff), &policy)?;
1335 Ok(())
1336}
1337
1338fn dispatch_post_commit(state_dir: &Path) -> Result<()> {
1339 let sha = git_stdout(&["rev-parse", "HEAD"])?;
1340 ReviewQueue::new(state_dir).enqueue(sha.trim())?;
1341 Ok(())
1342}
1343
1344fn dispatch_pre_push(
1345 state_dir: &Path,
1346 config_path: Option<&Path>,
1347 config: &crate::config::TruthMirrorConfig,
1348 args: &[String],
1349) -> Result<()> {
1350 let remote_name = pre_push_remote_name_from_args(args);
1351 let mut stdin = String::new();
1352 io::stdin().read_to_string(&mut stdin)?;
1353 for line in stdin.lines() {
1354 if let Some(range) = pre_push_range_from_line_for_remote(line, remote_name) {
1355 gate::run(
1356 cli::GateArgs {
1357 pre_push: Some(range),
1358 commit_msg: None,
1359 claim_file: None,
1360 diff_file: None,
1361 fake_markers: Vec::new(),
1362 pre_tool_use: false,
1363 tool: None,
1364 },
1365 state_dir,
1366 config_path,
1367 config,
1368 )?;
1369 }
1370 }
1371 Ok(())
1372}
1373
1374fn pre_push_range_from_line_for_remote(line: &str, remote_name: Option<&str>) -> Option<String> {
1375 let parts = line.split_whitespace().collect::<Vec<_>>();
1376 let local_sha = parts.get(1)?;
1377 let remote_sha = parts.get(3)?;
1378 if is_zero_sha(local_sha) {
1379 return None;
1380 }
1381
1382 if is_zero_sha(remote_sha) {
1383 match remote_name.filter(|name| safe_pre_push_remote_name(name)) {
1384 Some(remote_name) => Some(format!("new:{remote_name}:{local_sha}")),
1385 None => Some(format!("new:{local_sha}")),
1386 }
1387 } else {
1388 Some(format!("{remote_sha}..{local_sha}"))
1389 }
1390}
1391
1392fn pre_push_remote_name_from_args(args: &[String]) -> Option<&str> {
1393 let remote_name = args.first()?.as_str();
1394 let remote_url = args.get(1).map(String::as_str);
1395 if remote_url == Some(remote_name) || !safe_pre_push_remote_name(remote_name) {
1396 return None;
1397 }
1398 Some(remote_name)
1399}
1400
1401fn safe_pre_push_remote_name(value: &str) -> bool {
1402 !value.is_empty()
1403 && !value.starts_with('-')
1404 && !value.contains(':')
1405 && !value.contains("..")
1406 && !value
1407 .chars()
1408 .any(|character| character.is_whitespace() || character.is_control())
1409 && !value
1410 .chars()
1411 .any(|character| matches!(character, '*' | '?' | '['))
1412}
1413
1414fn is_zero_sha(value: &str) -> bool {
1415 value.chars().all(|character| character == '0')
1416}
1417
1418fn run_chained_hook(state_dir: &Path, hook: HookName, args: &[String]) -> Result<()> {
1419 let chained = state_dir.join("hooks/chained").join(hook.as_str());
1420 if !chained.is_file() {
1421 return Ok(());
1422 }
1423 let content = fs::read_to_string(&chained)?;
1424 if has_outer_hook_marker(&content) {
1425 eprintln!(
1426 "{} skipping unsafe chained hook {} because it appears to invoke an outer hook manager",
1427 crate::messages::diagnostic_prefix(),
1428 chained.display()
1429 );
1430 return Ok(());
1431 }
1432
1433 let status = Command::new(&chained).args(args).status()?;
1434 if !status.success() {
1435 bail!(
1436 "chained hook {} failed with status {status}",
1437 chained.display()
1438 );
1439 }
1440 Ok(())
1441}
1442
1443fn git_root() -> Result<PathBuf> {
1444 Ok(PathBuf::from(
1445 git_stdout(&["rev-parse", "--show-toplevel"])?.trim(),
1446 ))
1447}
1448
1449fn git_stdout(args: &[&str]) -> Result<String> {
1450 let output = Command::new("git").args(args).output()?;
1451 if !output.status.success() {
1452 bail!(
1453 "git {} failed: {}",
1454 args.join(" "),
1455 String::from_utf8_lossy(&output.stderr)
1456 );
1457 }
1458 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1459}
1460
1461fn git_config_get(repo_root: &Path, key: &str) -> Result<Option<String>> {
1462 let output = Command::new("git")
1463 .args(["config", "--get", key])
1464 .current_dir(repo_root)
1465 .output()?;
1466 if output.status.success() {
1467 return Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()));
1468 }
1469 if output.status.code() == Some(1) {
1470 return Ok(None);
1471 }
1472 bail!(
1473 "git config --get {key} failed: {}",
1474 String::from_utf8_lossy(&output.stderr)
1475 );
1476}
1477
1478#[cfg(unix)]
1479fn make_executable(path: &Path) -> Result<()> {
1480 use std::os::unix::fs::PermissionsExt;
1481
1482 let mut permissions = fs::metadata(path)?.permissions();
1483 permissions.set_mode(0o755);
1484 fs::set_permissions(path, permissions)?;
1485 Ok(())
1486}
1487
1488#[cfg(not(unix))]
1489fn make_executable(_path: &Path) -> Result<()> {
1490 Ok(())
1491}
1492
1493#[cfg(test)]
1494mod tests {
1495 use std::{fs, process::Command};
1496
1497 use proptest::prelude::*;
1498
1499 use super::{HookInstallPlan, pre_push_range_from_line_for_remote, render_shim};
1500 use crate::cli::HookName;
1501
1502 #[test]
1503 fn hook_shim_is_only_exec_delegation() {
1504 let shim = render_shim(HookName::CommitMsg, "");
1505 let lines = shim.lines().collect::<Vec<_>>();
1506
1507 assert_eq!(lines.len(), 3);
1508 assert_eq!(lines[0], "#!/bin/sh");
1509 assert_eq!(lines[1], super::MANAGED_MARKER);
1510 assert_eq!(
1511 lines[2],
1512 "exec truth-mirror hook-dispatch commit-msg \"$@\""
1513 );
1514 }
1515
1516 #[test]
1517 fn quote_git_arg_escapes_shell_metacharacters() {
1518 use std::path::Path;
1519 assert_eq!(super::quote_git_arg(Path::new("/a/b.toml")), "'/a/b.toml'");
1520 assert_eq!(
1522 super::quote_git_arg(Path::new("/a/c;$(touch x).toml")),
1523 "'/a/c;$(touch x).toml'"
1524 );
1525 assert_eq!(
1527 super::quote_git_arg(Path::new("/a/it's.toml")),
1528 "'/a/it'\\''s.toml'"
1529 );
1530 }
1531
1532 #[test]
1533 fn normalize_path_preserves_unmatched_relative_parents() {
1534 use std::path::{Path, PathBuf};
1535
1536 assert_eq!(
1537 super::normalize_path(Path::new("../hooks")),
1538 PathBuf::from("../hooks")
1539 );
1540 assert_eq!(
1541 super::normalize_path(Path::new("a/../../hooks")),
1542 PathBuf::from("../hooks")
1543 );
1544 }
1545
1546 #[test]
1547 fn hook_shim_preserves_global_args() {
1548 let shim = render_shim(HookName::PrePush, "--config /abs/enforce.toml ");
1549 let lines = shim.lines().collect::<Vec<_>>();
1550
1551 assert_eq!(lines.len(), 3, "shim stays <=3 lines + exec-only");
1552 assert_eq!(
1553 lines[2],
1554 "exec truth-mirror --config /abs/enforce.toml hook-dispatch pre-push \"$@\""
1555 );
1556 }
1557
1558 #[test]
1559 fn dry_run_plan_names_hooks_and_hooks_path() {
1560 let plan = HookInstallPlan::new(
1561 std::path::Path::new("/repo"),
1562 std::path::Path::new("/repo/.truth/hooks"),
1563 false,
1564 );
1565 let rendered = plan.render();
1566
1567 assert!(rendered.contains("commit-msg"));
1568 assert!(rendered.contains("post-commit"));
1569 assert!(rendered.contains("pre-push"));
1570 assert!(rendered.contains("stateHooks=/repo/.truth/hooks"));
1571 assert!(rendered.contains("path=/repo/.git/hooks/commit-msg"));
1572 assert!(rendered.contains("body:\n#!/bin/sh\n# truth-mirror managed hook"));
1573 }
1574
1575 #[test]
1576 fn managed_block_replace_is_idempotent() {
1577 let block = super::render_husky_block(HookName::CommitMsg, "");
1578 let original = "#!/bin/sh\necho before\n";
1579 let once = super::append_managed_block(original, &block);
1580 let twice = super::append_managed_block(&once, &block);
1581
1582 assert_eq!(twice.matches(super::MANAGED_MARKER).count(), 1);
1583 assert!(twice.contains("echo before"));
1584 }
1585
1586 #[test]
1587 fn chained_hook_safety_filter_detects_outer_tools() {
1588 assert!(super::has_outer_hook_marker(
1589 "#!/bin/sh\n# Entire CLI hooks\nentire hooks git commit-msg\n"
1590 ));
1591 assert!(super::has_outer_hook_marker(
1592 "#!/bin/sh\n# truth-mirror managed hook\nexec truth-mirror hook-dispatch commit-msg \"$@\"\n"
1593 ));
1594 assert!(super::has_outer_hook_marker(
1595 "#!/bin/sh\nexec truth hook-dispatch commit-msg \"$@\"\n"
1596 ));
1597 assert!(super::has_outer_hook_marker(
1598 "#!/bin/sh\nexec /usr/local/bin/truth hook-dispatch commit-msg \"$@\"\n"
1599 ));
1600 assert!(!super::has_outer_hook_marker(
1601 "#!/bin/sh\n# exec truth hook-dispatch commit-msg \"$@\"\n"
1602 ));
1603 assert!(!super::has_outer_hook_marker("#!/bin/sh\necho safe\n"));
1604 }
1605
1606 #[test]
1607 fn managed_detection_ignores_commented_and_unrelated_truth_lines() {
1608 assert!(!super::is_truth_mirror_managed(
1609 "#!/bin/sh\n# exec truth hook-dispatch commit-msg \"$@\"\n"
1610 ));
1611 assert!(!super::is_truth_mirror_managed(
1612 "#!/bin/sh\nexec truth unrelated \"$@\"\n"
1613 ));
1614 assert!(!super::is_truth_mirror_managed(
1615 "#!/bin/sh\nexec truth --version && hook-dispatch commit-msg \"$@\"\n"
1616 ));
1617 assert!(!super::is_truth_mirror_managed(
1618 "#!/bin/sh\nhook-dispatch commit-msg \"$@\"; exec truth --version\n"
1619 ));
1620 assert!(super::is_truth_mirror_managed(
1621 "#!/bin/sh\nexec /opt/bin/truth hook-dispatch pre-push \"$@\"\n"
1622 ));
1623 }
1624
1625 #[test]
1626 fn run_chained_hook_skips_marker_bearing_stale_hook() {
1627 let temp = tempfile::tempdir().unwrap();
1628 let chained = temp.path().join("hooks/chained/commit-msg");
1629 std::fs::create_dir_all(chained.parent().unwrap()).unwrap();
1630 std::fs::write(&chained, "#!/bin/sh\n# Entire CLI hooks\nexit 42\n").unwrap();
1631 super::make_executable(&chained).unwrap();
1632
1633 super::run_chained_hook(temp.path(), HookName::CommitMsg, &[]).unwrap();
1634 }
1635
1636 #[test]
1637 fn pre_push_line_maps_to_git_range() {
1638 let line = "refs/heads/main abc123 refs/heads/main def456";
1639
1640 assert_eq!(
1641 pre_push_range_from_line_for_remote(line, None),
1642 Some("def456..abc123".to_owned())
1643 );
1644 }
1645
1646 #[test]
1647 fn pre_push_new_branch_maps_to_new_branch_token() {
1648 let line =
1649 "refs/heads/topic abc123 refs/heads/topic 0000000000000000000000000000000000000000";
1650
1651 assert_eq!(
1652 pre_push_range_from_line_for_remote(line, None),
1653 Some("new:abc123".to_owned())
1654 );
1655 }
1656
1657 #[test]
1658 fn pre_push_new_branch_includes_safe_remote_name_when_available() {
1659 let line =
1660 "refs/heads/topic abc123 refs/heads/topic 0000000000000000000000000000000000000000";
1661
1662 assert_eq!(
1663 pre_push_range_from_line_for_remote(line, Some("origin")),
1664 Some("new:origin:abc123".to_owned())
1665 );
1666 assert_eq!(
1667 pre_push_range_from_line_for_remote(line, Some("../bad")),
1668 Some("new:abc123".to_owned())
1669 );
1670 }
1671
1672 #[test]
1673 fn pre_push_args_omit_remote_name_for_direct_path_pushes() {
1674 assert_eq!(
1675 super::pre_push_remote_name_from_args(&[
1676 "origin".to_owned(),
1677 "git@example/repo".to_owned()
1678 ]),
1679 Some("origin")
1680 );
1681 assert_eq!(
1682 super::pre_push_remote_name_from_args(&[
1683 "../repo.git".to_owned(),
1684 "../repo.git".to_owned()
1685 ]),
1686 None
1687 );
1688 }
1689
1690 #[test]
1691 fn pre_push_args_omit_glob_like_remote_names() {
1692 for remote_name in ["bad*remote", "bad?remote", "bad[remote"] {
1693 assert_eq!(
1694 super::pre_push_remote_name_from_args(&[
1695 remote_name.to_owned(),
1696 "git@example/repo".to_owned()
1697 ]),
1698 None
1699 );
1700 }
1701 }
1702
1703 #[test]
1704 fn legacy_truth_only_husky_hook_is_removed() {
1705 let temp = tempfile::tempdir().unwrap();
1706 let husky = temp.path().join(".husky");
1707 fs::create_dir_all(&husky).unwrap();
1708 let hook = husky.join("commit-msg");
1709 fs::write(
1710 &hook,
1711 "#!/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",
1712 )
1713 .unwrap();
1714 super::make_executable(&hook).unwrap();
1715 super::uninstall_legacy_hook_references(temp.path()).unwrap();
1716 assert!(!hook.exists());
1717 }
1718
1719 #[test]
1720 fn legacy_truth_hook_with_entire_is_stripped_not_removed() {
1721 let temp = tempfile::tempdir().unwrap();
1722 let husky = temp.path().join(".husky");
1723 fs::create_dir_all(&husky).unwrap();
1724 let hook = husky.join("pre-push");
1725 fs::write(
1726 &hook,
1727 "#!/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",
1728 )
1729 .unwrap();
1730 super::make_executable(&hook).unwrap();
1731 super::uninstall_legacy_hook_references(temp.path()).unwrap();
1732 let content = fs::read_to_string(&hook).unwrap();
1733 assert!(!content.contains("truth-mirror"));
1734 assert!(content.contains("entire hooks git pre-push"));
1735 }
1736
1737 #[test]
1738 fn legacy_truth_mirror_bin_pre_entire_bridge_is_removed() {
1739 let temp = tempfile::tempdir().unwrap();
1740 let husky = temp.path().join(".husky");
1741 fs::create_dir_all(&husky).unwrap();
1742 let bridge = husky.join("pre-push.pre-entire");
1743 fs::write(
1744 &bridge,
1745 "#!/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",
1746 )
1747 .unwrap();
1748 super::make_executable(&bridge).unwrap();
1749 super::uninstall_legacy_hook_references(temp.path()).unwrap();
1750 assert!(!bridge.exists());
1751 }
1752
1753 #[test]
1754 fn install_migrates_root_truth_md_to_state_dir() {
1755 let temp = tempfile::tempdir().unwrap();
1756 let repo_root = temp.path().join("repo");
1757 fs::create_dir_all(&repo_root).unwrap();
1758 Command::new("git")
1759 .args(["init"])
1760 .current_dir(&repo_root)
1761 .output()
1762 .unwrap();
1763 let root_truth = repo_root.join("TRUTH.md");
1764 fs::write(&root_truth, "# truth\n").unwrap();
1765 let state_dir = repo_root.join(".truth");
1766 fs::create_dir_all(&state_dir).unwrap();
1767 super::migrate_truth_md(&repo_root, &state_dir).unwrap();
1768 assert!(state_dir.join("TRUTH.md").is_file());
1769 assert!(!root_truth.exists());
1770 assert_eq!(
1771 fs::read_to_string(state_dir.join("TRUTH.md")).unwrap(),
1772 "# truth\n"
1773 );
1774 }
1775
1776 #[test]
1777 fn install_migrates_legacy_truth_mirror_md_to_state_dir() {
1778 let temp = tempfile::tempdir().unwrap();
1779 let repo_root = temp.path().join("repo");
1780 fs::create_dir_all(&repo_root).unwrap();
1781 let legacy_dir = repo_root.join(".truth-mirror");
1782 fs::create_dir_all(&legacy_dir).unwrap();
1783 fs::write(legacy_dir.join("TRUTH.md"), "# legacy truth\n").unwrap();
1784 let state_dir = repo_root.join(".truth");
1785 fs::create_dir_all(&state_dir).unwrap();
1786 super::migrate_truth_md(&repo_root, &state_dir).unwrap();
1787 assert!(state_dir.join("TRUTH.md").is_file());
1788 assert!(!legacy_dir.join("TRUTH.md").exists());
1789 }
1790
1791 proptest! {
1792 #[test]
1793 fn hook_shim_rendering_stays_tiny_exec_only(index in 0usize..3) {
1794 let hook = [HookName::CommitMsg, HookName::PostCommit, HookName::PrePush][index];
1795 let shim = render_shim(hook, "");
1796 let lines = shim.lines().collect::<Vec<_>>();
1797
1798 prop_assert_eq!(lines.len(), 3);
1799 prop_assert_eq!(lines[0], "#!/bin/sh");
1800 prop_assert_eq!(lines[1], super::MANAGED_MARKER);
1801 prop_assert!(lines[2].starts_with("exec truth-mirror hook-dispatch "));
1802 prop_assert!(lines[2].contains(hook.as_str()));
1803 prop_assert_eq!(shim.matches(super::MANAGED_MARKER).count(), 1);
1804 prop_assert_eq!(shim.matches("exec truth-mirror").count(), 1);
1805 }
1806 }
1807}