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