1use anyhow::{Context, Result};
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use crate::config::Registry;
14use crate::output;
15
16const HOOKS: [&str; 3] = ["post-commit", "post-checkout", "post-merge"];
20
21const GIT_HOOK_NAMES: [&str; 28] = [
29 "applypatch-msg",
30 "pre-applypatch",
31 "post-applypatch",
32 "pre-commit",
33 "pre-merge-commit",
34 "prepare-commit-msg",
35 "commit-msg",
36 "post-commit",
37 "pre-rebase",
38 "post-checkout",
39 "post-merge",
40 "pre-push",
41 "pre-receive",
42 "update",
43 "proc-receive",
44 "post-receive",
45 "post-update",
46 "reference-transaction",
47 "push-to-checkout",
48 "pre-auto-gc",
49 "post-rewrite",
50 "sendemail-validate",
51 "fsmonitor-watchman",
52 "p4-changelist",
53 "p4-prepare-changelist",
54 "p4-post-changelist",
55 "p4-pre-submit",
56 "post-index-change",
57];
58
59const NO_SHADOW_SHIM: [&str; 2] = ["reference-transaction", "post-index-change"];
69
70fn shadowing_hooks() -> Vec<&'static str> {
72 GIT_HOOK_NAMES
73 .iter()
74 .copied()
75 .filter(|name| !NO_SHADOW_SHIM.contains(name))
76 .collect()
77}
78
79const CHAIN_MARKER: &str = ".chain-target";
85
86pub fn hooks_dir() -> Result<PathBuf> {
88 let base = Registry::config_dir()?;
89 Ok(base.join("hooks"))
90}
91
92pub fn chain_target() -> Option<PathBuf> {
94 let marker = hooks_dir().ok()?.join(CHAIN_MARKER);
95 let raw = fs::read_to_string(marker).ok()?;
96 let trimmed = raw.trim();
97 (!trimmed.is_empty()).then(|| PathBuf::from(trimmed))
98}
99
100pub const GIT_MISSING_HELP: &str = "`git` was not found on your PATH.\n\
102 dev-prune identifies repositories with Git and installs its hooks through \
103 `git config --global`, so it can do neither without it.\n\
104 Install Git from https://git-scm.com/downloads (or your package manager), confirm \
105 that `git --version` works in a new terminal, then run `devp setup` again.";
106
107pub fn git_available() -> bool {
112 crate::spawn::command("git")
113 .arg("--version")
114 .output()
115 .map(|out| out.status.success())
116 .unwrap_or(false)
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum HookState {
122 Active,
124 Absent,
126 Foreign(String),
129 Chained {
131 previous: String,
133 drifted: Vec<String>,
136 },
137}
138
139pub fn state() -> Result<HookState> {
141 let dir = hooks_dir()?;
142 match global_hooks_path() {
143 Some(existing) if Path::new(&existing) != dir => Ok(HookState::Foreign(existing)),
144 Some(_) if HOOKS.iter().all(|hook| dir.join(hook).exists()) => match chain_target() {
145 Some(previous) => Ok(HookState::Chained {
146 drifted: chain_drift(&dir, &previous),
147 previous: output::clean_path(&previous),
148 }),
149 None => Ok(HookState::Active),
150 },
151 _ => Ok(HookState::Absent),
154 }
155}
156
157fn chain_drift(ours: &Path, theirs: &Path) -> Vec<String> {
163 hook_names_in(theirs)
164 .into_iter()
165 .filter(|name| !ours.join(name).exists())
166 .collect()
167}
168
169fn hook_names_in(dir: &Path) -> Vec<String> {
171 let Ok(entries) = fs::read_dir(dir) else {
172 return Vec::new();
173 };
174 let present: Vec<String> = entries
175 .flatten()
176 .filter(|e| e.path().is_file())
177 .filter_map(|e| e.file_name().into_string().ok())
178 .collect();
179 GIT_HOOK_NAMES
180 .iter()
181 .filter(|name| present.iter().any(|p| p == *name))
182 .map(|name| name.to_string())
183 .collect()
184}
185
186pub fn shims_incomplete() -> bool {
196 let Ok(dir) = hooks_dir() else {
197 return false;
198 };
199 if chain_target().is_some() {
200 return false;
201 }
202 shims_missing_in(&dir)
203}
204
205fn shims_missing_in(dir: &Path) -> bool {
208 shadowing_hooks()
209 .into_iter()
210 .any(|name| !dir.join(name).exists())
211}
212
213fn global_hooks_path() -> Option<String> {
215 let out = crate::spawn::command("git")
216 .args(["config", "--global", "core.hooksPath"])
217 .output()
218 .ok()?;
219 if !out.status.success() {
220 return None;
221 }
222 let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
223 (!value.is_empty()).then_some(value)
224}
225
226fn build_hook_script(exe: &str, hook: &str, register: bool) -> String {
234 let registration = if register {
235 format!("('{}' link . --quiet >/dev/null 2>&1 &)\n", sq(exe))
236 } else {
237 String::new()
238 };
239 format!(
240 r#"#!/usr/bin/env sh
241# dev-prune hook shim. Rebuild with `devp hook install`.
242{registration}{}"#,
243 local_passthrough(hook)
244 )
245}
246
247fn local_passthrough(hook: &str) -> String {
266 format!(
267 r#"common=$(git rev-parse --git-common-dir 2>/dev/null) || common="${{GIT_DIR:-.git}}"
268next="$common/hooks/{0}"
269if [ -x "$next" ]; then exec "$next" "$@"; fi
270if [ -f "$next" ]; then exec sh "$next" "$@"; fi
271exit 0
272"#,
273 hook
274 )
275}
276
277fn build_chained_hook_script(exe: &str, previous: &Path, hook: &str, register: bool) -> String {
286 let registration = if register {
287 format!("('{}' link . --quiet >/dev/null 2>&1 &)\n", sq(exe))
288 } else {
289 String::new()
290 };
291 let target = previous.join(hook);
292 format!(
293 r#"#!/usr/bin/env sh
294# dev-prune hook shim — chained. Rebuild with `devp hook install --chain`.
295{registration}next='{}'
296if [ -x "$next" ]; then exec "$next" "$@"; fi
297if [ -f "$next" ]; then exec sh "$next" "$@"; fi
298exit 0
299"#,
300 sq(&target.to_string_lossy())
301 )
302}
303
304fn sq(value: &str) -> String {
307 value.replace('\'', r"'\''")
308}
309
310fn parse_hook_exe(script: &str) -> Option<PathBuf> {
317 let start = script.find("('")? + 2;
318 let end = start + script[start..].find("' link . --quiet")?;
319 let exe = script[start..end].replace(r"'\''", "'");
320 (!exe.is_empty()).then(|| PathBuf::from(exe))
321}
322
323pub fn registered_exe_path() -> Option<PathBuf> {
330 let script = fs::read_to_string(hooks_dir().ok()?.join(HOOKS[0])).ok()?;
331 parse_hook_exe(&script)
332}
333
334pub fn run_install(chain: bool) -> Result<()> {
336 let dir = hooks_dir()?;
337 install_with(chain)?;
338
339 output::print_header("dev-prune Non-Blocking Git Hooks");
340 output::print_success(&format!(
341 "Installed global Git hooks in `{}`",
342 output::clean_path(&dir)
343 ));
344 println!(" Hooks Active: {}", HOOKS.join(", "));
345 println!(" Execution Mode: Asynchronous / Non-blocking (0ms commit impact)");
346
347 match chain_target() {
348 Some(previous) => {
349 let forwarded = hook_names_in(&previous);
350 println!(" Chained To: {}", output::clean_path(&previous));
351 println!(
352 " Forwarded Hooks: {}",
353 if forwarded.is_empty() {
354 "none found (the directory is empty)".to_string()
355 } else {
356 forwarded.join(", ")
357 }
358 );
359 println!();
360 output::print_info("How to manage hook settings:");
361 println!(" Restore Previous: devp hook uninstall");
362 println!(" Rebuild The Chain: devp hook install --chain");
363 println!();
364 output::print_warning(
365 "The chain is a snapshot. If that tool adds a hook later, re-run \
366 `devp hook install --chain` — `devp hook status` reports the drift.",
367 );
368 }
369 None => {
370 println!();
371 output::print_info("How to manage hook settings:");
372 println!(" Disable Globally: devp hook uninstall");
373 println!(" Disable Per-Repo: git config core.hooksPath \"\" (inside project root)");
374 println!(" Re-enable Globally: devp hook install");
375 println!();
376 output::print_warning(
377 "While this is active, per-repo `.git/hooks` are ignored in every repository on \
378 this machine — that includes husky, pre-commit and lefthook.",
379 );
380 }
381 }
382
383 Ok(())
384}
385
386pub fn install() -> Result<()> {
390 install_with(false)
391}
392
393pub fn install_with(chain: bool) -> Result<()> {
401 let dir = hooks_dir()?;
402
403 if !git_available() {
406 anyhow::bail!("{GIT_MISSING_HELP}");
407 }
408
409 let previous = match global_hooks_path() {
413 Some(existing) if Path::new(&existing) != dir => {
414 if !chain {
415 anyhow::bail!(
416 "`core.hooksPath` is already set globally to `{existing}`.\n\
417 Git only supports one hooks directory, so installing here would disable \
418 those hooks in every repo on this machine.\n\
419 Run `devp hook install --chain` to install in front of it instead: \
420 dev-prune registers the repo, then hands every hook on to `{existing}`.\n\
421 Or unset it first:\n git config --global --unset core.hooksPath\n\
422 Or do nothing — `devp link .` in new repos does the same job by hand."
423 );
424 }
425 let path = PathBuf::from(&existing);
426 if path.is_relative() {
430 anyhow::bail!(
431 "`core.hooksPath` is set to the relative path `{existing}`, which Git \
432 resolves separately inside every repository. There is no one directory \
433 to chain to.\n\
434 Set it to an absolute path first, or leave it alone and use `devp link .`."
435 );
436 }
437 Some(path)
438 }
439 _ => chain.then(chain_target).flatten(),
442 };
443
444 fs::create_dir_all(&dir)
445 .with_context(|| format!("Failed to create hooks directory at {}", dir.display()))?;
446
447 let exe = crate::setup::stable_exe_path()
455 .to_string_lossy()
456 .into_owned();
457
458 match &previous {
459 None => {
460 let names = shadowing_hooks();
461 for hook in &names {
462 let content = build_hook_script(&exe, hook, HOOKS.contains(hook));
463 write_hook(&dir.join(hook), &content)?;
464 }
465 for stale in hook_names_in(&dir) {
468 if !names.contains(&stale.as_str()) {
469 let _ = fs::remove_file(dir.join(&stale));
470 }
471 }
472 let _ = fs::remove_file(dir.join(CHAIN_MARKER));
475 }
476 Some(prev) => {
477 let mut names: Vec<String> = HOOKS.iter().map(|h| h.to_string()).collect();
481 for name in hook_names_in(prev) {
482 if !names.contains(&name) {
483 names.push(name);
484 }
485 }
486 for stale in hook_names_in(&dir) {
489 if !names.contains(&stale) {
490 let _ = fs::remove_file(dir.join(&stale));
491 }
492 }
493 for name in &names {
494 let register = HOOKS.contains(&name.as_str());
495 let content = build_chained_hook_script(&exe, prev, name, register);
496 write_hook(&dir.join(name), &content)?;
497 }
498 fs::write(dir.join(CHAIN_MARKER), format!("{}\n", prev.display())).with_context(
499 || "Failed to record the chained hooks path; refusing a chain we cannot undo",
500 )?;
501 }
502 }
503
504 let status = crate::spawn::command("git")
506 .args([
507 "config",
508 "--global",
509 "core.hooksPath",
510 &dir.to_string_lossy(),
511 ])
512 .status()
513 .with_context(|| "Failed to execute `git config --global core.hooksPath`")?;
514
515 if !status.success() {
516 anyhow::bail!("Failed to update git global configuration.");
517 }
518
519 Ok(())
520}
521
522fn write_hook(path: &Path, content: &str) -> Result<()> {
524 fs::write(path, content).with_context(|| format!("Failed to write hook {}", path.display()))?;
525 #[cfg(unix)]
526 {
527 use std::os::unix::fs::PermissionsExt;
528 let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o755));
529 }
530 Ok(())
531}
532
533fn remove_hook_files(dir: &Path) {
540 let _ = fs::remove_file(dir.join(CHAIN_MARKER));
541 for name in hook_names_in(dir) {
542 let _ = fs::remove_file(dir.join(name));
543 }
544}
545
546pub fn run_uninstall() -> Result<()> {
548 let dir = hooks_dir()?;
551
552 if let Some(previous) = chain_target()
556 && global_hooks_path().is_some_and(|c| Path::new(&c) == dir)
557 {
558 let restored = crate::spawn::command("git")
559 .args([
560 "config",
561 "--global",
562 "core.hooksPath",
563 &previous.to_string_lossy(),
564 ])
565 .status();
566 match restored {
567 Ok(status) if status.success() => {
568 remove_hook_files(&dir);
569 output::print_success(&format!(
570 "Restored `core.hooksPath` to `{}`.",
571 output::clean_path(&previous)
572 ));
573 return Ok(());
574 }
575 Ok(status) => anyhow::bail!(
576 "`git config --global core.hooksPath` exited with {status} while restoring \
577 `{}`. Set it by hand to bring those hooks back.",
578 output::clean_path(&previous)
579 ),
580 Err(e) => anyhow::bail!("Could not run `git config --global core.hooksPath`: {e}"),
581 }
582 }
583
584 match global_hooks_path() {
585 Some(current) if Path::new(¤t) != dir => {
586 output::print_info(&format!(
587 "`core.hooksPath` is set to `{current}`, which is not dev-prune's — leaving it alone."
588 ));
589 if !hook_names_in(&dir).is_empty() || dir.join(CHAIN_MARKER).exists() {
592 remove_hook_files(&dir);
593 output::print_info("Removed dev-prune's leftover hook scripts.");
594 }
595 return Ok(());
596 }
597 None => {
598 if !hook_names_in(&dir).is_empty() || dir.join(CHAIN_MARKER).exists() {
599 remove_hook_files(&dir);
600 output::print_success(
601 "`core.hooksPath` was not set globally; removed dev-prune's leftover \
602 hook scripts.",
603 );
604 } else {
605 output::print_info("`core.hooksPath` is not set globally — nothing to remove.");
606 }
607 return Ok(());
608 }
609 Some(_) => {}
610 }
611
612 let unset = crate::spawn::command("git")
616 .args(["config", "--global", "--unset", "core.hooksPath"])
617 .status();
618 match unset {
619 Ok(status) if status.success() => {
620 remove_hook_files(&dir);
621 output::print_success(
622 "Removed global Git hook configuration (`git config --global --unset core.hooksPath`).",
623 );
624 Ok(())
625 }
626 Ok(status) => anyhow::bail!(
627 "`git config --global --unset core.hooksPath` exited with {status}. \
628 Run it by hand to finish removing the hooks."
629 ),
630 Err(e) => anyhow::bail!("Could not run `git config --global --unset core.hooksPath`: {e}"),
631 }
632}
633
634pub fn run_status() -> Result<()> {
636 let dir = hooks_dir()?;
637
638 if !git_available() {
639 output::print_header("dev-prune Git Hooks Status");
640 output::print_error(GIT_MISSING_HELP);
641 return Ok(());
642 }
643
644 let configured = global_hooks_path();
645 let on_disk = HOOKS.iter().all(|hook| dir.join(hook).exists());
646
647 let points_at_us = configured
651 .as_deref()
652 .is_some_and(|current| Path::new(current) == dir);
653
654 output::print_header("dev-prune Git Hooks Status");
655 println!(
656 " Configured core.hooksPath: {}",
657 configured.as_deref().unwrap_or("Not set")
658 );
659 println!(" DevPrune Hooks Directory: {}", dir.display());
660 println!(
661 " Hooks Installed on Disk: {}",
662 if on_disk {
663 format!("Yes ({})", HOOKS.join(", "))
664 } else {
665 "No".to_string()
666 }
667 );
668 if let Some(previous) = chain_target() {
669 println!(
670 " Chained To: {}",
671 output::clean_path(&previous)
672 );
673 let forwarded = hook_names_in(&previous);
674 println!(
675 " Forwarded Hooks: {}",
676 if forwarded.is_empty() {
677 "none".to_string()
678 } else {
679 forwarded.join(", ")
680 }
681 );
682 let drifted = chain_drift(&dir, &previous);
683 if !drifted.is_empty() {
684 println!();
685 output::print_warning(&format!(
686 "`{}` now has hooks the chain does not forward: {}.\n \
687 They are not running. Rebuild with `devp hook install --chain`.",
688 output::clean_path(&previous),
689 drifted.join(", ")
690 ));
691 }
692 }
693 println!();
694 match (points_at_us, on_disk) {
695 (true, true) => output::print_success("Global background auto-registration is ACTIVE."),
696 (true, false) => output::print_warning(
697 "`core.hooksPath` points here but the hook files are missing. \
698 Re-run `devp hook install`.",
699 ),
700 (false, true) => output::print_warning(
701 "Hook files exist but `core.hooksPath` points elsewhere — they never run. \
702 Re-run `devp hook install`, or delete the directory.",
703 ),
704 (false, false) => output::print_info(
705 "Global background hook is inactive. Run `devp hook install` to enable.",
706 ),
707 }
708
709 Ok(())
710}
711
712#[cfg(test)]
713mod tests {
714 use super::*;
715
716 #[test]
717 fn hook_script_single_quotes_the_executable_path() {
718 let script = build_hook_script("/usr/local/bin/dev-prune", "post-commit", true);
719 assert!(script.contains("('/usr/local/bin/dev-prune' link . --quiet"));
720 }
721
722 #[test]
723 fn hook_script_neutralises_shell_metacharacters_in_the_path() {
724 let script = build_hook_script(r"C:\Users\a$b\`whoami`\dev-prune.exe", "post-commit", true);
726 assert!(script.contains(r"('C:\Users\a$b\`whoami`\dev-prune.exe' link ."));
727 }
728
729 #[test]
730 fn hook_script_escapes_an_embedded_single_quote() {
731 let script = build_hook_script("/home/o'brien/dev-prune", "post-commit", true);
732 assert!(script.contains(r"('/home/o'\''brien/dev-prune' link ."));
733 }
734
735 #[test]
736 fn hook_script_starts_with_a_shebang_and_backgrounds_the_call() {
737 let script = build_hook_script("devp", "post-commit", true);
738 assert!(script.starts_with("#!/usr/bin/env sh\n"));
739 assert!(script.contains(">/dev/null 2>&1 &)"));
741 }
742
743 #[test]
744 fn a_hooks_path_git_reported_with_forward_slashes_is_still_ours() {
745 #[cfg(windows)]
751 assert_eq!(
752 Path::new("C:/Users/dev/AppData/Roaming/dev-prune/hooks"),
753 Path::new(r"C:\Users\dev\AppData\Roaming\dev-prune\hooks")
754 );
755
756 assert_ne!(
758 Path::new("/home/dev/.config/dev-prune/hooks"),
759 Path::new("/home/dev/.config/husky/hooks")
760 );
761 }
762
763 #[test]
764 fn a_chained_hook_execs_the_hook_it_displaced() {
765 let script = build_chained_hook_script(
766 "/usr/local/bin/dev-prune",
767 Path::new("/home/dev/.husky"),
768 "post-commit",
769 true,
770 );
771 assert!(script.contains("('/usr/local/bin/dev-prune' link . --quiet"));
772 assert!(script.contains(r#"exec "$next" "$@""#));
774 assert!(script.contains("post-commit'"));
775 assert!(script.trim_end().ends_with("exit 0"));
777 }
778
779 #[test]
780 fn the_binary_is_recoverable_from_a_plain_hook() {
781 let script = build_hook_script("/usr/local/bin/dev-prune", "post-commit", true);
782 assert_eq!(
783 parse_hook_exe(&script),
784 Some(PathBuf::from("/usr/local/bin/dev-prune"))
785 );
786 }
787
788 #[test]
789 fn the_binary_is_recoverable_from_a_chained_hook() {
790 let script = build_chained_hook_script(
791 "C:\\Users\\a\\AppData\\Roaming\\dev-prune\\bin\\dev-prune.exe",
792 Path::new("/home/dev/.husky"),
793 "post-commit",
794 true,
795 );
796 assert_eq!(
797 parse_hook_exe(&script),
798 Some(PathBuf::from(
799 "C:\\Users\\a\\AppData\\Roaming\\dev-prune\\bin\\dev-prune.exe"
800 ))
801 );
802 }
803
804 #[test]
805 fn a_quote_in_the_path_survives_the_round_trip() {
806 let script = build_hook_script("/home/o'brien/dev-prune", "post-commit", true);
809 assert_eq!(
810 parse_hook_exe(&script),
811 Some(PathBuf::from("/home/o'brien/dev-prune"))
812 );
813 }
814
815 #[test]
816 fn a_script_that_is_not_ours_answers_nothing() {
817 assert!(parse_hook_exe("#!/bin/sh\nnpm test\n").is_none());
818 }
819
820 #[test]
821 fn a_forwarded_hook_we_do_not_own_only_forwards() {
822 let script = build_chained_hook_script(
823 "/usr/local/bin/dev-prune",
824 Path::new("/home/dev/.husky"),
825 "pre-commit",
826 false,
827 );
828 assert!(!script.contains("link ."));
831 assert!(script.contains(r#"exec "$next" "$@""#));
832 }
833
834 #[test]
835 fn chaining_never_shims_a_file_that_is_not_a_git_hook() {
836 let tmp = tempfile::TempDir::new().unwrap();
837 fs::write(tmp.path().join("pre-commit"), "#!/bin/sh\nnpm test\n").unwrap();
839 fs::write(tmp.path().join("commit-msg"), "#!/bin/sh\ncommitlint\n").unwrap();
840 fs::write(tmp.path().join(".gitignore"), "_\n").unwrap();
841 fs::write(tmp.path().join("README.md"), "hooks\n").unwrap();
842 fs::create_dir(tmp.path().join("_")).unwrap();
843
844 let found = hook_names_in(tmp.path());
845 assert_eq!(
846 found,
847 vec!["pre-commit".to_string(), "commit-msg".to_string()]
848 );
849 }
850
851 #[test]
852 fn drift_is_a_hook_the_other_tool_added_after_the_chain_was_built() {
853 let ours = tempfile::TempDir::new().unwrap();
854 let theirs = tempfile::TempDir::new().unwrap();
855 fs::write(theirs.path().join("pre-commit"), "x").unwrap();
856 fs::write(theirs.path().join("pre-push"), "x").unwrap();
857 fs::write(ours.path().join("pre-commit"), "shim").unwrap();
858
859 assert_eq!(
860 chain_drift(ours.path(), theirs.path()),
861 vec!["pre-push".to_string()]
862 );
863 }
864
865 #[test]
866 fn every_installed_hook_runs_after_the_operation_it_follows() {
867 assert!(HOOKS.iter().all(|hook| hook.starts_with("post-")));
869 assert_eq!(HOOKS.len(), 3);
870 }
871
872 #[test]
873 fn every_shim_hands_control_back_to_the_repositorys_own_hook() {
874 for hook in shadowing_hooks() {
877 let script = build_hook_script("/usr/local/bin/dev-prune", hook, false);
878 assert!(
879 script.contains(&format!("hooks/{hook}")),
880 "{hook} does not forward to the repository's own hook"
881 );
882 assert!(script.contains("exec"), "{hook} must exec, not call");
883 assert!(
886 !script.contains("--git-path"),
887 "{hook} must not resolve its target through core.hooksPath"
888 );
889 assert!(
890 script.trim_end().ends_with("exit 0"),
891 "{hook} must succeed when the repository has no hook of that name"
892 );
893 }
894 }
895
896 #[test]
897 fn only_the_three_registration_hooks_register() {
898 let registering = build_hook_script("/bin/devp", "post-commit", true);
901 assert!(registering.contains("link . --quiet"));
902 let passthrough = build_hook_script("/bin/devp", "pre-push", false);
903 assert!(!passthrough.contains("link . --quiet"));
904 }
905
906 #[test]
907 fn the_high_frequency_hooks_are_left_alone() {
908 let names = shadowing_hooks();
912 assert!(!names.contains(&"reference-transaction"));
913 assert!(!names.contains(&"post-index-change"));
914 for expected in ["pre-commit", "commit-msg", "pre-push", "prepare-commit-msg"] {
916 assert!(names.contains(&expected), "{expected} must be shimmed");
917 }
918 }
919
920 #[test]
921 fn a_pre_1_4_0_hook_set_is_recognised_as_incomplete() {
922 let tmp = tempfile::tempdir().unwrap();
925 for name in HOOKS {
926 std::fs::write(
927 tmp.path().join(name),
928 "#!/bin/sh
929",
930 )
931 .unwrap();
932 }
933 assert!(
934 shims_missing_in(tmp.path()),
935 "a three-file hooks directory must be reported as needing repair"
936 );
937 }
938
939 #[test]
940 fn a_full_shim_set_needs_no_repair() {
941 let tmp = tempfile::tempdir().unwrap();
942 for name in shadowing_hooks() {
943 std::fs::write(
944 tmp.path().join(name),
945 "#!/bin/sh
946",
947 )
948 .unwrap();
949 }
950 assert!(!shims_missing_in(tmp.path()));
951 std::fs::remove_file(tmp.path().join("pre-commit")).unwrap();
953 assert!(shims_missing_in(tmp.path()));
954 }
955}