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