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