use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use crate::config::Registry;
use crate::output;
const HOOKS: [&str; 3] = ["post-commit", "post-checkout", "post-merge"];
const GIT_HOOK_NAMES: [&str; 28] = [
"applypatch-msg",
"pre-applypatch",
"post-applypatch",
"pre-commit",
"pre-merge-commit",
"prepare-commit-msg",
"commit-msg",
"post-commit",
"pre-rebase",
"post-checkout",
"post-merge",
"pre-push",
"pre-receive",
"update",
"proc-receive",
"post-receive",
"post-update",
"reference-transaction",
"push-to-checkout",
"pre-auto-gc",
"post-rewrite",
"sendemail-validate",
"fsmonitor-watchman",
"p4-changelist",
"p4-prepare-changelist",
"p4-post-changelist",
"p4-pre-submit",
"post-index-change",
];
const NO_SHADOW_SHIM: [&str; 2] = ["reference-transaction", "post-index-change"];
fn shadowing_hooks() -> Vec<&'static str> {
GIT_HOOK_NAMES
.iter()
.copied()
.filter(|name| !NO_SHADOW_SHIM.contains(name))
.collect()
}
const CHAIN_MARKER: &str = ".chain-target";
pub fn hooks_dir() -> Result<PathBuf> {
let base = Registry::config_dir()?;
Ok(base.join("hooks"))
}
pub fn chain_target() -> Option<PathBuf> {
let marker = hooks_dir().ok()?.join(CHAIN_MARKER);
let raw = fs::read_to_string(marker).ok()?;
let trimmed = raw.trim();
(!trimmed.is_empty()).then(|| PathBuf::from(trimmed))
}
pub const GIT_MISSING_HELP: &str = "`git` was not found on your PATH.\n\
dev-prune identifies repositories with Git and installs its hooks through \
`git config --global`, so it can do neither without it.\n\
Install Git from https://git-scm.com/downloads (or your package manager), confirm \
that `git --version` works in a new terminal, then run `devp setup` again.";
pub fn git_available() -> bool {
crate::spawn::command("git")
.arg("--version")
.output()
.map(|out| out.status.success())
.unwrap_or(false)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookState {
Active,
Absent,
Foreign(String),
Chained {
previous: String,
drifted: Vec<String>,
},
}
pub fn state() -> Result<HookState> {
let dir = hooks_dir()?;
match global_hooks_path() {
Some(existing) if Path::new(&existing) != dir => Ok(HookState::Foreign(existing)),
Some(_) if HOOKS.iter().all(|hook| dir.join(hook).exists()) => match chain_target() {
Some(previous) => Ok(HookState::Chained {
drifted: chain_drift(&dir, &previous),
previous: output::clean_path(&previous),
}),
None => Ok(HookState::Active),
},
_ => Ok(HookState::Absent),
}
}
fn chain_drift(ours: &Path, theirs: &Path) -> Vec<String> {
hook_names_in(theirs)
.into_iter()
.filter(|name| !ours.join(name).exists())
.collect()
}
fn hook_names_in(dir: &Path) -> Vec<String> {
let Ok(entries) = fs::read_dir(dir) else {
return Vec::new();
};
let present: Vec<String> = entries
.flatten()
.filter(|e| e.path().is_file())
.filter_map(|e| e.file_name().into_string().ok())
.collect();
GIT_HOOK_NAMES
.iter()
.filter(|name| present.iter().any(|p| p == *name))
.map(|name| name.to_string())
.collect()
}
pub fn shims_incomplete() -> bool {
let Ok(dir) = hooks_dir() else {
return false;
};
if chain_target().is_some() {
return false;
}
shims_missing_in(&dir)
}
fn shims_missing_in(dir: &Path) -> bool {
shadowing_hooks()
.into_iter()
.any(|name| !dir.join(name).exists())
}
fn global_hooks_path() -> Option<String> {
let out = crate::spawn::command("git")
.args(["config", "--global", "core.hooksPath"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!value.is_empty()).then_some(value)
}
fn build_hook_script(exe: &str, hook: &str, register: bool) -> String {
let registration = if register {
format!("('{}' link . --quiet >/dev/null 2>&1 &)\n", sq(exe))
} else {
String::new()
};
format!(
r#"#!/usr/bin/env sh
# dev-prune hook shim. Rebuild with `devp hook install`.
{registration}{}"#,
local_passthrough(hook)
)
}
fn local_passthrough(hook: &str) -> String {
format!(
r#"common=$(git rev-parse --git-common-dir 2>/dev/null) || common="${{GIT_DIR:-.git}}"
next="$common/hooks/{0}"
if [ -x "$next" ]; then exec "$next" "$@"; fi
if [ -f "$next" ]; then exec sh "$next" "$@"; fi
exit 0
"#,
hook
)
}
fn build_chained_hook_script(exe: &str, previous: &Path, hook: &str, register: bool) -> String {
let registration = if register {
format!("('{}' link . --quiet >/dev/null 2>&1 &)\n", sq(exe))
} else {
String::new()
};
let target = previous.join(hook);
format!(
r#"#!/usr/bin/env sh
# dev-prune hook shim — chained. Rebuild with `devp hook install --chain`.
{registration}next='{}'
if [ -x "$next" ]; then exec "$next" "$@"; fi
if [ -f "$next" ]; then exec sh "$next" "$@"; fi
exit 0
"#,
sq(&target.to_string_lossy())
)
}
fn sq(value: &str) -> String {
value.replace('\'', r"'\''")
}
fn parse_hook_exe(script: &str) -> Option<PathBuf> {
let start = script.find("('")? + 2;
let end = start + script[start..].find("' link . --quiet")?;
let exe = script[start..end].replace(r"'\''", "'");
(!exe.is_empty()).then(|| PathBuf::from(exe))
}
pub fn registered_exe_path() -> Option<PathBuf> {
let script = fs::read_to_string(hooks_dir().ok()?.join(HOOKS[0])).ok()?;
parse_hook_exe(&script)
}
pub fn run_install(chain: bool) -> Result<()> {
let dir = hooks_dir()?;
install_with(chain)?;
output::print_header("dev-prune Non-Blocking Git Hooks");
output::print_success(&format!(
"Installed global Git hooks in `{}`",
output::clean_path(&dir)
));
println!(" Hooks Active: {}", HOOKS.join(", "));
println!(" Execution Mode: Asynchronous / Non-blocking (0ms commit impact)");
match chain_target() {
Some(previous) => {
let forwarded = hook_names_in(&previous);
println!(" Chained To: {}", output::clean_path(&previous));
println!(
" Forwarded Hooks: {}",
if forwarded.is_empty() {
"none found (the directory is empty)".to_string()
} else {
forwarded.join(", ")
}
);
println!();
output::print_info("How to manage hook settings:");
println!(" Restore Previous: devp hook uninstall");
println!(" Rebuild The Chain: devp hook install --chain");
println!();
output::print_warning(
"The chain is a snapshot. If that tool adds a hook later, re-run \
`devp hook install --chain` — `devp hook status` reports the drift.",
);
}
None => {
println!();
output::print_info("How to manage hook settings:");
println!(" Disable Globally: devp hook uninstall");
println!(" Disable Per-Repo: git config core.hooksPath \"\" (inside project root)");
println!(" Re-enable Globally: devp hook install");
println!();
output::print_warning(
"While this is active, per-repo `.git/hooks` are ignored in every repository on \
this machine — that includes husky, pre-commit and lefthook.",
);
}
}
Ok(())
}
pub fn install() -> Result<()> {
install_with(false)
}
pub fn install_with(chain: bool) -> Result<()> {
let dir = hooks_dir()?;
if !git_available() {
anyhow::bail!("{GIT_MISSING_HELP}");
}
let previous = match global_hooks_path() {
Some(existing) if Path::new(&existing) != dir => {
if !chain {
anyhow::bail!(
"`core.hooksPath` is already set globally to `{existing}`.\n\
Git only supports one hooks directory, so installing here would disable \
those hooks in every repo on this machine.\n\
Run `devp hook install --chain` to install in front of it instead: \
dev-prune registers the repo, then hands every hook on to `{existing}`.\n\
Or unset it first:\n git config --global --unset core.hooksPath\n\
Or do nothing — `devp link .` in new repos does the same job by hand."
);
}
let path = PathBuf::from(&existing);
if path.is_relative() {
anyhow::bail!(
"`core.hooksPath` is set to the relative path `{existing}`, which Git \
resolves separately inside every repository. There is no one directory \
to chain to.\n\
Set it to an absolute path first, or leave it alone and use `devp link .`."
);
}
Some(path)
}
_ => chain.then(chain_target).flatten(),
};
fs::create_dir_all(&dir)
.with_context(|| format!("Failed to create hooks directory at {}", dir.display()))?;
let exe = crate::setup::stable_exe_path()
.to_string_lossy()
.into_owned();
match &previous {
None => {
let names = shadowing_hooks();
for hook in &names {
let content = build_hook_script(&exe, hook, HOOKS.contains(hook));
write_hook(&dir.join(hook), &content)?;
}
for stale in hook_names_in(&dir) {
if !names.contains(&stale.as_str()) {
let _ = fs::remove_file(dir.join(&stale));
}
}
let _ = fs::remove_file(dir.join(CHAIN_MARKER));
}
Some(prev) => {
let mut names: Vec<String> = HOOKS.iter().map(|h| h.to_string()).collect();
for name in hook_names_in(prev) {
if !names.contains(&name) {
names.push(name);
}
}
for stale in hook_names_in(&dir) {
if !names.contains(&stale) {
let _ = fs::remove_file(dir.join(&stale));
}
}
for name in &names {
let register = HOOKS.contains(&name.as_str());
let content = build_chained_hook_script(&exe, prev, name, register);
write_hook(&dir.join(name), &content)?;
}
fs::write(dir.join(CHAIN_MARKER), format!("{}\n", prev.display())).with_context(
|| "Failed to record the chained hooks path; refusing a chain we cannot undo",
)?;
}
}
let status = crate::spawn::command("git")
.args([
"config",
"--global",
"core.hooksPath",
&dir.to_string_lossy(),
])
.status()
.with_context(|| "Failed to execute `git config --global core.hooksPath`")?;
if !status.success() {
anyhow::bail!("Failed to update git global configuration.");
}
Ok(())
}
fn write_hook(path: &Path, content: &str) -> Result<()> {
fs::write(path, content).with_context(|| format!("Failed to write hook {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o755));
}
Ok(())
}
fn remove_hook_files(dir: &Path) {
let _ = fs::remove_file(dir.join(CHAIN_MARKER));
for name in hook_names_in(dir) {
let _ = fs::remove_file(dir.join(name));
}
}
pub fn run_uninstall() -> Result<()> {
let dir = hooks_dir()?;
if let Some(previous) = chain_target()
&& global_hooks_path().is_some_and(|c| Path::new(&c) == dir)
{
let restored = crate::spawn::command("git")
.args([
"config",
"--global",
"core.hooksPath",
&previous.to_string_lossy(),
])
.status();
match restored {
Ok(status) if status.success() => {
remove_hook_files(&dir);
output::print_success(&format!(
"Restored `core.hooksPath` to `{}`.",
output::clean_path(&previous)
));
return Ok(());
}
Ok(status) => anyhow::bail!(
"`git config --global core.hooksPath` exited with {status} while restoring \
`{}`. Set it by hand to bring those hooks back.",
output::clean_path(&previous)
),
Err(e) => anyhow::bail!("Could not run `git config --global core.hooksPath`: {e}"),
}
}
match global_hooks_path() {
Some(current) if Path::new(¤t) != dir => {
output::print_info(&format!(
"`core.hooksPath` is set to `{current}`, which is not dev-prune's — leaving it alone."
));
if !hook_names_in(&dir).is_empty() || dir.join(CHAIN_MARKER).exists() {
remove_hook_files(&dir);
output::print_info("Removed dev-prune's leftover hook scripts.");
}
return Ok(());
}
None => {
if !hook_names_in(&dir).is_empty() || dir.join(CHAIN_MARKER).exists() {
remove_hook_files(&dir);
output::print_success(
"`core.hooksPath` was not set globally; removed dev-prune's leftover \
hook scripts.",
);
} else {
output::print_info("`core.hooksPath` is not set globally — nothing to remove.");
}
return Ok(());
}
Some(_) => {}
}
let unset = crate::spawn::command("git")
.args(["config", "--global", "--unset", "core.hooksPath"])
.status();
match unset {
Ok(status) if status.success() => {
remove_hook_files(&dir);
output::print_success(
"Removed global Git hook configuration (`git config --global --unset core.hooksPath`).",
);
Ok(())
}
Ok(status) => anyhow::bail!(
"`git config --global --unset core.hooksPath` exited with {status}. \
Run it by hand to finish removing the hooks."
),
Err(e) => anyhow::bail!("Could not run `git config --global --unset core.hooksPath`: {e}"),
}
}
pub fn run_status() -> Result<()> {
let dir = hooks_dir()?;
if !git_available() {
output::print_header("dev-prune Git Hooks Status");
output::print_error(GIT_MISSING_HELP);
return Ok(());
}
let configured = global_hooks_path();
let on_disk = HOOKS.iter().all(|hook| dir.join(hook).exists());
let points_at_us = configured
.as_deref()
.is_some_and(|current| Path::new(current) == dir);
output::print_header("dev-prune Git Hooks Status");
println!(
" Configured core.hooksPath: {}",
configured.as_deref().unwrap_or("Not set")
);
println!(" DevPrune Hooks Directory: {}", dir.display());
println!(
" Hooks Installed on Disk: {}",
if on_disk {
format!("Yes ({})", HOOKS.join(", "))
} else {
"No".to_string()
}
);
if let Some(previous) = chain_target() {
println!(
" Chained To: {}",
output::clean_path(&previous)
);
let forwarded = hook_names_in(&previous);
println!(
" Forwarded Hooks: {}",
if forwarded.is_empty() {
"none".to_string()
} else {
forwarded.join(", ")
}
);
let drifted = chain_drift(&dir, &previous);
if !drifted.is_empty() {
println!();
output::print_warning(&format!(
"`{}` now has hooks the chain does not forward: {}.\n \
They are not running. Rebuild with `devp hook install --chain`.",
output::clean_path(&previous),
drifted.join(", ")
));
}
}
println!();
match (points_at_us, on_disk) {
(true, true) => output::print_success("Global background auto-registration is ACTIVE."),
(true, false) => output::print_warning(
"`core.hooksPath` points here but the hook files are missing. \
Re-run `devp hook install`.",
),
(false, true) => output::print_warning(
"Hook files exist but `core.hooksPath` points elsewhere — they never run. \
Re-run `devp hook install`, or delete the directory.",
),
(false, false) => output::print_info(
"Global background hook is inactive. Run `devp hook install` to enable.",
),
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hook_script_single_quotes_the_executable_path() {
let script = build_hook_script("/usr/local/bin/dev-prune", "post-commit", true);
assert!(script.contains("('/usr/local/bin/dev-prune' link . --quiet"));
}
#[test]
fn hook_script_neutralises_shell_metacharacters_in_the_path() {
let script = build_hook_script(r"C:\Users\a$b\`whoami`\dev-prune.exe", "post-commit", true);
assert!(script.contains(r"('C:\Users\a$b\`whoami`\dev-prune.exe' link ."));
}
#[test]
fn hook_script_escapes_an_embedded_single_quote() {
let script = build_hook_script("/home/o'brien/dev-prune", "post-commit", true);
assert!(script.contains(r"('/home/o'\''brien/dev-prune' link ."));
}
#[test]
fn hook_script_starts_with_a_shebang_and_backgrounds_the_call() {
let script = build_hook_script("devp", "post-commit", true);
assert!(script.starts_with("#!/usr/bin/env sh\n"));
assert!(script.contains(">/dev/null 2>&1 &)"));
}
#[test]
fn a_hooks_path_git_reported_with_forward_slashes_is_still_ours() {
#[cfg(windows)]
assert_eq!(
Path::new("C:/Users/dev/AppData/Roaming/dev-prune/hooks"),
Path::new(r"C:\Users\dev\AppData\Roaming\dev-prune\hooks")
);
assert_ne!(
Path::new("/home/dev/.config/dev-prune/hooks"),
Path::new("/home/dev/.config/husky/hooks")
);
}
#[test]
fn a_chained_hook_execs_the_hook_it_displaced() {
let script = build_chained_hook_script(
"/usr/local/bin/dev-prune",
Path::new("/home/dev/.husky"),
"post-commit",
true,
);
assert!(script.contains("('/usr/local/bin/dev-prune' link . --quiet"));
assert!(script.contains(r#"exec "$next" "$@""#));
assert!(script.contains("post-commit'"));
assert!(script.trim_end().ends_with("exit 0"));
}
#[test]
fn the_binary_is_recoverable_from_a_plain_hook() {
let script = build_hook_script("/usr/local/bin/dev-prune", "post-commit", true);
assert_eq!(
parse_hook_exe(&script),
Some(PathBuf::from("/usr/local/bin/dev-prune"))
);
}
#[test]
fn the_binary_is_recoverable_from_a_chained_hook() {
let script = build_chained_hook_script(
"C:\\Users\\a\\AppData\\Roaming\\dev-prune\\bin\\dev-prune.exe",
Path::new("/home/dev/.husky"),
"post-commit",
true,
);
assert_eq!(
parse_hook_exe(&script),
Some(PathBuf::from(
"C:\\Users\\a\\AppData\\Roaming\\dev-prune\\bin\\dev-prune.exe"
))
);
}
#[test]
fn a_quote_in_the_path_survives_the_round_trip() {
let script = build_hook_script("/home/o'brien/dev-prune", "post-commit", true);
assert_eq!(
parse_hook_exe(&script),
Some(PathBuf::from("/home/o'brien/dev-prune"))
);
}
#[test]
fn a_script_that_is_not_ours_answers_nothing() {
assert!(parse_hook_exe("#!/bin/sh\nnpm test\n").is_none());
}
#[test]
fn a_forwarded_hook_we_do_not_own_only_forwards() {
let script = build_chained_hook_script(
"/usr/local/bin/dev-prune",
Path::new("/home/dev/.husky"),
"pre-commit",
false,
);
assert!(!script.contains("link ."));
assert!(script.contains(r#"exec "$next" "$@""#));
}
#[test]
fn chaining_never_shims_a_file_that_is_not_a_git_hook() {
let tmp = tempfile::TempDir::new().unwrap();
fs::write(tmp.path().join("pre-commit"), "#!/bin/sh\nnpm test\n").unwrap();
fs::write(tmp.path().join("commit-msg"), "#!/bin/sh\ncommitlint\n").unwrap();
fs::write(tmp.path().join(".gitignore"), "_\n").unwrap();
fs::write(tmp.path().join("README.md"), "hooks\n").unwrap();
fs::create_dir(tmp.path().join("_")).unwrap();
let found = hook_names_in(tmp.path());
assert_eq!(
found,
vec!["pre-commit".to_string(), "commit-msg".to_string()]
);
}
#[test]
fn drift_is_a_hook_the_other_tool_added_after_the_chain_was_built() {
let ours = tempfile::TempDir::new().unwrap();
let theirs = tempfile::TempDir::new().unwrap();
fs::write(theirs.path().join("pre-commit"), "x").unwrap();
fs::write(theirs.path().join("pre-push"), "x").unwrap();
fs::write(ours.path().join("pre-commit"), "shim").unwrap();
assert_eq!(
chain_drift(ours.path(), theirs.path()),
vec!["pre-push".to_string()]
);
}
#[test]
fn every_installed_hook_runs_after_the_operation_it_follows() {
assert!(HOOKS.iter().all(|hook| hook.starts_with("post-")));
assert_eq!(HOOKS.len(), 3);
}
#[test]
fn every_shim_hands_control_back_to_the_repositorys_own_hook() {
for hook in shadowing_hooks() {
let script = build_hook_script("/usr/local/bin/dev-prune", hook, false);
assert!(
script.contains(&format!("hooks/{hook}")),
"{hook} does not forward to the repository's own hook"
);
assert!(script.contains("exec"), "{hook} must exec, not call");
assert!(
!script.contains("--git-path"),
"{hook} must not resolve its target through core.hooksPath"
);
assert!(
script.trim_end().ends_with("exit 0"),
"{hook} must succeed when the repository has no hook of that name"
);
}
}
#[test]
fn only_the_three_registration_hooks_register() {
let registering = build_hook_script("/bin/devp", "post-commit", true);
assert!(registering.contains("link . --quiet"));
let passthrough = build_hook_script("/bin/devp", "pre-push", false);
assert!(!passthrough.contains("link . --quiet"));
}
#[test]
fn the_high_frequency_hooks_are_left_alone() {
let names = shadowing_hooks();
assert!(!names.contains(&"reference-transaction"));
assert!(!names.contains(&"post-index-change"));
for expected in ["pre-commit", "commit-msg", "pre-push", "prepare-commit-msg"] {
assert!(names.contains(&expected), "{expected} must be shimmed");
}
}
#[test]
fn a_pre_1_4_0_hook_set_is_recognised_as_incomplete() {
let tmp = tempfile::tempdir().unwrap();
for name in HOOKS {
std::fs::write(
tmp.path().join(name),
"#!/bin/sh
",
)
.unwrap();
}
assert!(
shims_missing_in(tmp.path()),
"a three-file hooks directory must be reported as needing repair"
);
}
#[test]
fn a_full_shim_set_needs_no_repair() {
let tmp = tempfile::tempdir().unwrap();
for name in shadowing_hooks() {
std::fs::write(
tmp.path().join(name),
"#!/bin/sh
",
)
.unwrap();
}
assert!(!shims_missing_in(tmp.path()));
std::fs::remove_file(tmp.path().join("pre-commit")).unwrap();
assert!(shims_missing_in(tmp.path()));
}
}