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
443pub fn run_uninstall() -> Result<()> {
445 let dir = hooks_dir()?;
448
449 if let Some(previous) = chain_target() {
453 if global_hooks_path().is_some_and(|c| Path::new(&c) == dir) {
454 let restored = Command::new("git")
455 .args([
456 "config",
457 "--global",
458 "core.hooksPath",
459 &previous.to_string_lossy(),
460 ])
461 .status();
462 match restored {
463 Ok(status) if status.success() => {
464 let _ = fs::remove_file(dir.join(CHAIN_MARKER));
465 output::print_success(&format!(
466 "Restored `core.hooksPath` to `{}`.",
467 output::clean_path(&previous)
468 ));
469 return Ok(());
470 }
471 Ok(status) => anyhow::bail!(
472 "`git config --global core.hooksPath` exited with {status} while restoring \
473 `{}`. Set it by hand to bring those hooks back.",
474 output::clean_path(&previous)
475 ),
476 Err(e) => anyhow::bail!("Could not run `git config --global core.hooksPath`: {e}"),
477 }
478 }
479 }
480
481 match global_hooks_path() {
482 Some(current) if Path::new(¤t) != dir => {
483 output::print_info(&format!(
484 "`core.hooksPath` is set to `{current}`, which is not dev-prune's — leaving it alone."
485 ));
486 return Ok(());
487 }
488 None => {
489 output::print_info("`core.hooksPath` is not set globally — nothing to remove.");
490 return Ok(());
491 }
492 Some(_) => {}
493 }
494
495 let unset = Command::new("git")
499 .args(["config", "--global", "--unset", "core.hooksPath"])
500 .status();
501 match unset {
502 Ok(status) if status.success() => {
503 output::print_success(
504 "Removed global Git hook configuration (`git config --global --unset core.hooksPath`).",
505 );
506 Ok(())
507 }
508 Ok(status) => anyhow::bail!(
509 "`git config --global --unset core.hooksPath` exited with {status}. \
510 Run it by hand to finish removing the hooks."
511 ),
512 Err(e) => anyhow::bail!("Could not run `git config --global --unset core.hooksPath`: {e}"),
513 }
514}
515
516pub fn run_status() -> Result<()> {
518 let dir = hooks_dir()?;
519
520 if !git_available() {
521 output::print_header("dev-prune Git Hooks Status");
522 output::print_error(GIT_MISSING_HELP);
523 return Ok(());
524 }
525
526 let configured = global_hooks_path();
527 let on_disk = HOOKS.iter().all(|hook| dir.join(hook).exists());
528
529 let points_at_us = configured
533 .as_deref()
534 .is_some_and(|current| Path::new(current) == dir);
535
536 output::print_header("dev-prune Git Hooks Status");
537 println!(
538 " Configured core.hooksPath: {}",
539 configured.as_deref().unwrap_or("Not set")
540 );
541 println!(" DevPrune Hooks Directory: {}", dir.display());
542 println!(
543 " Hooks Installed on Disk: {}",
544 if on_disk {
545 format!("Yes ({})", HOOKS.join(", "))
546 } else {
547 "No".to_string()
548 }
549 );
550 if let Some(previous) = chain_target() {
551 println!(
552 " Chained To: {}",
553 output::clean_path(&previous)
554 );
555 let forwarded = hook_names_in(&previous);
556 println!(
557 " Forwarded Hooks: {}",
558 if forwarded.is_empty() {
559 "none".to_string()
560 } else {
561 forwarded.join(", ")
562 }
563 );
564 let drifted = chain_drift(&dir, &previous);
565 if !drifted.is_empty() {
566 println!();
567 output::print_warning(&format!(
568 "`{}` now has hooks the chain does not forward: {}.\n \
569 They are not running. Rebuild with `devp hook install --chain`.",
570 output::clean_path(&previous),
571 drifted.join(", ")
572 ));
573 }
574 }
575 println!();
576 match (points_at_us, on_disk) {
577 (true, true) => output::print_success("Global background auto-registration is ACTIVE."),
578 (true, false) => output::print_warning(
579 "`core.hooksPath` points here but the hook files are missing. \
580 Re-run `devp hook install`.",
581 ),
582 (false, true) => output::print_warning(
583 "Hook files exist but `core.hooksPath` points elsewhere — they never run. \
584 Re-run `devp hook install`, or delete the directory.",
585 ),
586 (false, false) => output::print_info(
587 "Global background hook is inactive. Run `devp hook install` to enable.",
588 ),
589 }
590
591 Ok(())
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597
598 #[test]
599 fn hook_script_single_quotes_the_executable_path() {
600 let script = build_hook_script("/usr/local/bin/dev-prune");
601 assert!(script.contains("('/usr/local/bin/dev-prune' link . --quiet"));
602 }
603
604 #[test]
605 fn hook_script_neutralises_shell_metacharacters_in_the_path() {
606 let script = build_hook_script(r"C:\Users\a$b\`whoami`\dev-prune.exe");
608 assert!(script.contains(r"('C:\Users\a$b\`whoami`\dev-prune.exe' link ."));
609 }
610
611 #[test]
612 fn hook_script_escapes_an_embedded_single_quote() {
613 let script = build_hook_script("/home/o'brien/dev-prune");
614 assert!(script.contains(r"('/home/o'\''brien/dev-prune' link ."));
615 }
616
617 #[test]
618 fn hook_script_starts_with_a_shebang_and_backgrounds_the_call() {
619 let script = build_hook_script("devp");
620 assert!(script.starts_with("#!/usr/bin/env sh\n"));
621 assert!(script.contains(">/dev/null 2>&1 &)"));
623 }
624
625 #[test]
626 fn a_hooks_path_git_reported_with_forward_slashes_is_still_ours() {
627 #[cfg(windows)]
633 assert_eq!(
634 Path::new("C:/Users/dev/AppData/Roaming/dev-prune/hooks"),
635 Path::new(r"C:\Users\dev\AppData\Roaming\dev-prune\hooks")
636 );
637
638 assert_ne!(
640 Path::new("/home/dev/.config/dev-prune/hooks"),
641 Path::new("/home/dev/.config/husky/hooks")
642 );
643 }
644
645 #[test]
646 fn a_chained_hook_execs_the_hook_it_displaced() {
647 let script = build_chained_hook_script(
648 "/usr/local/bin/dev-prune",
649 Path::new("/home/dev/.husky"),
650 "post-commit",
651 true,
652 );
653 assert!(script.contains("('/usr/local/bin/dev-prune' link . --quiet"));
654 assert!(script.contains(r#"exec "$next" "$@""#));
656 assert!(script.contains("post-commit'"));
657 assert!(script.trim_end().ends_with("exit 0"));
659 }
660
661 #[test]
662 fn the_binary_is_recoverable_from_a_plain_hook() {
663 let script = build_hook_script("/usr/local/bin/dev-prune");
664 assert_eq!(
665 parse_hook_exe(&script),
666 Some(PathBuf::from("/usr/local/bin/dev-prune"))
667 );
668 }
669
670 #[test]
671 fn the_binary_is_recoverable_from_a_chained_hook() {
672 let script = build_chained_hook_script(
673 "C:\\Users\\a\\AppData\\Roaming\\dev-prune\\bin\\dev-prune.exe",
674 Path::new("/home/dev/.husky"),
675 "post-commit",
676 true,
677 );
678 assert_eq!(
679 parse_hook_exe(&script),
680 Some(PathBuf::from(
681 "C:\\Users\\a\\AppData\\Roaming\\dev-prune\\bin\\dev-prune.exe"
682 ))
683 );
684 }
685
686 #[test]
687 fn a_quote_in_the_path_survives_the_round_trip() {
688 let script = build_hook_script("/home/o'brien/dev-prune");
691 assert_eq!(
692 parse_hook_exe(&script),
693 Some(PathBuf::from("/home/o'brien/dev-prune"))
694 );
695 }
696
697 #[test]
698 fn a_script_that_is_not_ours_answers_nothing() {
699 assert!(parse_hook_exe("#!/bin/sh\nnpm test\n").is_none());
700 }
701
702 #[test]
703 fn a_forwarded_hook_we_do_not_own_only_forwards() {
704 let script = build_chained_hook_script(
705 "/usr/local/bin/dev-prune",
706 Path::new("/home/dev/.husky"),
707 "pre-commit",
708 false,
709 );
710 assert!(!script.contains("link ."));
713 assert!(script.contains(r#"exec "$next" "$@""#));
714 }
715
716 #[test]
717 fn chaining_never_shims_a_file_that_is_not_a_git_hook() {
718 let tmp = tempfile::TempDir::new().unwrap();
719 fs::write(tmp.path().join("pre-commit"), "#!/bin/sh\nnpm test\n").unwrap();
721 fs::write(tmp.path().join("commit-msg"), "#!/bin/sh\ncommitlint\n").unwrap();
722 fs::write(tmp.path().join(".gitignore"), "_\n").unwrap();
723 fs::write(tmp.path().join("README.md"), "hooks\n").unwrap();
724 fs::create_dir(tmp.path().join("_")).unwrap();
725
726 let found = hook_names_in(tmp.path());
727 assert_eq!(
728 found,
729 vec!["pre-commit".to_string(), "commit-msg".to_string()]
730 );
731 }
732
733 #[test]
734 fn drift_is_a_hook_the_other_tool_added_after_the_chain_was_built() {
735 let ours = tempfile::TempDir::new().unwrap();
736 let theirs = tempfile::TempDir::new().unwrap();
737 fs::write(theirs.path().join("pre-commit"), "x").unwrap();
738 fs::write(theirs.path().join("pre-push"), "x").unwrap();
739 fs::write(ours.path().join("pre-commit"), "shim").unwrap();
740
741 assert_eq!(
742 chain_drift(ours.path(), theirs.path()),
743 vec!["pre-push".to_string()]
744 );
745 }
746
747 #[test]
748 fn every_installed_hook_runs_after_the_operation_it_follows() {
749 assert!(HOOKS.iter().all(|hook| hook.starts_with("post-")));
751 assert_eq!(HOOKS.len(), 3);
752 }
753}