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 HUSKY_ENTIRE_BRIDGE_MARKER: &str = "# Bridge for the husky+entire chain";
25pub(crate) const FORWARDER_NAME: &str = "_forward-local.sh";
26const OUTER_HOOK_MARKERS: &[&str] = &[
27 "# Entire CLI hooks",
28 HUSKY_ENTIRE_BRIDGE_MARKER,
29 "# truth-mirror managed hook",
30 "entire hooks git",
31];
32const STATE_GITIGNORE_HEADER: &str = "# truth-mirror runtime state - machine-generated";
33const STATE_GITIGNORE_LINES: &[&str] =
34 &["runs/", "review-queue.jsonl", "tmp/", "logs/", "*.local.*"];
35pub(crate) const FORWARDER_SOURCE: &str = r#"#!/bin/sh
36# _forward-local.sh - delegate to .git/hooks from a committed core.hooksPath.
37# Uses --git-common-dir (resolves to .git/) not core.hooksPath to prevent an infinite loop.
38name="$1"; shift
39git_dir="$(git rev-parse --git-common-dir 2>/dev/null)"
40[ -n "$git_dir" ] || exit 0
41local_hook="$git_dir/hooks/$name"
42if [ -x "$local_hook" ]; then exec "$local_hook" "$@"; fi
43exit 0
44"#;
45
46const PI_TRUST_NOTE: &str = "pi: installed .pi/extensions/truth-mirror.js — Pi loads it once you trust this project folder in Pi.";
49
50pub fn run(
51 args: cli::InstallHooksArgs,
52 state_dir: &Path,
53 config_path: Option<&Path>,
54 config: &crate::config::TruthMirrorConfig,
55) -> Result<ExitCode> {
56 let repo_root = git_root()?;
57 let hooks_path = repo_root.join(state_dir).join("hooks");
58 let plan = HookInstallPlan::new(&repo_root, &hooks_path, args.uninstall);
59 let global_args = hook_global_args(config_path, &repo_root, state_dir);
62 let plan = HookInstallPlan {
63 global_args,
64 mode: detect_hook_mode(&repo_root, state_dir)?,
65 ..plan
66 };
67 let agents = file_surface_agents(&args);
68 let pi = pi_targeted(&args);
69
70 if args.dry_run {
71 println!("{}", plan.render());
72 for agent in &agents {
73 println!(
74 "surface: {} -> {}",
75 surface::agent_slug(*agent),
76 surface::surface_relative_path(*agent)
77 );
78 }
79 if pi {
80 println!("surface: pi -> {}", surface::PI_EXTENSION_RELATIVE);
81 }
82 return Ok(ExitCode::SUCCESS);
83 }
84
85 let enforcement_enabled = config.enforcement.is_enabled();
86
87 if args.uninstall {
88 uninstall(&plan)?;
89 for agent in &agents {
90 surface::uninstall_enforcement(&repo_root, *agent)?;
91 SurfacePlan::for_agent(&repo_root, *agent).uninstall()?;
92 }
93 if pi {
94 surface::uninstall_pi_extension(&repo_root)?;
95 remove_legacy_pi_hooks(&repo_root)?;
96 }
97 } else {
98 install(&plan, args.inject_forwarder)?;
99 print_async_review_guidance(&plan.global_args);
100 for agent in &agents {
101 SurfacePlan::for_agent(&repo_root, *agent).install()?;
102 if enforcement_enabled {
105 surface::install_enforcement(&repo_root, *agent, &plan.global_args)?;
106 }
107 }
108 if args.pi {
109 remove_legacy_pi_hooks(&repo_root)?;
112 surface::install_pi_extension(&repo_root)?;
113 println!("{PI_TRUST_NOTE}");
114 }
115 }
116
117 Ok(ExitCode::SUCCESS)
118}
119
120fn print_async_review_guidance(global_args: &str) {
121 eprintln!(
122 "{} post-commit hooks queue async reviews; {}.",
123 crate::messages::diagnostic_prefix(),
124 crate::messages::async_review_drain_hint(global_args)
125 );
126}
127
128fn file_surface_agents(args: &cli::InstallHooksArgs) -> Vec<Agent> {
132 let mut agents = Vec::new();
133 if args.claude {
134 agents.push(Agent::Claude);
135 }
136 if args.codex {
137 agents.push(Agent::Codex);
138 }
139
140 if agents.is_empty() && args.uninstall && !args.pi {
141 return surface::FILE_SURFACE_AGENTS.to_vec();
142 }
143 agents
144}
145
146fn pi_targeted(args: &cli::InstallHooksArgs) -> bool {
149 args.pi || (args.uninstall && !args.claude && !args.codex)
150}
151
152fn remove_legacy_pi_hooks(repo_root: &Path) -> Result<()> {
154 let path = repo_root.join(".pi/hooks.json");
155 if path.is_file() {
156 fs::remove_file(&path)?;
157 }
158 Ok(())
159}
160
161pub fn dispatch(
162 args: cli::HookDispatchArgs,
163 state_dir: &Path,
164 config_path: Option<&Path>,
165 config: &crate::config::TruthMirrorConfig,
166) -> Result<ExitCode> {
167 run_chained_hook(state_dir, args.hook, &args.args)?;
168
169 match args.hook {
170 HookName::CommitMsg => dispatch_commit_msg(state_dir, &args.args, config)?,
171 HookName::PostCommit => dispatch_post_commit(state_dir)?,
172 HookName::PrePush => dispatch_pre_push(state_dir, config_path, config, &args.args)?,
173 }
174
175 Ok(ExitCode::SUCCESS)
176}
177
178#[derive(Clone, Debug, Default, Eq, PartialEq)]
179pub struct HookInstallPlan {
180 pub repo_root: PathBuf,
181 pub hooks_path: PathBuf,
182 pub uninstall: bool,
183 pub mode: HookInstallMode,
184 pub global_args: String,
188}
189
190#[derive(Clone, Debug, Default, Eq, PartialEq)]
191pub enum HookInstallMode {
192 #[default]
193 Plain,
194 LegacyTruthMirrorHooksPath {
195 configured_path: String,
196 },
197 Husky {
198 content_dir: PathBuf,
199 },
200 CustomCommitted {
201 hooks_path: PathBuf,
202 },
203}
204
205impl HookInstallPlan {
206 pub fn new(repo_root: &Path, hooks_path: &Path, uninstall: bool) -> Self {
207 Self {
208 repo_root: repo_root.to_path_buf(),
209 hooks_path: hooks_path.to_path_buf(),
210 uninstall,
211 mode: HookInstallMode::Plain,
212 global_args: String::new(),
213 }
214 }
215
216 pub fn render(&self) -> String {
217 let action = if self.uninstall {
218 "uninstall"
219 } else {
220 "install"
221 };
222 let mut output = format!(
223 "truth-mirror hook plan\nrepo={}\naction={action}\nmode={}\nstateHooks={}\n",
224 self.repo_root.display(),
225 self.mode.as_str(),
226 self.hooks_path.display()
227 );
228 for hook in INSTALLED_HOOKS {
229 output.push_str(&format!(
230 "\nhook={}\npath={}\nbody:\n{}",
231 hook.as_str(),
232 self.active_hook_path(*hook).display(),
233 self.render_hook_body(*hook)
234 ));
235 if let HookInstallMode::Husky { content_dir } = &self.mode {
236 output.push_str(&format!(
237 "\nbridge={}.pre-entire\npath={}\nbody:\n{}",
238 hook.as_str(),
239 husky_entire_bridge_path(content_dir, *hook).display(),
240 render_husky_entire_bridge(*hook)
241 ));
242 }
243 }
244 output
245 }
246
247 fn active_hook_path(&self, hook: HookName) -> PathBuf {
248 match &self.mode {
249 HookInstallMode::Husky { content_dir } => content_dir.join(hook.as_str()),
250 HookInstallMode::Plain | HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
251 self.repo_root.join(".git/hooks").join(hook.as_str())
252 }
253 HookInstallMode::CustomCommitted { hooks_path } => hooks_path.join(hook.as_str()),
254 }
255 }
256
257 fn render_hook_body(&self, hook: HookName) -> String {
258 match self.mode {
259 HookInstallMode::Husky { .. } => render_husky_block(hook, &self.global_args),
260 HookInstallMode::CustomCommitted { .. } => render_custom_forwarder_stub(hook),
261 HookInstallMode::Plain | HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
262 render_shim(hook, &self.global_args)
263 }
264 }
265 }
266}
267
268impl HookInstallMode {
269 fn as_str(&self) -> &'static str {
270 match self {
271 HookInstallMode::Plain => "plain",
272 HookInstallMode::LegacyTruthMirrorHooksPath { .. } => "plain-legacy-truth-mirror",
273 HookInstallMode::Husky { .. } => "husky",
274 HookInstallMode::CustomCommitted { .. } => "custom-committed",
275 }
276 }
277}
278
279fn hook_global_args(config_path: Option<&Path>, repo_root: &Path, state_dir: &Path) -> String {
281 let mut parts = Vec::new();
282 if let Some(config) = config_path {
283 parts.push(format!(
284 "--config {}",
285 quote_git_arg(&absolutize(repo_root, config))
286 ));
287 }
288 if state_dir != Path::new(crate::config::DEFAULT_STATE_DIR) {
289 parts.push(format!(
290 "--state-dir {}",
291 quote_git_arg(&absolutize(repo_root, state_dir))
292 ));
293 }
294 if parts.is_empty() {
295 String::new()
296 } else {
297 format!("{} ", parts.join(" "))
298 }
299}
300
301fn absolutize(repo_root: &Path, path: &Path) -> PathBuf {
302 if path.is_absolute() {
303 path.to_path_buf()
304 } else {
305 repo_root.join(path)
306 }
307}
308
309fn quote_git_arg(path: &Path) -> String {
313 let value = path.to_string_lossy();
314 format!("'{}'", value.replace('\'', "'\\''"))
315}
316
317pub fn render_shim(hook: HookName, global_args: &str) -> String {
318 format!(
319 "#!/bin/sh\n{MANAGED_MARKER}\nexec truth-mirror {global_args}hook-dispatch {} \"$@\"\n",
320 hook.as_str()
321 )
322}
323
324fn render_husky_block(hook: HookName, global_args: &str) -> String {
325 format!(
326 "{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",
327 hook.as_str()
328 )
329}
330
331fn render_husky_entire_bridge(hook: HookName) -> String {
332 format!(
333 "#!/bin/sh\n{HUSKY_ENTIRE_BRIDGE_MARKER}\n{}\n",
334 husky_entire_bridge_exec_line(hook)
335 )
336}
337
338fn husky_entire_bridge_exec_line(hook: HookName) -> String {
339 format!("exec \"$(dirname \"$0\")/{}\" \"$@\"", hook.as_str())
340}
341
342fn render_custom_forwarder_stub(hook: HookName) -> String {
343 format!(
344 "#!/bin/sh\n{MANAGED_MARKER}\nexec \"$(dirname \"$0\")/{FORWARDER_NAME}\" {} \"$@\"\n",
345 hook.as_str()
346 )
347}
348
349fn detect_hook_mode(repo_root: &Path, state_dir: &Path) -> Result<HookInstallMode> {
350 let Some(configured) = git_config_get(repo_root, "core.hooksPath")? else {
351 return Ok(HookInstallMode::Plain);
352 };
353 let configured = configured.trim().to_owned();
354 if configured.is_empty() {
355 return Ok(HookInstallMode::Plain);
356 }
357 if is_managed_hooks_path(repo_root, state_dir, &configured) {
358 return Ok(HookInstallMode::LegacyTruthMirrorHooksPath {
359 configured_path: configured,
360 });
361 }
362 if configured.contains(".husky") {
363 let hooks_path = repo_relative_path(repo_root, Path::new(&configured));
364 let content_dir = if hooks_path.file_name().and_then(|name| name.to_str()) == Some("_") {
365 hooks_path
366 .parent()
367 .map_or_else(|| repo_root.join(".husky"), Path::to_path_buf)
368 } else {
369 hooks_path
370 };
371 return Ok(HookInstallMode::Husky { content_dir });
372 }
373 Ok(HookInstallMode::CustomCommitted {
374 hooks_path: repo_relative_path(repo_root, Path::new(&configured)),
375 })
376}
377
378fn repo_relative_path(repo_root: &Path, path: &Path) -> PathBuf {
379 if path.is_absolute() {
380 path.to_path_buf()
381 } else {
382 repo_root.join(path)
383 }
384}
385
386fn is_managed_hooks_path(repo_root: &Path, state_dir: &Path, configured: &str) -> bool {
387 let configured_path = normalize_path(&repo_relative_path(repo_root, Path::new(configured)));
388 [
389 state_dir.join("hooks"),
390 PathBuf::from(crate::config::DEFAULT_STATE_DIR).join("hooks"),
391 PathBuf::from(crate::config::LEGACY_STATE_DIR).join("hooks"),
392 ]
393 .iter()
394 .any(|candidate| normalize_path(&repo_relative_path(repo_root, candidate)) == configured_path)
395}
396
397fn normalize_path(path: &Path) -> PathBuf {
398 let mut normalized = PathBuf::new();
399 for component in path.components() {
400 match component {
401 Component::CurDir => {}
402 Component::ParentDir => {
403 if normalized.as_os_str().is_empty()
404 || (!normalized.has_root() && normalized.ends_with(".."))
405 {
406 normalized.push("..");
407 } else {
408 normalized.pop();
409 }
410 }
411 Component::Normal(part) => normalized.push(part),
412 Component::RootDir | Component::Prefix(_) => normalized.push(component.as_os_str()),
413 }
414 }
415 normalized
416}
417
418fn install(plan: &HookInstallPlan, inject_forwarder: bool) -> Result<()> {
419 match &plan.mode {
420 HookInstallMode::Plain => {
421 prepare_state_hooks(plan)?;
422 install_plain(plan)
423 }
424 HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
425 prepare_state_hooks(plan)?;
426 unset_core_hooks_path(&plan.repo_root)?;
427 install_plain(plan)
428 }
429 HookInstallMode::Husky { content_dir } => {
430 prepare_state_hooks(plan)?;
431 install_husky(plan, content_dir)
432 }
433 HookInstallMode::CustomCommitted { hooks_path } => {
434 install_custom(plan, hooks_path, inject_forwarder)
435 }
436 }
437}
438
439fn uninstall(plan: &HookInstallPlan) -> Result<()> {
440 match &plan.mode {
441 HookInstallMode::Plain => uninstall_plain(plan)?,
442 HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
443 unset_core_hooks_path(&plan.repo_root)?;
444 uninstall_plain(plan)?;
445 }
446 HookInstallMode::Husky { content_dir } => uninstall_husky(plan, content_dir)?,
447 HookInstallMode::CustomCommitted { hooks_path } => {
448 uninstall_plain(plan)?;
449 uninstall_custom_forwarders(hooks_path)?;
450 }
451 }
452
453 if plan.hooks_path.exists() {
454 fs::remove_dir_all(&plan.hooks_path)?;
455 }
456 Ok(())
457}
458
459fn state_dir_from_hooks_path(hooks_path: &Path) -> &Path {
460 hooks_path.parent().unwrap_or(hooks_path)
461}
462
463fn prepare_state_hooks(plan: &HookInstallPlan) -> Result<()> {
464 ensure_state_gitignore(&plan.repo_root, state_dir_from_hooks_path(&plan.hooks_path))?;
465 fs::create_dir_all(&plan.hooks_path)?;
466 fs::create_dir_all(plan.hooks_path.join("chained"))?;
467 clear_chained_hooks(plan)
468}
469
470fn install_plain(plan: &HookInstallPlan) -> Result<()> {
471 let git_hooks = plan.repo_root.join(".git/hooks");
472 fs::create_dir_all(&git_hooks)?;
473 for hook in INSTALLED_HOOKS {
474 install_local_hook(plan, &git_hooks, *hook)?;
475 }
476 Ok(())
477}
478
479fn install_local_hook(plan: &HookInstallPlan, git_hooks: &Path, hook: HookName) -> Result<()> {
480 let active = git_hooks.join(hook.as_str());
481 let backup = backup_path(&active, "pre-truth-mirror");
482 let chained = plan.hooks_path.join("chained").join(hook.as_str());
483 remove_file_if_exists(&chained)?;
484
485 let source = local_hook_source(&active, &backup)?;
486 if let Some(source) = source {
487 if source.path != backup {
488 fs::copy(&source.path, &backup)?;
489 make_executable(&backup)?;
490 }
491 if !has_outer_hook_marker(&source.content) {
492 fs::copy(&source.path, &chained)?;
493 make_executable(&chained)?;
494 }
495 }
496
497 fs::write(&active, render_shim(hook, &plan.global_args))?;
498 make_executable(&active)?;
499 Ok(())
500}
501
502fn uninstall_plain(plan: &HookInstallPlan) -> Result<()> {
503 let git_hooks = plan.repo_root.join(".git/hooks");
504 for hook in INSTALLED_HOOKS {
505 let active = git_hooks.join(hook.as_str());
506 let backup = backup_path(&active, "pre-truth-mirror");
507 let active_content = read_to_string_if_file(&active)?;
508 if active_content
509 .as_deref()
510 .is_some_and(is_truth_mirror_managed)
511 {
512 if backup.is_file() {
513 fs::copy(&backup, &active)?;
514 make_executable(&active)?;
515 fs::remove_file(&backup)?;
516 } else {
517 remove_file_if_exists(&active)?;
518 }
519 }
520 remove_file_if_exists(&plan.hooks_path.join("chained").join(hook.as_str()))?;
521 }
522 Ok(())
523}
524
525fn install_husky(plan: &HookInstallPlan, content_dir: &Path) -> Result<()> {
526 fs::create_dir_all(content_dir)?;
527 for hook in INSTALLED_HOOKS {
528 let target = content_dir.join(hook.as_str());
529 let block = render_husky_block(*hook, &plan.global_args);
530 let next = match read_to_string_if_file(&target)? {
531 Some(content) => append_managed_block(&content, &block),
532 None => format!("#!/bin/sh\n{block}"),
533 };
534 fs::write(&target, next)?;
535 make_executable(&target)?;
536 install_husky_entire_bridge(content_dir, *hook)?;
537 }
538 Ok(())
539}
540
541fn install_husky_entire_bridge(content_dir: &Path, hook: HookName) -> Result<()> {
542 let target = husky_entire_bridge_path(content_dir, hook);
543 let backup = backup_path(&target, "pre-truth-mirror");
544 if let Some(content) = read_to_string_if_file(&target)? {
545 if is_husky_entire_bridge(&content) && has_husky_entire_bridge_exec(&content, hook) {
546 make_executable(&target)?;
547 return Ok(());
548 }
549 if !is_husky_entire_bridge(&content) {
550 fs::copy(&target, &backup)?;
551 make_executable(&backup)?;
552 }
553 }
554 fs::write(&target, render_husky_entire_bridge(hook))?;
555 make_executable(&target)?;
556 Ok(())
557}
558
559fn uninstall_husky(_plan: &HookInstallPlan, content_dir: &Path) -> Result<()> {
560 for hook in INSTALLED_HOOKS {
561 let target = content_dir.join(hook.as_str());
562 if let Some(content) = read_to_string_if_file(&target)? {
563 let next = remove_managed_block(&content);
564 if is_empty_or_shebang_only(&next) {
565 fs::remove_file(&target)?;
566 } else {
567 fs::write(&target, next)?;
568 make_executable(&target)?;
569 }
570 }
571 uninstall_husky_entire_bridge(content_dir, *hook)?;
572 }
573 Ok(())
574}
575
576fn uninstall_husky_entire_bridge(content_dir: &Path, hook: HookName) -> Result<()> {
577 let target = husky_entire_bridge_path(content_dir, hook);
578 let backup = backup_path(&target, "pre-truth-mirror");
579 match read_to_string_if_file(&target)? {
580 Some(content) if is_husky_entire_bridge(&content) => {
581 if backup.is_file() {
582 fs::copy(&backup, &target)?;
583 make_executable(&target)?;
584 fs::remove_file(&backup)?;
585 } else {
586 remove_file_if_exists(&target)?;
587 }
588 }
589 None if backup.is_file() => {
590 fs::copy(&backup, &target)?;
591 make_executable(&target)?;
592 fs::remove_file(&backup)?;
593 }
594 _ => {}
595 }
596 Ok(())
597}
598
599fn husky_entire_bridge_path(content_dir: &Path, hook: HookName) -> PathBuf {
600 content_dir.join(format!("{}.pre-entire", hook.as_str()))
601}
602
603fn install_custom(plan: &HookInstallPlan, hooks_path: &Path, inject_forwarder: bool) -> Result<()> {
604 let missing_forwarder = INSTALLED_HOOKS
605 .iter()
606 .any(|hook| !custom_forwarder_present(hooks_path, *hook));
607 if missing_forwarder && !inject_forwarder {
608 bail!(
609 "{} 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",
610 crate::messages::diagnostic_prefix(),
611 hooks_path.display(),
612 crate::messages::command_for_cli(&plan.global_args, "install-hooks --inject-forwarder"),
613 );
614 }
615
616 if inject_forwarder {
617 inject_custom_forwarders(hooks_path)?;
618 }
619
620 prepare_state_hooks(plan)?;
621 let git_hooks = plan.repo_root.join(".git/hooks");
622 fs::create_dir_all(&git_hooks)?;
623 for hook in INSTALLED_HOOKS {
624 install_local_hook(plan, &git_hooks, *hook)?;
625 }
626 Ok(())
627}
628
629fn inject_custom_forwarders(hooks_path: &Path) -> Result<()> {
630 fs::create_dir_all(hooks_path)?;
631 let forwarder = hooks_path.join(FORWARDER_NAME);
632 fs::write(&forwarder, FORWARDER_SOURCE)?;
633 make_executable(&forwarder)?;
634
635 for hook in INSTALLED_HOOKS {
636 let target = hooks_path.join(hook.as_str());
637 if custom_forwarder_present(hooks_path, *hook) {
638 continue;
639 }
640 if target.is_file() {
641 fs::copy(&target, backup_path(&target, "pre-truth-mirror"))?;
642 }
643 fs::write(&target, render_custom_forwarder_stub(*hook))?;
644 make_executable(&target)?;
645 }
646 eprintln!(
647 "hint: commit the truth-mirror forwarder changes in {}",
648 hooks_path.display()
649 );
650 Ok(())
651}
652
653fn uninstall_custom_forwarders(hooks_path: &Path) -> Result<()> {
654 for hook in INSTALLED_HOOKS {
655 let target = hooks_path.join(hook.as_str());
656 let backup = backup_path(&target, "pre-truth-mirror");
657 let content = read_to_string_if_file(&target)?;
658 if content.as_deref().is_some_and(is_truth_mirror_managed) {
659 if backup.is_file() {
660 fs::copy(&backup, &target)?;
661 make_executable(&target)?;
662 fs::remove_file(&backup)?;
663 } else {
664 remove_file_if_exists(&target)?;
665 }
666 }
667 }
668 let forwarder = hooks_path.join(FORWARDER_NAME);
669 if read_to_string_if_file(&forwarder)?.as_deref() == Some(FORWARDER_SOURCE) {
670 fs::remove_file(forwarder)?;
671 }
672 Ok(())
673}
674
675fn append_managed_block(content: &str, block: &str) -> String {
676 let mut next = remove_managed_block(content);
677 if !next.ends_with('\n') {
678 next.push('\n');
679 }
680 next.push_str(block);
681 next
682}
683
684fn remove_managed_block(content: &str) -> String {
685 let mut out = Vec::new();
686 let mut skipping = false;
687 for line in content.lines() {
688 if line == MANAGED_MARKER {
689 skipping = true;
690 continue;
691 }
692 if skipping {
693 if line == MANAGED_END_MARKER {
694 skipping = false;
695 }
696 continue;
697 }
698 out.push(line);
699 }
700 if out.is_empty() {
701 String::new()
702 } else {
703 format!("{}\n", out.join("\n"))
704 }
705}
706
707fn is_empty_or_shebang_only(content: &str) -> bool {
708 let lines = content
709 .lines()
710 .map(str::trim)
711 .filter(|line| !line.is_empty())
712 .collect::<Vec<_>>();
713 lines.is_empty() || lines == ["#!/bin/sh"]
714}
715
716#[derive(Debug)]
717struct LocalHookSource {
718 path: PathBuf,
719 content: String,
720}
721
722fn local_hook_source(active: &Path, backup: &Path) -> Result<Option<LocalHookSource>> {
723 let Some(active_content) = read_to_string_if_file(active)? else {
724 return Ok(None);
725 };
726 if is_truth_mirror_managed(&active_content) {
727 return read_to_string_if_file(backup)?.map_or(Ok(None), |content| {
728 Ok(Some(LocalHookSource {
729 path: backup.to_path_buf(),
730 content,
731 }))
732 });
733 }
734 Ok(Some(LocalHookSource {
735 path: active.to_path_buf(),
736 content: active_content,
737 }))
738}
739
740fn custom_forwarder_present(hooks_path: &Path, hook: HookName) -> bool {
741 read_to_string_if_file(&hooks_path.join(hook.as_str()))
742 .ok()
743 .flatten()
744 .is_some_and(|content| {
745 active_hook_lines(&content).any(|line| line.contains(FORWARDER_NAME))
746 || content_forwards_to_local_git_hook(&content, hook)
747 || is_truth_mirror_managed(&content)
748 })
749}
750
751fn ensure_state_gitignore(repo_root: &Path, state_dir: &Path) -> Result<()> {
752 let state_dir = absolutize(repo_root, state_dir);
753 fs::create_dir_all(&state_dir)?;
754 let gitignore = state_dir.join(".gitignore");
755 let mut content = read_to_string_if_file(&gitignore)?.unwrap_or_default();
756 if content.is_empty() {
757 content.push_str(STATE_GITIGNORE_HEADER);
758 content.push('\n');
759 }
760 for line in STATE_GITIGNORE_LINES {
761 if !content.lines().any(|existing| existing.trim() == *line) {
762 if !content.ends_with('\n') {
763 content.push('\n');
764 }
765 content.push_str(line);
766 content.push('\n');
767 }
768 }
769 fs::write(&gitignore, content)?;
770 print_tracked_runtime_hints(repo_root, &state_dir)?;
771 Ok(())
772}
773
774fn print_tracked_runtime_hints(repo_root: &Path, state_dir: &Path) -> Result<()> {
775 let rel_state = state_dir.strip_prefix(repo_root).unwrap_or(state_dir);
776 for path in [
777 rel_state.join("review-queue.jsonl"),
778 rel_state.join("runs"),
779 rel_state.join("tmp"),
780 rel_state.join("logs"),
781 ] {
782 let output = Command::new("git")
783 .args(["ls-files", "--"])
784 .arg(&path)
785 .current_dir(repo_root)
786 .output()?;
787 if output.status.success() {
788 for tracked in String::from_utf8_lossy(&output.stdout)
789 .lines()
790 .filter(|line| !line.trim().is_empty())
791 {
792 eprintln!("hint: run: git rm --cached {tracked}");
793 }
794 }
795 }
796 Ok(())
797}
798
799fn clear_chained_hooks(plan: &HookInstallPlan) -> Result<()> {
800 for hook in INSTALLED_HOOKS {
801 remove_file_if_exists(&plan.hooks_path.join("chained").join(hook.as_str()))?;
802 }
803 Ok(())
804}
805
806fn has_outer_hook_marker(content: &str) -> bool {
807 content.lines().take(20).any(|line| {
808 OUTER_HOOK_MARKERS
809 .iter()
810 .any(|marker| line.contains(marker))
811 || (!line.trim_start().starts_with('#') && line_invokes_truth_hook_dispatch(line, None))
812 })
813}
814
815fn is_truth_mirror_managed(content: &str) -> bool {
816 content.contains(MANAGED_MARKER)
817 || content.contains(HUSKY_ENTIRE_BRIDGE_MARKER)
818 || active_hook_lines(content).any(|line| line_invokes_truth_hook_dispatch(line, None))
819}
820
821fn active_hook_lines(content: &str) -> impl Iterator<Item = &str> {
822 content
823 .lines()
824 .map(str::trim_start)
825 .filter(|line| !line.trim().is_empty() && !line.starts_with('#'))
826}
827
828fn content_forwards_to_local_git_hook(content: &str, hook: HookName) -> bool {
829 let resolves_git_dir =
830 content.contains("--git-common-dir") || content.contains("git rev-parse --git-common-dir");
831 resolves_git_dir
832 && active_hook_lines(content)
833 .any(|line| line_forwards_to_local_git_hook_with_args(line, hook))
834}
835
836fn line_forwards_to_local_git_hook_with_args(line: &str, hook: HookName) -> bool {
837 line.contains("$@")
838 && (line.contains(&format!("hooks/{}", hook.as_str()))
839 || line.contains("hooks/$name")
840 || line.contains("hooks/${name}")
841 || line.contains("hooks/$hook")
842 || line.contains("hooks/${hook}"))
843}
844
845fn line_invokes_truth_hook_dispatch(line: &str, expected_hook: Option<HookName>) -> bool {
846 crate::shell::shellish_token_segments(line)
847 .iter()
848 .any(|tokens| token_segment_invokes_truth_hook_dispatch(tokens, expected_hook))
849}
850
851fn token_segment_invokes_truth_hook_dispatch(
852 tokens: &[&str],
853 expected_hook: Option<HookName>,
854) -> bool {
855 for (index, token) in tokens.iter().enumerate() {
856 if !is_truth_binary(token) {
857 continue;
858 }
859 let Some(dispatch_offset) = tokens[index + 1..]
860 .iter()
861 .position(|candidate| *candidate == "hook-dispatch")
862 else {
863 continue;
864 };
865 let hook_index = index + 1 + dispatch_offset + 1;
866 let Some(hook) = tokens.get(hook_index) else {
867 continue;
868 };
869 if expected_hook.is_some_and(|expected| hook == &expected.as_str())
870 || expected_hook.is_none() && token_is_installed_hook(hook)
871 {
872 return true;
873 }
874 }
875 false
876}
877
878fn is_truth_binary(token: &str) -> bool {
879 token == "truth"
880 || token == "truth-mirror"
881 || token.ends_with("/truth")
882 || token.ends_with("/truth-mirror")
883}
884
885fn token_is_installed_hook(token: &str) -> bool {
886 INSTALLED_HOOKS.iter().any(|hook| token == hook.as_str())
887}
888
889fn is_husky_entire_bridge(content: &str) -> bool {
890 content.contains(HUSKY_ENTIRE_BRIDGE_MARKER)
891}
892
893fn has_husky_entire_bridge_exec(content: &str, hook: HookName) -> bool {
894 let expected = husky_entire_bridge_exec_line(hook);
895 content.lines().any(|line| line.trim() == expected)
896}
897
898fn backup_path(path: &Path, suffix: &str) -> PathBuf {
899 let file_name = path
900 .file_name()
901 .and_then(|name| name.to_str())
902 .unwrap_or("hook");
903 path.with_file_name(format!("{file_name}.{suffix}"))
904}
905
906fn read_to_string_if_file(path: &Path) -> Result<Option<String>> {
907 match fs::read_to_string(path) {
908 Ok(content) => Ok(Some(content)),
909 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
910 Err(error) => Err(error.into()),
911 }
912}
913
914fn remove_file_if_exists(path: &Path) -> Result<()> {
915 match fs::remove_file(path) {
916 Ok(()) => Ok(()),
917 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
918 Err(error) => Err(error.into()),
919 }
920}
921
922fn unset_core_hooks_path(repo_root: &Path) -> Result<()> {
923 let status = Command::new("git")
924 .args(["config", "--unset", "core.hooksPath"])
925 .current_dir(repo_root)
926 .status()?;
927 if !status.success() {
928 return Ok(());
930 }
931 Ok(())
932}
933
934fn dispatch_commit_msg(
935 state_dir: &Path,
936 args: &[String],
937 config: &crate::config::TruthMirrorConfig,
938) -> Result<()> {
939 let commit_msg_path = args
940 .first()
941 .context("commit-msg hook requires COMMIT_EDITMSG path")?;
942 let commit_message = fs::read_to_string(commit_msg_path)?;
943 let diff = git_stdout(&["diff", "--cached"])?;
944 let claim_file = fs::read_to_string(state_dir.join("claim.txt")).ok();
945 let policy = config.gates.to_policy();
946 claim::evaluate_commit_message(&commit_message, claim_file.as_deref(), Some(&diff), &policy)?;
947 Ok(())
948}
949
950fn dispatch_post_commit(state_dir: &Path) -> Result<()> {
951 let sha = git_stdout(&["rev-parse", "HEAD"])?;
952 ReviewQueue::new(state_dir).enqueue(sha.trim())?;
953 Ok(())
954}
955
956fn dispatch_pre_push(
957 state_dir: &Path,
958 config_path: Option<&Path>,
959 config: &crate::config::TruthMirrorConfig,
960 args: &[String],
961) -> Result<()> {
962 let remote_name = pre_push_remote_name_from_args(args);
963 let mut stdin = String::new();
964 io::stdin().read_to_string(&mut stdin)?;
965 for line in stdin.lines() {
966 if let Some(range) = pre_push_range_from_line_for_remote(line, remote_name) {
967 gate::run(
968 cli::GateArgs {
969 pre_push: Some(range),
970 commit_msg: None,
971 claim_file: None,
972 diff_file: None,
973 fake_markers: Vec::new(),
974 pre_tool_use: false,
975 tool: None,
976 },
977 state_dir,
978 config_path,
979 config,
980 )?;
981 }
982 }
983 Ok(())
984}
985
986fn pre_push_range_from_line_for_remote(line: &str, remote_name: Option<&str>) -> Option<String> {
987 let parts = line.split_whitespace().collect::<Vec<_>>();
988 let local_sha = parts.get(1)?;
989 let remote_sha = parts.get(3)?;
990 if is_zero_sha(local_sha) {
991 return None;
992 }
993
994 if is_zero_sha(remote_sha) {
995 match remote_name.filter(|name| safe_pre_push_remote_name(name)) {
996 Some(remote_name) => Some(format!("new:{remote_name}:{local_sha}")),
997 None => Some(format!("new:{local_sha}")),
998 }
999 } else {
1000 Some(format!("{remote_sha}..{local_sha}"))
1001 }
1002}
1003
1004fn pre_push_remote_name_from_args(args: &[String]) -> Option<&str> {
1005 let remote_name = args.first()?.as_str();
1006 let remote_url = args.get(1).map(String::as_str);
1007 if remote_url == Some(remote_name) || !safe_pre_push_remote_name(remote_name) {
1008 return None;
1009 }
1010 Some(remote_name)
1011}
1012
1013fn safe_pre_push_remote_name(value: &str) -> bool {
1014 !value.is_empty()
1015 && !value.starts_with('-')
1016 && !value.contains(':')
1017 && !value.contains("..")
1018 && !value
1019 .chars()
1020 .any(|character| character.is_whitespace() || character.is_control())
1021 && !value
1022 .chars()
1023 .any(|character| matches!(character, '*' | '?' | '['))
1024}
1025
1026fn is_zero_sha(value: &str) -> bool {
1027 value.chars().all(|character| character == '0')
1028}
1029
1030fn run_chained_hook(state_dir: &Path, hook: HookName, args: &[String]) -> Result<()> {
1031 let chained = state_dir.join("hooks/chained").join(hook.as_str());
1032 if !chained.is_file() {
1033 return Ok(());
1034 }
1035 let content = fs::read_to_string(&chained)?;
1036 if has_outer_hook_marker(&content) {
1037 eprintln!(
1038 "{} skipping unsafe chained hook {} because it appears to invoke an outer hook manager",
1039 crate::messages::diagnostic_prefix(),
1040 chained.display()
1041 );
1042 return Ok(());
1043 }
1044
1045 let status = Command::new(&chained).args(args).status()?;
1046 if !status.success() {
1047 bail!(
1048 "chained hook {} failed with status {status}",
1049 chained.display()
1050 );
1051 }
1052 Ok(())
1053}
1054
1055fn git_root() -> Result<PathBuf> {
1056 Ok(PathBuf::from(
1057 git_stdout(&["rev-parse", "--show-toplevel"])?.trim(),
1058 ))
1059}
1060
1061fn git_stdout(args: &[&str]) -> Result<String> {
1062 let output = Command::new("git").args(args).output()?;
1063 if !output.status.success() {
1064 bail!(
1065 "git {} failed: {}",
1066 args.join(" "),
1067 String::from_utf8_lossy(&output.stderr)
1068 );
1069 }
1070 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1071}
1072
1073fn git_config_get(repo_root: &Path, key: &str) -> Result<Option<String>> {
1074 let output = Command::new("git")
1075 .args(["config", "--get", key])
1076 .current_dir(repo_root)
1077 .output()?;
1078 if output.status.success() {
1079 return Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()));
1080 }
1081 if output.status.code() == Some(1) {
1082 return Ok(None);
1083 }
1084 bail!(
1085 "git config --get {key} failed: {}",
1086 String::from_utf8_lossy(&output.stderr)
1087 );
1088}
1089
1090#[cfg(unix)]
1091fn make_executable(path: &Path) -> Result<()> {
1092 use std::os::unix::fs::PermissionsExt;
1093
1094 let mut permissions = fs::metadata(path)?.permissions();
1095 permissions.set_mode(0o755);
1096 fs::set_permissions(path, permissions)?;
1097 Ok(())
1098}
1099
1100#[cfg(not(unix))]
1101fn make_executable(_path: &Path) -> Result<()> {
1102 Ok(())
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107 use proptest::prelude::*;
1108
1109 use super::{
1110 HookInstallMode, HookInstallPlan, pre_push_range_from_line_for_remote, render_shim,
1111 };
1112 use crate::cli::HookName;
1113
1114 #[test]
1115 fn hook_shim_is_only_exec_delegation() {
1116 let shim = render_shim(HookName::CommitMsg, "");
1117 let lines = shim.lines().collect::<Vec<_>>();
1118
1119 assert_eq!(lines.len(), 3);
1120 assert_eq!(lines[0], "#!/bin/sh");
1121 assert_eq!(lines[1], super::MANAGED_MARKER);
1122 assert_eq!(
1123 lines[2],
1124 "exec truth-mirror hook-dispatch commit-msg \"$@\""
1125 );
1126 }
1127
1128 #[test]
1129 fn quote_git_arg_escapes_shell_metacharacters() {
1130 use std::path::Path;
1131 assert_eq!(super::quote_git_arg(Path::new("/a/b.toml")), "'/a/b.toml'");
1132 assert_eq!(
1134 super::quote_git_arg(Path::new("/a/c;$(touch x).toml")),
1135 "'/a/c;$(touch x).toml'"
1136 );
1137 assert_eq!(
1139 super::quote_git_arg(Path::new("/a/it's.toml")),
1140 "'/a/it'\\''s.toml'"
1141 );
1142 }
1143
1144 #[test]
1145 fn normalize_path_preserves_unmatched_relative_parents() {
1146 use std::path::{Path, PathBuf};
1147
1148 assert_eq!(
1149 super::normalize_path(Path::new("../hooks")),
1150 PathBuf::from("../hooks")
1151 );
1152 assert_eq!(
1153 super::normalize_path(Path::new("a/../../hooks")),
1154 PathBuf::from("../hooks")
1155 );
1156 }
1157
1158 #[test]
1159 fn hook_shim_preserves_global_args() {
1160 let shim = render_shim(HookName::PrePush, "--config /abs/enforce.toml ");
1161 let lines = shim.lines().collect::<Vec<_>>();
1162
1163 assert_eq!(lines.len(), 3, "shim stays <=3 lines + exec-only");
1164 assert_eq!(
1165 lines[2],
1166 "exec truth-mirror --config /abs/enforce.toml hook-dispatch pre-push \"$@\""
1167 );
1168 }
1169
1170 #[test]
1171 fn husky_entire_bridge_is_only_exec_delegation() {
1172 let bridge = super::render_husky_entire_bridge(HookName::CommitMsg);
1173 let lines = bridge.lines().collect::<Vec<_>>();
1174
1175 assert_eq!(lines.len(), 3);
1176 assert_eq!(lines[0], "#!/bin/sh");
1177 assert_eq!(lines[1], super::HUSKY_ENTIRE_BRIDGE_MARKER);
1178 assert_eq!(lines[2], "exec \"$(dirname \"$0\")/commit-msg\" \"$@\"");
1179 }
1180
1181 #[test]
1182 fn dry_run_plan_names_hooks_and_hooks_path() {
1183 let plan = HookInstallPlan::new(
1184 std::path::Path::new("/repo"),
1185 std::path::Path::new("/repo/.truth/hooks"),
1186 false,
1187 );
1188 let rendered = plan.render();
1189
1190 assert!(rendered.contains("commit-msg"));
1191 assert!(rendered.contains("post-commit"));
1192 assert!(rendered.contains("pre-push"));
1193 assert!(rendered.contains("stateHooks=/repo/.truth/hooks"));
1194 assert!(rendered.contains("path=/repo/.git/hooks/commit-msg"));
1195 assert!(rendered.contains("body:\n#!/bin/sh\n# truth-mirror managed hook"));
1196 }
1197
1198 #[test]
1199 fn husky_dry_run_plan_names_entire_bridge_paths() {
1200 let mut plan = HookInstallPlan::new(
1201 std::path::Path::new("/repo"),
1202 std::path::Path::new("/repo/.truth/hooks"),
1203 false,
1204 );
1205 plan.mode = HookInstallMode::Husky {
1206 content_dir: std::path::PathBuf::from("/repo/.husky"),
1207 };
1208 let rendered = plan.render();
1209
1210 assert!(rendered.contains("bridge=commit-msg.pre-entire"));
1211 assert!(rendered.contains("path=/repo/.husky/commit-msg.pre-entire"));
1212 assert!(rendered.contains(super::HUSKY_ENTIRE_BRIDGE_MARKER));
1213 }
1214
1215 #[test]
1216 fn managed_block_replace_is_idempotent() {
1217 let block = super::render_husky_block(HookName::CommitMsg, "");
1218 let original = "#!/bin/sh\necho before\n";
1219 let once = super::append_managed_block(original, &block);
1220 let twice = super::append_managed_block(&once, &block);
1221
1222 assert_eq!(twice.matches(super::MANAGED_MARKER).count(), 1);
1223 assert!(twice.contains("echo before"));
1224 }
1225
1226 #[test]
1227 fn chained_hook_safety_filter_detects_outer_tools() {
1228 assert!(super::has_outer_hook_marker(
1229 "#!/bin/sh\n# Entire CLI hooks\nentire hooks git commit-msg\n"
1230 ));
1231 assert!(super::has_outer_hook_marker(
1232 "#!/bin/sh\n# truth-mirror managed hook\nexec truth-mirror hook-dispatch commit-msg \"$@\"\n"
1233 ));
1234 assert!(super::has_outer_hook_marker(
1235 "#!/bin/sh\nexec truth hook-dispatch commit-msg \"$@\"\n"
1236 ));
1237 assert!(super::has_outer_hook_marker(
1238 "#!/bin/sh\nexec /usr/local/bin/truth hook-dispatch commit-msg \"$@\"\n"
1239 ));
1240 assert!(!super::has_outer_hook_marker(
1241 "#!/bin/sh\n# exec truth hook-dispatch commit-msg \"$@\"\n"
1242 ));
1243 assert!(!super::has_outer_hook_marker("#!/bin/sh\necho safe\n"));
1244 }
1245
1246 #[test]
1247 fn managed_detection_ignores_commented_and_unrelated_truth_lines() {
1248 assert!(!super::is_truth_mirror_managed(
1249 "#!/bin/sh\n# exec truth hook-dispatch commit-msg \"$@\"\n"
1250 ));
1251 assert!(!super::is_truth_mirror_managed(
1252 "#!/bin/sh\nexec truth unrelated \"$@\"\n"
1253 ));
1254 assert!(!super::is_truth_mirror_managed(
1255 "#!/bin/sh\nexec truth --version && hook-dispatch commit-msg \"$@\"\n"
1256 ));
1257 assert!(!super::is_truth_mirror_managed(
1258 "#!/bin/sh\nhook-dispatch commit-msg \"$@\"; exec truth --version\n"
1259 ));
1260 assert!(super::is_truth_mirror_managed(
1261 "#!/bin/sh\nexec /opt/bin/truth hook-dispatch pre-push \"$@\"\n"
1262 ));
1263 }
1264
1265 #[test]
1266 fn run_chained_hook_skips_marker_bearing_stale_hook() {
1267 let temp = tempfile::tempdir().unwrap();
1268 let chained = temp.path().join("hooks/chained/commit-msg");
1269 std::fs::create_dir_all(chained.parent().unwrap()).unwrap();
1270 std::fs::write(&chained, "#!/bin/sh\n# Entire CLI hooks\nexit 42\n").unwrap();
1271 super::make_executable(&chained).unwrap();
1272
1273 super::run_chained_hook(temp.path(), HookName::CommitMsg, &[]).unwrap();
1274 }
1275
1276 #[test]
1277 fn pre_push_line_maps_to_git_range() {
1278 let line = "refs/heads/main abc123 refs/heads/main def456";
1279
1280 assert_eq!(
1281 pre_push_range_from_line_for_remote(line, None),
1282 Some("def456..abc123".to_owned())
1283 );
1284 }
1285
1286 #[test]
1287 fn pre_push_new_branch_maps_to_new_branch_token() {
1288 let line =
1289 "refs/heads/topic abc123 refs/heads/topic 0000000000000000000000000000000000000000";
1290
1291 assert_eq!(
1292 pre_push_range_from_line_for_remote(line, None),
1293 Some("new:abc123".to_owned())
1294 );
1295 }
1296
1297 #[test]
1298 fn pre_push_new_branch_includes_safe_remote_name_when_available() {
1299 let line =
1300 "refs/heads/topic abc123 refs/heads/topic 0000000000000000000000000000000000000000";
1301
1302 assert_eq!(
1303 pre_push_range_from_line_for_remote(line, Some("origin")),
1304 Some("new:origin:abc123".to_owned())
1305 );
1306 assert_eq!(
1307 pre_push_range_from_line_for_remote(line, Some("../bad")),
1308 Some("new:abc123".to_owned())
1309 );
1310 }
1311
1312 #[test]
1313 fn pre_push_args_omit_remote_name_for_direct_path_pushes() {
1314 assert_eq!(
1315 super::pre_push_remote_name_from_args(&[
1316 "origin".to_owned(),
1317 "git@example/repo".to_owned()
1318 ]),
1319 Some("origin")
1320 );
1321 assert_eq!(
1322 super::pre_push_remote_name_from_args(&[
1323 "../repo.git".to_owned(),
1324 "../repo.git".to_owned()
1325 ]),
1326 None
1327 );
1328 }
1329
1330 #[test]
1331 fn pre_push_args_omit_glob_like_remote_names() {
1332 for remote_name in ["bad*remote", "bad?remote", "bad[remote"] {
1333 assert_eq!(
1334 super::pre_push_remote_name_from_args(&[
1335 remote_name.to_owned(),
1336 "git@example/repo".to_owned()
1337 ]),
1338 None
1339 );
1340 }
1341 }
1342
1343 proptest! {
1344 #[test]
1345 fn hook_shim_rendering_stays_tiny_exec_only(index in 0usize..3) {
1346 let hook = [HookName::CommitMsg, HookName::PostCommit, HookName::PrePush][index];
1347 let shim = render_shim(hook, "");
1348 let lines = shim.lines().collect::<Vec<_>>();
1349
1350 prop_assert_eq!(lines.len(), 3);
1351 prop_assert_eq!(lines[0], "#!/bin/sh");
1352 prop_assert_eq!(lines[1], super::MANAGED_MARKER);
1353 prop_assert!(lines[2].starts_with("exec truth-mirror hook-dispatch "));
1354 prop_assert!(lines[2].contains(hook.as_str()));
1355 prop_assert_eq!(shim.matches(super::MANAGED_MARKER).count(), 1);
1356 prop_assert_eq!(shim.matches("exec truth-mirror").count(), 1);
1357 }
1358
1359 #[test]
1360 fn husky_entire_bridge_rendering_stays_tiny_exec_only(index in 0usize..3) {
1361 let hook = [HookName::CommitMsg, HookName::PostCommit, HookName::PrePush][index];
1362 let bridge = super::render_husky_entire_bridge(hook);
1363 let lines = bridge.lines().collect::<Vec<_>>();
1364
1365 prop_assert_eq!(lines.len(), 3);
1366 prop_assert_eq!(lines[0], "#!/bin/sh");
1367 prop_assert_eq!(lines[1], super::HUSKY_ENTIRE_BRIDGE_MARKER);
1368 prop_assert_eq!(lines[2], format!("exec \"$(dirname \"$0\")/{}\" \"$@\"", hook.as_str()));
1369 prop_assert_eq!(bridge.matches(super::HUSKY_ENTIRE_BRIDGE_MARKER).count(), 1);
1370 prop_assert_eq!(bridge.matches("exec ").count(), 1);
1371 }
1372 }
1373}