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] = [
27 "applypatch-msg",
28 "pre-applypatch",
29 "post-applypatch",
30 "pre-commit",
31 "pre-merge-commit",
32 "prepare-commit-msg",
33 "commit-msg",
34 "post-commit",
35 "pre-rebase",
36 "post-checkout",
37 "post-merge",
38 "pre-push",
39 "pre-receive",
40 "update",
41 "proc-receive",
42 "post-receive",
43 "post-update",
44 "reference-transaction",
45 "push-to-checkout",
46 "pre-auto-gc",
47 "post-rewrite",
48 "sendemail-validate",
49 "fsmonitor-watchman",
50 "p4-changelist",
51 "p4-prepare-changelist",
52 "p4-post-changelist",
53 "p4-pre-submit",
54 "post-index-change",
55];
56
57const CHAIN_MARKER: &str = ".chain-target";
63
64pub fn hooks_dir() -> Result<PathBuf> {
66 let base = Registry::config_dir()?;
67 Ok(base.join("hooks"))
68}
69
70pub fn chain_target() -> Option<PathBuf> {
72 let marker = hooks_dir().ok()?.join(CHAIN_MARKER);
73 let raw = fs::read_to_string(marker).ok()?;
74 let trimmed = raw.trim();
75 (!trimmed.is_empty()).then(|| PathBuf::from(trimmed))
76}
77
78pub const GIT_MISSING_HELP: &str = "`git` was not found on your PATH.\n\
80 dev-prune identifies repositories with Git and installs its hooks through \
81 `git config --global`, so it can do neither without it.\n\
82 Install Git from https://git-scm.com/downloads (or your package manager), confirm \
83 that `git --version` works in a new terminal, then run `devp setup` again.";
84
85pub fn git_available() -> bool {
90 crate::spawn::command("git")
91 .arg("--version")
92 .output()
93 .map(|out| out.status.success())
94 .unwrap_or(false)
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum HookState {
100 Active,
102 Absent,
104 Foreign(String),
107 Chained {
109 previous: String,
111 drifted: Vec<String>,
114 },
115}
116
117pub fn state() -> Result<HookState> {
119 let dir = hooks_dir()?;
120 match global_hooks_path() {
121 Some(existing) if Path::new(&existing) != dir => Ok(HookState::Foreign(existing)),
122 Some(_) if HOOKS.iter().all(|hook| dir.join(hook).exists()) => match chain_target() {
123 Some(previous) => Ok(HookState::Chained {
124 drifted: chain_drift(&dir, &previous),
125 previous: output::clean_path(&previous),
126 }),
127 None => Ok(HookState::Active),
128 },
129 _ => Ok(HookState::Absent),
132 }
133}
134
135fn chain_drift(ours: &Path, theirs: &Path) -> Vec<String> {
141 hook_names_in(theirs)
142 .into_iter()
143 .filter(|name| !ours.join(name).exists())
144 .collect()
145}
146
147fn hook_names_in(dir: &Path) -> Vec<String> {
149 let Ok(entries) = fs::read_dir(dir) else {
150 return Vec::new();
151 };
152 let present: Vec<String> = entries
153 .flatten()
154 .filter(|e| e.path().is_file())
155 .filter_map(|e| e.file_name().into_string().ok())
156 .collect();
157 GIT_HOOK_NAMES
158 .iter()
159 .filter(|name| present.iter().any(|p| p == *name))
160 .map(|name| name.to_string())
161 .collect()
162}
163
164fn global_hooks_path() -> Option<String> {
166 let out = crate::spawn::command("git")
167 .args(["config", "--global", "core.hooksPath"])
168 .output()
169 .ok()?;
170 if !out.status.success() {
171 return None;
172 }
173 let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
174 (!value.is_empty()).then_some(value)
175}
176
177fn build_hook_script(exe: &str) -> String {
185 format!(
186 r#"#!/usr/bin/env sh
187# dev-prune automatic workspace registration hook (non-blocking)
188('{}' link . --quiet >/dev/null 2>&1 &)
189"#,
190 sq(exe)
191 )
192}
193
194fn build_chained_hook_script(exe: &str, previous: &Path, hook: &str, register: bool) -> String {
203 let registration = if register {
204 format!("('{}' link . --quiet >/dev/null 2>&1 &)\n", sq(exe))
205 } else {
206 String::new()
207 };
208 let target = previous.join(hook);
209 format!(
210 r#"#!/usr/bin/env sh
211# dev-prune hook shim — chained. Rebuild with `devp hook install --chain`.
212{registration}next='{}'
213if [ -x "$next" ]; then exec "$next" "$@"; fi
214if [ -f "$next" ]; then exec sh "$next" "$@"; fi
215exit 0
216"#,
217 sq(&target.to_string_lossy())
218 )
219}
220
221fn sq(value: &str) -> String {
224 value.replace('\'', r"'\''")
225}
226
227fn parse_hook_exe(script: &str) -> Option<PathBuf> {
234 let start = script.find("('")? + 2;
235 let end = start + script[start..].find("' link . --quiet")?;
236 let exe = script[start..end].replace(r"'\''", "'");
237 (!exe.is_empty()).then(|| PathBuf::from(exe))
238}
239
240pub fn registered_exe_path() -> Option<PathBuf> {
247 let script = fs::read_to_string(hooks_dir().ok()?.join(HOOKS[0])).ok()?;
248 parse_hook_exe(&script)
249}
250
251pub fn run_install(chain: bool) -> Result<()> {
253 let dir = hooks_dir()?;
254 install_with(chain)?;
255
256 output::print_header("dev-prune Non-Blocking Git Hooks");
257 output::print_success(&format!(
258 "Installed global Git hooks in `{}`",
259 output::clean_path(&dir)
260 ));
261 println!(" Hooks Active: {}", HOOKS.join(", "));
262 println!(" Execution Mode: Asynchronous / Non-blocking (0ms commit impact)");
263
264 match chain_target() {
265 Some(previous) => {
266 let forwarded = hook_names_in(&previous);
267 println!(" Chained To: {}", output::clean_path(&previous));
268 println!(
269 " Forwarded Hooks: {}",
270 if forwarded.is_empty() {
271 "none found (the directory is empty)".to_string()
272 } else {
273 forwarded.join(", ")
274 }
275 );
276 println!();
277 output::print_info("How to manage hook settings:");
278 println!(" Restore Previous: devp hook uninstall");
279 println!(" Rebuild The Chain: devp hook install --chain");
280 println!();
281 output::print_warning(
282 "The chain is a snapshot. If that tool adds a hook later, re-run \
283 `devp hook install --chain` — `devp hook status` reports the drift.",
284 );
285 }
286 None => {
287 println!();
288 output::print_info("How to manage hook settings:");
289 println!(" Disable Globally: devp hook uninstall");
290 println!(" Disable Per-Repo: git config core.hooksPath \"\" (inside project root)");
291 println!(" Re-enable Globally: devp hook install");
292 println!();
293 output::print_warning(
294 "While this is active, per-repo `.git/hooks` are ignored in every repository on \
295 this machine — that includes husky, pre-commit and lefthook.",
296 );
297 }
298 }
299
300 Ok(())
301}
302
303pub fn install() -> Result<()> {
307 install_with(false)
308}
309
310pub fn install_with(chain: bool) -> Result<()> {
318 let dir = hooks_dir()?;
319
320 if !git_available() {
323 anyhow::bail!("{GIT_MISSING_HELP}");
324 }
325
326 let previous = match global_hooks_path() {
330 Some(existing) if Path::new(&existing) != dir => {
331 if !chain {
332 anyhow::bail!(
333 "`core.hooksPath` is already set globally to `{existing}`.\n\
334 Git only supports one hooks directory, so installing here would disable \
335 those hooks in every repo on this machine.\n\
336 Run `devp hook install --chain` to install in front of it instead: \
337 dev-prune registers the repo, then hands every hook on to `{existing}`.\n\
338 Or unset it first:\n git config --global --unset core.hooksPath\n\
339 Or do nothing — `devp link .` in new repos does the same job by hand."
340 );
341 }
342 let path = PathBuf::from(&existing);
343 if path.is_relative() {
347 anyhow::bail!(
348 "`core.hooksPath` is set to the relative path `{existing}`, which Git \
349 resolves separately inside every repository. There is no one directory \
350 to chain to.\n\
351 Set it to an absolute path first, or leave it alone and use `devp link .`."
352 );
353 }
354 Some(path)
355 }
356 _ => chain.then(chain_target).flatten(),
359 };
360
361 fs::create_dir_all(&dir)
362 .with_context(|| format!("Failed to create hooks directory at {}", dir.display()))?;
363
364 let exe = crate::setup::stable_exe_path()
372 .to_string_lossy()
373 .into_owned();
374
375 match &previous {
376 None => {
377 let hook_content = build_hook_script(&exe);
378 for hook in HOOKS {
379 write_hook(&dir.join(hook), &hook_content)?;
380 }
381 let _ = fs::remove_file(dir.join(CHAIN_MARKER));
384 }
385 Some(prev) => {
386 let mut names: Vec<String> = HOOKS.iter().map(|h| h.to_string()).collect();
390 for name in hook_names_in(prev) {
391 if !names.contains(&name) {
392 names.push(name);
393 }
394 }
395 for stale in hook_names_in(&dir) {
398 if !names.contains(&stale) {
399 let _ = fs::remove_file(dir.join(&stale));
400 }
401 }
402 for name in &names {
403 let register = HOOKS.contains(&name.as_str());
404 let content = build_chained_hook_script(&exe, prev, name, register);
405 write_hook(&dir.join(name), &content)?;
406 }
407 fs::write(dir.join(CHAIN_MARKER), format!("{}\n", prev.display())).with_context(
408 || "Failed to record the chained hooks path; refusing a chain we cannot undo",
409 )?;
410 }
411 }
412
413 let status = crate::spawn::command("git")
415 .args([
416 "config",
417 "--global",
418 "core.hooksPath",
419 &dir.to_string_lossy(),
420 ])
421 .status()
422 .with_context(|| "Failed to execute `git config --global core.hooksPath`")?;
423
424 if !status.success() {
425 anyhow::bail!("Failed to update git global configuration.");
426 }
427
428 Ok(())
429}
430
431fn write_hook(path: &Path, content: &str) -> Result<()> {
433 fs::write(path, content).with_context(|| format!("Failed to write hook {}", path.display()))?;
434 #[cfg(unix)]
435 {
436 use std::os::unix::fs::PermissionsExt;
437 let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o755));
438 }
439 Ok(())
440}
441
442fn remove_hook_files(dir: &Path) {
449 let _ = fs::remove_file(dir.join(CHAIN_MARKER));
450 for name in hook_names_in(dir) {
451 let _ = fs::remove_file(dir.join(name));
452 }
453}
454
455pub fn run_uninstall() -> Result<()> {
457 let dir = hooks_dir()?;
460
461 if let Some(previous) = chain_target()
465 && global_hooks_path().is_some_and(|c| Path::new(&c) == dir)
466 {
467 let restored = crate::spawn::command("git")
468 .args([
469 "config",
470 "--global",
471 "core.hooksPath",
472 &previous.to_string_lossy(),
473 ])
474 .status();
475 match restored {
476 Ok(status) if status.success() => {
477 remove_hook_files(&dir);
478 output::print_success(&format!(
479 "Restored `core.hooksPath` to `{}`.",
480 output::clean_path(&previous)
481 ));
482 return Ok(());
483 }
484 Ok(status) => anyhow::bail!(
485 "`git config --global core.hooksPath` exited with {status} while restoring \
486 `{}`. Set it by hand to bring those hooks back.",
487 output::clean_path(&previous)
488 ),
489 Err(e) => anyhow::bail!("Could not run `git config --global core.hooksPath`: {e}"),
490 }
491 }
492
493 match global_hooks_path() {
494 Some(current) if Path::new(¤t) != dir => {
495 output::print_info(&format!(
496 "`core.hooksPath` is set to `{current}`, which is not dev-prune's — leaving it alone."
497 ));
498 if !hook_names_in(&dir).is_empty() || dir.join(CHAIN_MARKER).exists() {
501 remove_hook_files(&dir);
502 output::print_info("Removed dev-prune's leftover hook scripts.");
503 }
504 return Ok(());
505 }
506 None => {
507 if !hook_names_in(&dir).is_empty() || dir.join(CHAIN_MARKER).exists() {
508 remove_hook_files(&dir);
509 output::print_success(
510 "`core.hooksPath` was not set globally; removed dev-prune's leftover \
511 hook scripts.",
512 );
513 } else {
514 output::print_info("`core.hooksPath` is not set globally — nothing to remove.");
515 }
516 return Ok(());
517 }
518 Some(_) => {}
519 }
520
521 let unset = crate::spawn::command("git")
525 .args(["config", "--global", "--unset", "core.hooksPath"])
526 .status();
527 match unset {
528 Ok(status) if status.success() => {
529 remove_hook_files(&dir);
530 output::print_success(
531 "Removed global Git hook configuration (`git config --global --unset core.hooksPath`).",
532 );
533 Ok(())
534 }
535 Ok(status) => anyhow::bail!(
536 "`git config --global --unset core.hooksPath` exited with {status}. \
537 Run it by hand to finish removing the hooks."
538 ),
539 Err(e) => anyhow::bail!("Could not run `git config --global --unset core.hooksPath`: {e}"),
540 }
541}
542
543pub fn run_status() -> Result<()> {
545 let dir = hooks_dir()?;
546
547 if !git_available() {
548 output::print_header("dev-prune Git Hooks Status");
549 output::print_error(GIT_MISSING_HELP);
550 return Ok(());
551 }
552
553 let configured = global_hooks_path();
554 let on_disk = HOOKS.iter().all(|hook| dir.join(hook).exists());
555
556 let points_at_us = configured
560 .as_deref()
561 .is_some_and(|current| Path::new(current) == dir);
562
563 output::print_header("dev-prune Git Hooks Status");
564 println!(
565 " Configured core.hooksPath: {}",
566 configured.as_deref().unwrap_or("Not set")
567 );
568 println!(" DevPrune Hooks Directory: {}", dir.display());
569 println!(
570 " Hooks Installed on Disk: {}",
571 if on_disk {
572 format!("Yes ({})", HOOKS.join(", "))
573 } else {
574 "No".to_string()
575 }
576 );
577 if let Some(previous) = chain_target() {
578 println!(
579 " Chained To: {}",
580 output::clean_path(&previous)
581 );
582 let forwarded = hook_names_in(&previous);
583 println!(
584 " Forwarded Hooks: {}",
585 if forwarded.is_empty() {
586 "none".to_string()
587 } else {
588 forwarded.join(", ")
589 }
590 );
591 let drifted = chain_drift(&dir, &previous);
592 if !drifted.is_empty() {
593 println!();
594 output::print_warning(&format!(
595 "`{}` now has hooks the chain does not forward: {}.\n \
596 They are not running. Rebuild with `devp hook install --chain`.",
597 output::clean_path(&previous),
598 drifted.join(", ")
599 ));
600 }
601 }
602 println!();
603 match (points_at_us, on_disk) {
604 (true, true) => output::print_success("Global background auto-registration is ACTIVE."),
605 (true, false) => output::print_warning(
606 "`core.hooksPath` points here but the hook files are missing. \
607 Re-run `devp hook install`.",
608 ),
609 (false, true) => output::print_warning(
610 "Hook files exist but `core.hooksPath` points elsewhere — they never run. \
611 Re-run `devp hook install`, or delete the directory.",
612 ),
613 (false, false) => output::print_info(
614 "Global background hook is inactive. Run `devp hook install` to enable.",
615 ),
616 }
617
618 Ok(())
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624
625 #[test]
626 fn hook_script_single_quotes_the_executable_path() {
627 let script = build_hook_script("/usr/local/bin/dev-prune");
628 assert!(script.contains("('/usr/local/bin/dev-prune' link . --quiet"));
629 }
630
631 #[test]
632 fn hook_script_neutralises_shell_metacharacters_in_the_path() {
633 let script = build_hook_script(r"C:\Users\a$b\`whoami`\dev-prune.exe");
635 assert!(script.contains(r"('C:\Users\a$b\`whoami`\dev-prune.exe' link ."));
636 }
637
638 #[test]
639 fn hook_script_escapes_an_embedded_single_quote() {
640 let script = build_hook_script("/home/o'brien/dev-prune");
641 assert!(script.contains(r"('/home/o'\''brien/dev-prune' link ."));
642 }
643
644 #[test]
645 fn hook_script_starts_with_a_shebang_and_backgrounds_the_call() {
646 let script = build_hook_script("devp");
647 assert!(script.starts_with("#!/usr/bin/env sh\n"));
648 assert!(script.contains(">/dev/null 2>&1 &)"));
650 }
651
652 #[test]
653 fn a_hooks_path_git_reported_with_forward_slashes_is_still_ours() {
654 #[cfg(windows)]
660 assert_eq!(
661 Path::new("C:/Users/dev/AppData/Roaming/dev-prune/hooks"),
662 Path::new(r"C:\Users\dev\AppData\Roaming\dev-prune\hooks")
663 );
664
665 assert_ne!(
667 Path::new("/home/dev/.config/dev-prune/hooks"),
668 Path::new("/home/dev/.config/husky/hooks")
669 );
670 }
671
672 #[test]
673 fn a_chained_hook_execs_the_hook_it_displaced() {
674 let script = build_chained_hook_script(
675 "/usr/local/bin/dev-prune",
676 Path::new("/home/dev/.husky"),
677 "post-commit",
678 true,
679 );
680 assert!(script.contains("('/usr/local/bin/dev-prune' link . --quiet"));
681 assert!(script.contains(r#"exec "$next" "$@""#));
683 assert!(script.contains("post-commit'"));
684 assert!(script.trim_end().ends_with("exit 0"));
686 }
687
688 #[test]
689 fn the_binary_is_recoverable_from_a_plain_hook() {
690 let script = build_hook_script("/usr/local/bin/dev-prune");
691 assert_eq!(
692 parse_hook_exe(&script),
693 Some(PathBuf::from("/usr/local/bin/dev-prune"))
694 );
695 }
696
697 #[test]
698 fn the_binary_is_recoverable_from_a_chained_hook() {
699 let script = build_chained_hook_script(
700 "C:\\Users\\a\\AppData\\Roaming\\dev-prune\\bin\\dev-prune.exe",
701 Path::new("/home/dev/.husky"),
702 "post-commit",
703 true,
704 );
705 assert_eq!(
706 parse_hook_exe(&script),
707 Some(PathBuf::from(
708 "C:\\Users\\a\\AppData\\Roaming\\dev-prune\\bin\\dev-prune.exe"
709 ))
710 );
711 }
712
713 #[test]
714 fn a_quote_in_the_path_survives_the_round_trip() {
715 let script = build_hook_script("/home/o'brien/dev-prune");
718 assert_eq!(
719 parse_hook_exe(&script),
720 Some(PathBuf::from("/home/o'brien/dev-prune"))
721 );
722 }
723
724 #[test]
725 fn a_script_that_is_not_ours_answers_nothing() {
726 assert!(parse_hook_exe("#!/bin/sh\nnpm test\n").is_none());
727 }
728
729 #[test]
730 fn a_forwarded_hook_we_do_not_own_only_forwards() {
731 let script = build_chained_hook_script(
732 "/usr/local/bin/dev-prune",
733 Path::new("/home/dev/.husky"),
734 "pre-commit",
735 false,
736 );
737 assert!(!script.contains("link ."));
740 assert!(script.contains(r#"exec "$next" "$@""#));
741 }
742
743 #[test]
744 fn chaining_never_shims_a_file_that_is_not_a_git_hook() {
745 let tmp = tempfile::TempDir::new().unwrap();
746 fs::write(tmp.path().join("pre-commit"), "#!/bin/sh\nnpm test\n").unwrap();
748 fs::write(tmp.path().join("commit-msg"), "#!/bin/sh\ncommitlint\n").unwrap();
749 fs::write(tmp.path().join(".gitignore"), "_\n").unwrap();
750 fs::write(tmp.path().join("README.md"), "hooks\n").unwrap();
751 fs::create_dir(tmp.path().join("_")).unwrap();
752
753 let found = hook_names_in(tmp.path());
754 assert_eq!(
755 found,
756 vec!["pre-commit".to_string(), "commit-msg".to_string()]
757 );
758 }
759
760 #[test]
761 fn drift_is_a_hook_the_other_tool_added_after_the_chain_was_built() {
762 let ours = tempfile::TempDir::new().unwrap();
763 let theirs = tempfile::TempDir::new().unwrap();
764 fs::write(theirs.path().join("pre-commit"), "x").unwrap();
765 fs::write(theirs.path().join("pre-push"), "x").unwrap();
766 fs::write(ours.path().join("pre-commit"), "shim").unwrap();
767
768 assert_eq!(
769 chain_drift(ours.path(), theirs.path()),
770 vec!["pre-push".to_string()]
771 );
772 }
773
774 #[test]
775 fn every_installed_hook_runs_after_the_operation_it_follows() {
776 assert!(HOOKS.iter().all(|hook| hook.starts_with("post-")));
778 assert_eq!(HOOKS.len(), 3);
779 }
780}