1use anyhow::{Context, Result};
14use std::fs;
15use std::path::{Path, PathBuf};
16
17use crate::config::Registry;
18use crate::output;
19
20const HOOKS: [&str; 3] = ["post-commit", "post-checkout", "post-merge"];
32
33const GIT_HOOK_NAMES: [&str; 28] = [
41 "applypatch-msg",
42 "pre-applypatch",
43 "post-applypatch",
44 "pre-commit",
45 "pre-merge-commit",
46 "prepare-commit-msg",
47 "commit-msg",
48 "post-commit",
49 "pre-rebase",
50 "post-checkout",
51 "post-merge",
52 "pre-push",
53 "pre-receive",
54 "update",
55 "proc-receive",
56 "post-receive",
57 "post-update",
58 "reference-transaction",
59 "push-to-checkout",
60 "pre-auto-gc",
61 "post-rewrite",
62 "sendemail-validate",
63 "fsmonitor-watchman",
64 "p4-changelist",
65 "p4-prepare-changelist",
66 "p4-post-changelist",
67 "p4-pre-submit",
68 "post-index-change",
69];
70
71const NO_SHADOW_SHIM: [&str; 2] = ["reference-transaction", "post-index-change"];
81
82fn shadowing_hooks() -> Vec<&'static str> {
84 GIT_HOOK_NAMES
85 .iter()
86 .copied()
87 .filter(|name| !NO_SHADOW_SHIM.contains(name))
88 .collect()
89}
90
91const CHAIN_MARKER: &str = ".chain-target";
97
98pub fn hooks_dir() -> Result<PathBuf> {
100 let base = Registry::config_dir()?;
101 Ok(base.join("hooks"))
102}
103
104pub fn chain_target() -> Option<PathBuf> {
106 let marker = hooks_dir().ok()?.join(CHAIN_MARKER);
107 let raw = fs::read_to_string(marker).ok()?;
108 let trimmed = raw.trim();
109 (!trimmed.is_empty()).then(|| PathBuf::from(trimmed))
110}
111
112pub const GIT_MISSING_HELP: &str = "`git` was not found on your PATH.\n\
114 dev-prune identifies repositories with Git and installs its hooks through \
115 `git config --global`, so it can do neither without it.\n\
116 Install Git from https://git-scm.com/downloads (or your package manager), confirm \
117 that `git --version` works in a new terminal, then run `devp setup` again.";
118
119pub fn git_available() -> bool {
124 crate::spawn::command("git")
125 .arg("--version")
126 .output()
127 .map(|out| out.status.success())
128 .unwrap_or(false)
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum HookState {
134 Active,
136 Absent,
138 Foreign(String),
141 Chained {
143 previous: String,
145 drifted: Vec<String>,
148 },
149}
150
151pub fn state() -> Result<HookState> {
153 let dir = hooks_dir()?;
154 match global_hooks_path() {
155 Some(existing) if Path::new(&existing) != dir => Ok(HookState::Foreign(existing)),
156 Some(_) if HOOKS.iter().all(|hook| dir.join(hook).exists()) => match chain_target() {
157 Some(previous) => Ok(HookState::Chained {
158 drifted: chain_drift(&dir, &previous),
159 previous: output::clean_path(&previous),
160 }),
161 None => Ok(HookState::Active),
162 },
163 _ => Ok(HookState::Absent),
166 }
167}
168
169fn chain_drift(ours: &Path, theirs: &Path) -> Vec<String> {
175 hook_names_in(theirs)
176 .into_iter()
177 .filter(|name| !ours.join(name).exists())
178 .collect()
179}
180
181fn hook_names_in(dir: &Path) -> Vec<String> {
183 let Ok(entries) = fs::read_dir(dir) else {
184 return Vec::new();
185 };
186 let present: Vec<String> = entries
187 .flatten()
188 .filter(|e| e.path().is_file())
189 .filter_map(|e| e.file_name().into_string().ok())
190 .collect();
191 GIT_HOOK_NAMES
192 .iter()
193 .filter(|name| present.iter().any(|p| p == *name))
194 .map(|name| name.to_string())
195 .collect()
196}
197
198pub fn shims_incomplete() -> bool {
208 let Ok(dir) = hooks_dir() else {
209 return false;
210 };
211 if chain_target().is_some() {
212 return false;
213 }
214 shims_missing_in(&dir)
215}
216
217fn shims_missing_in(dir: &Path) -> bool {
220 shadowing_hooks()
221 .into_iter()
222 .any(|name| !dir.join(name).exists())
223}
224
225fn global_hooks_path() -> Option<String> {
227 let out = crate::spawn::command("git")
228 .args(["config", "--global", "core.hooksPath"])
229 .output()
230 .ok()?;
231 if !out.status.success() {
232 return None;
233 }
234 let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
235 (!value.is_empty()).then_some(value)
236}
237
238fn build_hook_script(exe: &str, hook: &str, register: bool) -> String {
246 let registration = if register {
247 format!("('{}' link . --quiet >/dev/null 2>&1 &)\n", sq(exe))
248 } else {
249 String::new()
250 };
251 format!(
252 r#"#!/usr/bin/env sh
253# dev-prune hook shim. Rebuild with `devp hook install`.
254{registration}{}"#,
255 local_passthrough(hook)
256 )
257}
258
259fn local_passthrough(hook: &str) -> String {
278 format!(
279 r#"common=$(git rev-parse --git-common-dir 2>/dev/null) || common="${{GIT_DIR:-.git}}"
280next="$common/hooks/{0}"
281if [ -x "$next" ]; then exec "$next" "$@"; fi
282if [ -f "$next" ]; then exec sh "$next" "$@"; fi
283exit 0
284"#,
285 hook
286 )
287}
288
289fn build_chained_hook_script(exe: &str, previous: &Path, hook: &str, register: bool) -> String {
298 let registration = if register {
299 format!("('{}' link . --quiet >/dev/null 2>&1 &)\n", sq(exe))
300 } else {
301 String::new()
302 };
303 let target = previous.join(hook);
304 format!(
305 r#"#!/usr/bin/env sh
306# dev-prune hook shim — chained. Rebuild with `devp hook install --chain`.
307{registration}next='{}'
308if [ -x "$next" ]; then exec "$next" "$@"; fi
309if [ -f "$next" ]; then exec sh "$next" "$@"; fi
310exit 0
311"#,
312 sq(&target.to_string_lossy())
313 )
314}
315
316fn sq(value: &str) -> String {
319 value.replace('\'', r"'\''")
320}
321
322fn parse_hook_exe(script: &str) -> Option<PathBuf> {
329 let start = script.find("('")? + 2;
330 let end = start + script[start..].find("' link . --quiet")?;
331 let exe = script[start..end].replace(r"'\''", "'");
332 (!exe.is_empty()).then(|| PathBuf::from(exe))
333}
334
335pub fn registered_exe_path() -> Option<PathBuf> {
342 let script = fs::read_to_string(hooks_dir().ok()?.join(HOOKS[0])).ok()?;
343 parse_hook_exe(&script)
344}
345
346pub fn run_install(chain: bool) -> Result<()> {
348 let dir = hooks_dir()?;
349 install_with(chain)?;
350
351 output::print_header("dev-prune Non-Blocking Git Hooks");
352 output::print_success(&format!(
353 "Installed global Git hooks in `{}`",
354 output::clean_path(&dir)
355 ));
356 println!(" Hooks Active: {}", HOOKS.join(", "));
357 println!(" Execution Mode: Asynchronous / Non-blocking (0ms commit impact)");
358
359 match chain_target() {
360 Some(previous) => {
361 let forwarded = hook_names_in(&previous);
362 println!(" Chained To: {}", output::clean_path(&previous));
363 println!(
364 " Forwarded Hooks: {}",
365 if forwarded.is_empty() {
366 "none found (the directory is empty)".to_string()
367 } else {
368 forwarded.join(", ")
369 }
370 );
371 println!();
372 output::print_info("How to manage hook settings:");
373 println!(" Restore Previous: devp hook uninstall");
374 println!(" Rebuild The Chain: devp hook install --chain");
375 println!();
376 output::print_warning(
377 "The chain is a snapshot. If that tool adds a hook later, re-run \
378 `devp hook install --chain` — `devp hook status` reports the drift.",
379 );
380 }
381 None => {
382 println!();
383 output::print_info("How to manage hook settings:");
384 println!(" Disable Globally: devp hook uninstall");
385 println!(" Disable Per-Repo: git config core.hooksPath \"\" (inside project root)");
386 println!(" Re-enable Globally: devp hook install");
387 println!();
388 output::print_warning(
389 "While this is active, per-repo `.git/hooks` are ignored in every repository on \
390 this machine — that includes husky, pre-commit and lefthook.",
391 );
392 }
393 }
394
395 Ok(())
396}
397
398pub fn install() -> Result<()> {
402 install_with(false)
403}
404
405pub fn install_with(chain: bool) -> Result<()> {
413 let dir = hooks_dir()?;
414
415 if !git_available() {
418 anyhow::bail!("{GIT_MISSING_HELP}");
419 }
420
421 let previous = match global_hooks_path() {
425 Some(existing) if Path::new(&existing) != dir => {
426 if !chain {
427 anyhow::bail!(
428 "`core.hooksPath` is already set globally to `{existing}`.\n\
429 Git only supports one hooks directory, so installing here would disable \
430 those hooks in every repo on this machine.\n\
431 Run `devp hook install --chain` to install in front of it instead: \
432 dev-prune registers the repo, then hands every hook on to `{existing}`.\n\
433 Or unset it first:\n git config --global --unset core.hooksPath\n\
434 Or do nothing — `devp link .` in new repos does the same job by hand."
435 );
436 }
437 let path = PathBuf::from(&existing);
438 if path.is_relative() {
442 anyhow::bail!(
443 "`core.hooksPath` is set to the relative path `{existing}`, which Git \
444 resolves separately inside every repository. There is no one directory \
445 to chain to.\n\
446 Set it to an absolute path first, or leave it alone and use `devp link .`."
447 );
448 }
449 Some(path)
450 }
451 _ => chain.then(chain_target).flatten(),
454 };
455
456 fs::create_dir_all(&dir)
457 .with_context(|| format!("Failed to create hooks directory at {}", dir.display()))?;
458
459 let exe = crate::setup::stable_exe_path()
467 .to_string_lossy()
468 .into_owned();
469
470 match &previous {
471 None => {
472 let names = shadowing_hooks();
473 for hook in &names {
474 let content = build_hook_script(&exe, hook, HOOKS.contains(hook));
475 write_hook(&dir.join(hook), &content)?;
476 }
477 for stale in hook_names_in(&dir) {
480 if !names.contains(&stale.as_str()) {
481 let _ = fs::remove_file(dir.join(&stale));
482 }
483 }
484 let _ = fs::remove_file(dir.join(CHAIN_MARKER));
487 }
488 Some(prev) => {
489 let mut names: Vec<String> = HOOKS.iter().map(|h| h.to_string()).collect();
493 for name in hook_names_in(prev) {
494 if !names.contains(&name) {
495 names.push(name);
496 }
497 }
498 for stale in hook_names_in(&dir) {
501 if !names.contains(&stale) {
502 let _ = fs::remove_file(dir.join(&stale));
503 }
504 }
505 for name in &names {
506 let register = HOOKS.contains(&name.as_str());
507 let content = build_chained_hook_script(&exe, prev, name, register);
508 write_hook(&dir.join(name), &content)?;
509 }
510 fs::write(dir.join(CHAIN_MARKER), format!("{}\n", prev.display())).with_context(
511 || "Failed to record the chained hooks path; refusing a chain we cannot undo",
512 )?;
513 }
514 }
515
516 let status = crate::spawn::command("git")
518 .args([
519 "config",
520 "--global",
521 "core.hooksPath",
522 &dir.to_string_lossy(),
523 ])
524 .status()
525 .with_context(|| "Failed to execute `git config --global core.hooksPath`")?;
526
527 if !status.success() {
528 anyhow::bail!("Failed to update git global configuration.");
529 }
530
531 Ok(())
532}
533
534fn write_hook(path: &Path, content: &str) -> Result<()> {
536 fs::write(path, content).with_context(|| format!("Failed to write hook {}", path.display()))?;
537 #[cfg(unix)]
538 {
539 use std::os::unix::fs::PermissionsExt;
540 let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o755));
541 }
542 Ok(())
543}
544
545fn remove_hook_files(dir: &Path) {
552 let _ = fs::remove_file(dir.join(CHAIN_MARKER));
553 for name in hook_names_in(dir) {
554 let _ = fs::remove_file(dir.join(name));
555 }
556}
557
558pub fn run_uninstall() -> Result<()> {
560 let dir = hooks_dir()?;
563
564 if let Some(previous) = chain_target()
568 && global_hooks_path().is_some_and(|c| Path::new(&c) == dir)
569 {
570 let restored = crate::spawn::command("git")
571 .args([
572 "config",
573 "--global",
574 "core.hooksPath",
575 &previous.to_string_lossy(),
576 ])
577 .status();
578 match restored {
579 Ok(status) if status.success() => {
580 remove_hook_files(&dir);
581 output::print_success(&format!(
582 "Restored `core.hooksPath` to `{}`.",
583 output::clean_path(&previous)
584 ));
585 return Ok(());
586 }
587 Ok(status) => anyhow::bail!(
588 "`git config --global core.hooksPath` exited with {status} while restoring \
589 `{}`. Set it by hand to bring those hooks back.",
590 output::clean_path(&previous)
591 ),
592 Err(e) => anyhow::bail!("Could not run `git config --global core.hooksPath`: {e}"),
593 }
594 }
595
596 match global_hooks_path() {
597 Some(current) if Path::new(¤t) != dir => {
598 output::print_info(&format!(
599 "`core.hooksPath` is set to `{current}`, which is not dev-prune's — leaving it alone."
600 ));
601 if !hook_names_in(&dir).is_empty() || dir.join(CHAIN_MARKER).exists() {
604 remove_hook_files(&dir);
605 output::print_info("Removed dev-prune's leftover hook scripts.");
606 }
607 return Ok(());
608 }
609 None => {
610 if !hook_names_in(&dir).is_empty() || dir.join(CHAIN_MARKER).exists() {
611 remove_hook_files(&dir);
612 output::print_success(
613 "`core.hooksPath` was not set globally; removed dev-prune's leftover \
614 hook scripts.",
615 );
616 } else {
617 output::print_info("`core.hooksPath` is not set globally — nothing to remove.");
618 }
619 return Ok(());
620 }
621 Some(_) => {}
622 }
623
624 let unset = crate::spawn::command("git")
628 .args(["config", "--global", "--unset", "core.hooksPath"])
629 .status();
630 match unset {
631 Ok(status) if status.success() => {
632 remove_hook_files(&dir);
633 output::print_success(
634 "Removed global Git hook configuration (`git config --global --unset core.hooksPath`).",
635 );
636 Ok(())
637 }
638 Ok(status) => anyhow::bail!(
639 "`git config --global --unset core.hooksPath` exited with {status}. \
640 Run it by hand to finish removing the hooks."
641 ),
642 Err(e) => anyhow::bail!("Could not run `git config --global --unset core.hooksPath`: {e}"),
643 }
644}
645
646pub fn run_status() -> Result<()> {
648 let dir = hooks_dir()?;
649
650 if !git_available() {
651 output::print_header("dev-prune Git Hooks Status");
652 output::print_error(GIT_MISSING_HELP);
653 return Ok(());
654 }
655
656 let configured = global_hooks_path();
657 let on_disk = HOOKS.iter().all(|hook| dir.join(hook).exists());
658
659 let points_at_us = configured
663 .as_deref()
664 .is_some_and(|current| Path::new(current) == dir);
665
666 output::print_header("dev-prune Git Hooks Status");
667 println!(
668 " Configured core.hooksPath: {}",
669 configured.as_deref().unwrap_or("Not set")
670 );
671 println!(" DevPrune Hooks Directory: {}", dir.display());
672 println!(
673 " Hooks Installed on Disk: {}",
674 if on_disk {
675 format!("Yes ({})", HOOKS.join(", "))
676 } else {
677 "No".to_string()
678 }
679 );
680 if let Some(previous) = chain_target() {
681 println!(
682 " Chained To: {}",
683 output::clean_path(&previous)
684 );
685 let forwarded = hook_names_in(&previous);
686 println!(
687 " Forwarded Hooks: {}",
688 if forwarded.is_empty() {
689 "none".to_string()
690 } else {
691 forwarded.join(", ")
692 }
693 );
694 let drifted = chain_drift(&dir, &previous);
695 if !drifted.is_empty() {
696 println!();
697 output::print_warning(&format!(
698 "`{}` now has hooks the chain does not forward: {}.\n \
699 They are not running. Rebuild with `devp hook install --chain`.",
700 output::clean_path(&previous),
701 drifted.join(", ")
702 ));
703 }
704 }
705 println!();
706 match (points_at_us, on_disk) {
707 (true, true) => output::print_success("Global background auto-registration is ACTIVE."),
708 (true, false) => output::print_warning(
709 "`core.hooksPath` points here but the hook files are missing. \
710 Re-run `devp hook install`.",
711 ),
712 (false, true) => output::print_warning(
713 "Hook files exist but `core.hooksPath` points elsewhere — they never run. \
714 Re-run `devp hook install`, or delete the directory.",
715 ),
716 (false, false) => output::print_info(
717 "Global background hook is inactive. Run `devp hook install` to enable.",
718 ),
719 }
720
721 Ok(())
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727
728 #[test]
729 fn hook_script_single_quotes_the_executable_path() {
730 let script = build_hook_script("/usr/local/bin/dev-prune", "post-commit", true);
731 assert!(script.contains("('/usr/local/bin/dev-prune' link . --quiet"));
732 }
733
734 #[test]
735 fn hook_script_neutralises_shell_metacharacters_in_the_path() {
736 let script = build_hook_script(r"C:\Users\a$b\`whoami`\dev-prune.exe", "post-commit", true);
738 assert!(script.contains(r"('C:\Users\a$b\`whoami`\dev-prune.exe' link ."));
739 }
740
741 #[test]
742 fn hook_script_escapes_an_embedded_single_quote() {
743 let script = build_hook_script("/home/o'brien/dev-prune", "post-commit", true);
744 assert!(script.contains(r"('/home/o'\''brien/dev-prune' link ."));
745 }
746
747 #[test]
748 fn hook_script_starts_with_a_shebang_and_backgrounds_the_call() {
749 let script = build_hook_script("devp", "post-commit", true);
750 assert!(script.starts_with("#!/usr/bin/env sh\n"));
751 assert!(script.contains(">/dev/null 2>&1 &)"));
753 }
754
755 #[test]
756 fn a_hooks_path_git_reported_with_forward_slashes_is_still_ours() {
757 #[cfg(windows)]
763 assert_eq!(
764 Path::new("C:/Users/dev/AppData/Roaming/dev-prune/hooks"),
765 Path::new(r"C:\Users\dev\AppData\Roaming\dev-prune\hooks")
766 );
767
768 assert_ne!(
770 Path::new("/home/dev/.config/dev-prune/hooks"),
771 Path::new("/home/dev/.config/husky/hooks")
772 );
773 }
774
775 #[test]
776 fn a_chained_hook_execs_the_hook_it_displaced() {
777 let script = build_chained_hook_script(
778 "/usr/local/bin/dev-prune",
779 Path::new("/home/dev/.husky"),
780 "post-commit",
781 true,
782 );
783 assert!(script.contains("('/usr/local/bin/dev-prune' link . --quiet"));
784 assert!(script.contains(r#"exec "$next" "$@""#));
786 assert!(script.contains("post-commit'"));
787 assert!(script.trim_end().ends_with("exit 0"));
789 }
790
791 #[test]
792 fn the_binary_is_recoverable_from_a_plain_hook() {
793 let script = build_hook_script("/usr/local/bin/dev-prune", "post-commit", true);
794 assert_eq!(
795 parse_hook_exe(&script),
796 Some(PathBuf::from("/usr/local/bin/dev-prune"))
797 );
798 }
799
800 #[test]
801 fn the_binary_is_recoverable_from_a_chained_hook() {
802 let script = build_chained_hook_script(
803 "C:\\Users\\a\\AppData\\Roaming\\dev-prune\\bin\\dev-prune.exe",
804 Path::new("/home/dev/.husky"),
805 "post-commit",
806 true,
807 );
808 assert_eq!(
809 parse_hook_exe(&script),
810 Some(PathBuf::from(
811 "C:\\Users\\a\\AppData\\Roaming\\dev-prune\\bin\\dev-prune.exe"
812 ))
813 );
814 }
815
816 #[test]
817 fn a_quote_in_the_path_survives_the_round_trip() {
818 let script = build_hook_script("/home/o'brien/dev-prune", "post-commit", true);
821 assert_eq!(
822 parse_hook_exe(&script),
823 Some(PathBuf::from("/home/o'brien/dev-prune"))
824 );
825 }
826
827 #[test]
828 fn a_script_that_is_not_ours_answers_nothing() {
829 assert!(parse_hook_exe("#!/bin/sh\nnpm test\n").is_none());
830 }
831
832 #[test]
833 fn a_forwarded_hook_we_do_not_own_only_forwards() {
834 let script = build_chained_hook_script(
835 "/usr/local/bin/dev-prune",
836 Path::new("/home/dev/.husky"),
837 "pre-commit",
838 false,
839 );
840 assert!(!script.contains("link ."));
843 assert!(script.contains(r#"exec "$next" "$@""#));
844 }
845
846 #[test]
847 fn chaining_never_shims_a_file_that_is_not_a_git_hook() {
848 let tmp = tempfile::TempDir::new().unwrap();
849 fs::write(tmp.path().join("pre-commit"), "#!/bin/sh\nnpm test\n").unwrap();
851 fs::write(tmp.path().join("commit-msg"), "#!/bin/sh\ncommitlint\n").unwrap();
852 fs::write(tmp.path().join(".gitignore"), "_\n").unwrap();
853 fs::write(tmp.path().join("README.md"), "hooks\n").unwrap();
854 fs::create_dir(tmp.path().join("_")).unwrap();
855
856 let found = hook_names_in(tmp.path());
857 assert_eq!(
858 found,
859 vec!["pre-commit".to_string(), "commit-msg".to_string()]
860 );
861 }
862
863 #[test]
864 fn drift_is_a_hook_the_other_tool_added_after_the_chain_was_built() {
865 let ours = tempfile::TempDir::new().unwrap();
866 let theirs = tempfile::TempDir::new().unwrap();
867 fs::write(theirs.path().join("pre-commit"), "x").unwrap();
868 fs::write(theirs.path().join("pre-push"), "x").unwrap();
869 fs::write(ours.path().join("pre-commit"), "shim").unwrap();
870
871 assert_eq!(
872 chain_drift(ours.path(), theirs.path()),
873 vec!["pre-push".to_string()]
874 );
875 }
876
877 #[test]
878 fn every_installed_hook_runs_after_the_operation_it_follows() {
879 assert!(HOOKS.iter().all(|hook| hook.starts_with("post-")));
881 assert_eq!(HOOKS.len(), 3);
882 }
883
884 #[test]
885 fn every_shim_hands_control_back_to_the_repositorys_own_hook() {
886 for hook in shadowing_hooks() {
889 let script = build_hook_script("/usr/local/bin/dev-prune", hook, false);
890 assert!(
891 script.contains(&format!("hooks/{hook}")),
892 "{hook} does not forward to the repository's own hook"
893 );
894 assert!(script.contains("exec"), "{hook} must exec, not call");
895 assert!(
898 !script.contains("--git-path"),
899 "{hook} must not resolve its target through core.hooksPath"
900 );
901 assert!(
902 script.trim_end().ends_with("exit 0"),
903 "{hook} must succeed when the repository has no hook of that name"
904 );
905 }
906 }
907
908 #[test]
909 fn only_the_three_registration_hooks_register() {
910 let registering = build_hook_script("/bin/devp", "post-commit", true);
913 assert!(registering.contains("link . --quiet"));
914 let passthrough = build_hook_script("/bin/devp", "pre-push", false);
915 assert!(!passthrough.contains("link . --quiet"));
916 }
917
918 #[test]
919 fn the_high_frequency_hooks_are_left_alone() {
920 let names = shadowing_hooks();
924 assert!(!names.contains(&"reference-transaction"));
925 assert!(!names.contains(&"post-index-change"));
926 for expected in ["pre-commit", "commit-msg", "pre-push", "prepare-commit-msg"] {
928 assert!(names.contains(&expected), "{expected} must be shimmed");
929 }
930 }
931
932 #[test]
933 fn a_pre_1_4_0_hook_set_is_recognised_as_incomplete() {
934 let tmp = tempfile::tempdir().unwrap();
937 for name in HOOKS {
938 std::fs::write(
939 tmp.path().join(name),
940 "#!/bin/sh
941",
942 )
943 .unwrap();
944 }
945 assert!(
946 shims_missing_in(tmp.path()),
947 "a three-file hooks directory must be reported as needing repair"
948 );
949 }
950
951 #[test]
952 fn a_full_shim_set_needs_no_repair() {
953 let tmp = tempfile::tempdir().unwrap();
954 for name in shadowing_hooks() {
955 std::fs::write(
956 tmp.path().join(name),
957 "#!/bin/sh
958",
959 )
960 .unwrap();
961 }
962 assert!(!shims_missing_in(tmp.path()));
963 std::fs::remove_file(tmp.path().join("pre-commit")).unwrap();
965 assert!(shims_missing_in(tmp.path()));
966 }
967}