Skip to main content

dev_prune/commands/
hook.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for `dev-prune hook` subcommands.
5//
6// Manages non-blocking global Git hooks (`post-commit`, `post-checkout`, `post-merge`)
7// to automatically track Git repositories as you clone them and work in them.
8//
9// `git init` is the one arrival these cannot see: Git runs no hook for it, and none of
10// the three fires until the new repository's first commit. That gap is closed from the
11// other side, in `commands::link::adopt_enclosing_repo`.
12
13use anyhow::{Context, Result};
14use std::fs;
15use std::path::{Path, PathBuf};
16
17use crate::config::Registry;
18use crate::output;
19
20/// The hooks dev-prune installs. All three fire *after* the operation completes, so
21/// none of them can fail a commit, and each is a moment a repository can first appear
22/// on disk or first be worked in: `post-checkout` also runs after `git clone`.
23///
24/// What they cannot cover is `git init`. Git has no `post-init` hook to install, and a
25/// repository created here fires none of these until its first commit — so between
26/// `git init` and that commit it is genuinely invisible here. Widening the set does not
27/// help: the only name that fires in that window is `post-index-change`, which also
28/// fires on every routine index refresh (see [`NO_SHADOW_SHIM`]). The window is closed
29/// in `commands::link::adopt_enclosing_repo` instead, where reading the registry costs
30/// nothing extra.
31const HOOKS: [&str; 3] = ["post-commit", "post-checkout", "post-merge"];
32
33/// Every hook name Git will look for inside `core.hooksPath`.
34///
35/// Two uses. It decides which files in *another* tool's hooks directory are hooks worth
36/// forwarding — without it a chained install would happily shim husky's `_/` helper
37/// directory, its `.gitignore` and its README. And it is the set of shims an unchained
38/// install writes, so that setting `core.hooksPath` does not silently disable every
39/// repository's own `.git/hooks`; see [`SHADOWING_HOOKS`].
40const GIT_HOOK_NAMES: [&str; 28] = [
41    "applypatch-msg",
42    "pre-applypatch",
43    "post-applypatch",
44    "pre-commit",
45    "pre-merge-commit",
46    "prepare-commit-msg",
47    "commit-msg",
48    "post-commit",
49    "pre-rebase",
50    "post-checkout",
51    "post-merge",
52    "pre-push",
53    "pre-receive",
54    "update",
55    "proc-receive",
56    "post-receive",
57    "post-update",
58    "reference-transaction",
59    "push-to-checkout",
60    "pre-auto-gc",
61    "post-rewrite",
62    "sendemail-validate",
63    "fsmonitor-watchman",
64    "p4-changelist",
65    "p4-prepare-changelist",
66    "p4-post-changelist",
67    "p4-pre-submit",
68    "post-index-change",
69];
70
71/// Hook names an unchained install does *not* shim, despite Git looking for them there.
72///
73/// Setting `core.hooksPath` makes Git ignore `.git/hooks` in every repository on the
74/// machine, so dev-prune writes a passthrough for each name to put that behaviour back —
75/// but not for these. `reference-transaction` fires once per ref per transaction, which
76/// on a fetch of a busy remote is hundreds of times; `post-index-change` fires on
77/// routine index refreshes. Neither is a hook anyone reaches for in a working
78/// repository, and spawning a shell that many times to discover there is nothing to run
79/// is a cost users would feel and never attribute to this.
80const NO_SHADOW_SHIM: [&str; 2] = ["reference-transaction", "post-index-change"];
81
82/// The names an unchained install writes: everything Git looks for, minus the two above.
83fn shadowing_hooks() -> Vec<&'static str> {
84    GIT_HOOK_NAMES
85        .iter()
86        .copied()
87        .filter(|name| !NO_SHADOW_SHIM.contains(name))
88        .collect()
89}
90
91/// Marker file recording the `core.hooksPath` a chained install displaced.
92///
93/// It lives in the hooks directory rather than in the registry because it is state, not
94/// preference: it describes what is currently installed on this machine, and it has to
95/// stay next to the shims that depend on it. Git never runs it — it is not a hook name.
96const CHAIN_MARKER: &str = ".chain-target";
97
98/// Return path to the dev-prune hooks directory (`~/.config/dev-prune/hooks/`).
99pub fn hooks_dir() -> Result<PathBuf> {
100    let base = Registry::config_dir()?;
101    Ok(base.join("hooks"))
102}
103
104/// The hooks directory a chained install is forwarding to, if we are chaining.
105pub fn chain_target() -> Option<PathBuf> {
106    let marker = hooks_dir().ok()?.join(CHAIN_MARKER);
107    let raw = fs::read_to_string(marker).ok()?;
108    let trimmed = raw.trim();
109    (!trimmed.is_empty()).then(|| PathBuf::from(trimmed))
110}
111
112/// Advice printed when `git` cannot be found.
113pub const GIT_MISSING_HELP: &str = "`git` was not found on your PATH.\n\
114     dev-prune identifies repositories with Git and installs its hooks through \
115     `git config --global`, so it can do neither without it.\n\
116     Install Git from https://git-scm.com/downloads (or your package manager), confirm \
117     that `git --version` works in a new terminal, then run `devp setup` again.";
118
119/// Whether a usable `git` is on PATH.
120///
121/// Everything dev-prune does is scoped to Git repositories, so this is not a degraded
122/// mode to work around — it is a stop, with an instruction attached.
123pub fn git_available() -> bool {
124    crate::spawn::command("git")
125        .arg("--version")
126        .output()
127        .map(|out| out.status.success())
128        .unwrap_or(false)
129}
130
131/// Where the global hook installation currently stands.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum HookState {
134    /// `core.hooksPath` points at our directory and every hook file is present.
135    Active,
136    /// Nothing of ours is installed and the single global slot is free.
137    Absent,
138    /// `core.hooksPath` belongs to another tool; taking it would disable that tool
139    /// in every repository on the machine.
140    Foreign(String),
141    /// Ours, forwarding every hook on to the directory it displaced.
142    Chained {
143        /// Where the shims hand off to.
144        previous: String,
145        /// Hook names present over there with no shim here, so currently not running.
146        /// Non-empty means the other tool added a hook after the chain was built.
147        drifted: Vec<String>,
148    },
149}
150
151/// Classify the current global hook installation.
152pub fn state() -> Result<HookState> {
153    let dir = hooks_dir()?;
154    match global_hooks_path() {
155        Some(existing) if Path::new(&existing) != dir => Ok(HookState::Foreign(existing)),
156        Some(_) if HOOKS.iter().all(|hook| dir.join(hook).exists()) => match chain_target() {
157            Some(previous) => Ok(HookState::Chained {
158                drifted: chain_drift(&dir, &previous),
159                previous: output::clean_path(&previous),
160            }),
161            None => Ok(HookState::Active),
162        },
163        // Pointed here with files missing, or not pointed anywhere: either way the fix
164        // is the same install, so both are "absent".
165        _ => Ok(HookState::Absent),
166    }
167}
168
169/// Hook names the chained-to directory has that we do not shim.
170///
171/// A chain is built from a snapshot of the other tool's directory, and that tool can add
172/// a hook afterwards — husky's whole workflow is `husky add`. Without this check the new
173/// hook would simply stop running, with nothing anywhere saying so.
174fn chain_drift(ours: &Path, theirs: &Path) -> Vec<String> {
175    hook_names_in(theirs)
176        .into_iter()
177        .filter(|name| !ours.join(name).exists())
178        .collect()
179}
180
181/// The Git hook files present in a directory, in a stable order.
182fn hook_names_in(dir: &Path) -> Vec<String> {
183    let Ok(entries) = fs::read_dir(dir) else {
184        return Vec::new();
185    };
186    let present: Vec<String> = entries
187        .flatten()
188        .filter(|e| e.path().is_file())
189        .filter_map(|e| e.file_name().into_string().ok())
190        .collect();
191    GIT_HOOK_NAMES
192        .iter()
193        .filter(|name| present.iter().any(|p| p == *name))
194        .map(|name| name.to_string())
195        .collect()
196}
197
198/// Whether an unchained install is missing shims it should have.
199///
200/// Every install before 1.4.0 wrote three files and left `core.hooksPath` shadowing each
201/// repository's own `.git/hooks`. Upgrading has to repair that, and the upgrade path only
202/// reinstalls when something tells it to — [`state`] reports such an install as perfectly
203/// `Active`, because as far as registration goes it is.
204///
205/// Only meaningful for an unchained install: a chained one writes exactly the names the
206/// other tool has, which is a different and correct set. `chain_drift` covers that case.
207pub fn shims_incomplete() -> bool {
208    let Ok(dir) = hooks_dir() else {
209        return false;
210    };
211    if chain_target().is_some() {
212        return false;
213    }
214    shims_missing_in(&dir)
215}
216
217/// The directory-inspecting half of [`shims_incomplete`], split out so the rule can be
218/// tested against a scratch directory rather than the machine's real config directory.
219fn shims_missing_in(dir: &Path) -> bool {
220    shadowing_hooks()
221        .into_iter()
222        .any(|name| !dir.join(name).exists())
223}
224
225/// Read the current global `core.hooksPath`, if any.
226fn global_hooks_path() -> Option<String> {
227    let out = crate::spawn::command("git")
228        .args(["config", "--global", "core.hooksPath"])
229        .output()
230        .ok()?;
231    if !out.status.success() {
232        return None;
233    }
234    let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
235    (!value.is_empty()).then_some(value)
236}
237
238/// Build the hook script that registers the current repository in the background.
239///
240/// The executable path is single-quoted, not double-quoted: inside double quotes `sh`
241/// still expands `$` and backticks, and both are legal in a Windows install path
242/// (`C:\Users\a$b\...`), where the hook runs under Git for Windows' bundled `sh`.
243/// Single quotes are literal all the way through, so only an embedded single quote
244/// needs handling — done the POSIX way, by closing and reopening the string.
245fn build_hook_script(exe: &str, hook: &str, register: bool) -> String {
246    let registration = if register {
247        format!("('{}' link . --quiet >/dev/null 2>&1 &)\n", sq(exe))
248    } else {
249        String::new()
250    };
251    format!(
252        r#"#!/usr/bin/env sh
253# dev-prune hook shim. Rebuild with `devp hook install`.
254{registration}{}"#,
255        local_passthrough(hook)
256    )
257}
258
259/// The tail every unchained shim ends with: run this repository's own hook, if it has
260/// one, exactly as Git would have.
261///
262/// `core.hooksPath` replaces `.git/hooks` rather than adding to it, so without this a
263/// machine-wide install silently stops every repo-local `pre-commit`, `commit-msg` and
264/// `pre-push` on the machine — lint gates, secret scanners, conventional-commit checks —
265/// and nothing reports it. That is not a trade dev-prune gets to make on someone's
266/// behalf for the sake of registering a workspace.
267///
268/// `--git-common-dir` and not `--git-path hooks/<name>`, which looks like the obvious
269/// call and is a trap: `--git-path` resolves hook paths *through* `core.hooksPath`, so it
270/// hands back this very shim and the script execs itself until the machine gives up. It
271/// is also not simply `$GIT_DIR/hooks`, because in a linked worktree `$GIT_DIR` is
272/// `.git/worktrees/<name>` while the hooks live in the common directory. The `$GIT_DIR`
273/// arm is the fallback for the case where `git` is somehow not on the hook's own PATH.
274///
275/// `exec`, so the real hook inherits stdin — `pre-push` reads its refs from it — and its
276/// exit code is the hook's exit code, with no wrapper left to swallow a rejection.
277fn local_passthrough(hook: &str) -> String {
278    format!(
279        r#"common=$(git rev-parse --git-common-dir 2>/dev/null) || common="${{GIT_DIR:-.git}}"
280next="$common/hooks/{0}"
281if [ -x "$next" ]; then exec "$next" "$@"; fi
282if [ -f "$next" ]; then exec sh "$next" "$@"; fi
283exit 0
284"#,
285        hook
286    )
287}
288
289/// Build a hook that forwards to the same hook in the directory dev-prune displaced.
290///
291/// `register` adds the background registration on top; the other names are pure
292/// passthrough, present only so the other tool keeps working.
293///
294/// `exec` rather than a call: it replaces this process, so the real hook inherits stdin
295/// (which `pre-push` and `pre-receive` read their refs from) and its exit code is the
296/// hook's exit code, with no chance of a wrapper swallowing a rejection.
297fn build_chained_hook_script(exe: &str, previous: &Path, hook: &str, register: bool) -> String {
298    let registration = if register {
299        format!("('{}' link . --quiet >/dev/null 2>&1 &)\n", sq(exe))
300    } else {
301        String::new()
302    };
303    let target = previous.join(hook);
304    format!(
305        r#"#!/usr/bin/env sh
306# dev-prune hook shim — chained. Rebuild with `devp hook install --chain`.
307{registration}next='{}'
308if [ -x "$next" ]; then exec "$next" "$@"; fi
309if [ -f "$next" ]; then exec sh "$next" "$@"; fi
310exit 0
311"#,
312        sq(&target.to_string_lossy())
313    )
314}
315
316/// Escape a value for a POSIX single-quoted string, the only quoting `sh` does not
317/// expand: `$`, backticks and backslashes are all legal in a Windows install path.
318fn sq(value: &str) -> String {
319    value.replace('\'', r"'\''")
320}
321
322/// Recover the binary path out of a hook script this module wrote.
323///
324/// Both templates start the registration line with `('<exe>' link . --quiet`, and the
325/// path is single-quoted, so the closing quote is the one immediately before ` link`.
326/// Searching for that rather than the first `'` is what keeps a path containing an
327/// escaped quote intact.
328fn parse_hook_exe(script: &str) -> Option<PathBuf> {
329    let start = script.find("('")? + 2;
330    let end = start + script[start..].find("' link . --quiet")?;
331    let exe = script[start..end].replace(r"'\''", "'");
332    (!exe.is_empty()).then(|| PathBuf::from(exe))
333}
334
335/// The binary the installed hooks will actually run, if there are hooks to read.
336///
337/// `None` means "could not determine", not "not installed" — [`state`] answers that.
338/// A hook is deliberately silent (it backgrounds itself and discards its output), so a
339/// script left pointing at a deleted directory never reports anything; this is what lets
340/// `devp doctor` say so.
341pub fn registered_exe_path() -> Option<PathBuf> {
342    let script = fs::read_to_string(hooks_dir().ok()?.join(HOOKS[0])).ok()?;
343    parse_hook_exe(&script)
344}
345
346/// Install the hooks, printing the result and the caveats.
347pub fn run_install(chain: bool) -> Result<()> {
348    let dir = hooks_dir()?;
349    install_with(chain)?;
350
351    output::print_header("dev-prune Non-Blocking Git Hooks");
352    output::print_success(&format!(
353        "Installed global Git hooks in `{}`",
354        output::clean_path(&dir)
355    ));
356    println!("  Hooks Active:        {}", HOOKS.join(", "));
357    println!("  Execution Mode:      Asynchronous / Non-blocking (0ms commit impact)");
358
359    match chain_target() {
360        Some(previous) => {
361            let forwarded = hook_names_in(&previous);
362            println!("  Chained To:          {}", output::clean_path(&previous));
363            println!(
364                "  Forwarded Hooks:     {}",
365                if forwarded.is_empty() {
366                    "none found (the directory is empty)".to_string()
367                } else {
368                    forwarded.join(", ")
369                }
370            );
371            println!();
372            output::print_info("How to manage hook settings:");
373            println!("  Restore Previous:    devp hook uninstall");
374            println!("  Rebuild The Chain:   devp hook install --chain");
375            println!();
376            output::print_warning(
377                "The chain is a snapshot. If that tool adds a hook later, re-run \
378                 `devp hook install --chain` — `devp hook status` reports the drift.",
379            );
380        }
381        None => {
382            println!();
383            output::print_info("How to manage hook settings:");
384            println!("  Disable Globally:    devp hook uninstall");
385            println!("  Disable Per-Repo:    git config core.hooksPath \"\" (inside project root)");
386            println!("  Re-enable Globally:  devp hook install");
387            println!();
388            output::print_warning(
389                "While this is active, per-repo `.git/hooks` are ignored in every repository on \
390                 this machine — that includes husky, pre-commit and lefthook.",
391            );
392        }
393    }
394
395    Ok(())
396}
397
398/// Install non-blocking global Git hooks (`post-commit`, `post-checkout`, `post-merge`).
399///
400/// Silent, so the automatic setup pass can call it and report in its own format.
401pub fn install() -> Result<()> {
402    install_with(false)
403}
404
405/// Install the hooks, optionally forwarding to the hooks directory already configured.
406///
407/// `chain` is the answer to the single-slot problem. Git has one `core.hooksPath` and no
408/// way to list two, but nothing says the directory in that slot has to hold the *final*
409/// hooks: ours can register the repository and then `exec` the hook of the same name from
410/// the directory it displaced. The other tool keeps working, we get our registration, and
411/// `devp hook uninstall` puts the original value back.
412pub fn install_with(chain: bool) -> Result<()> {
413    let dir = hooks_dir()?;
414
415    // Git is not an optional dependency here: it is both what dev-prune detects
416    // repositories with and the mechanism that installs these hooks.
417    if !git_available() {
418        anyhow::bail!("{GIT_MISSING_HELP}");
419    }
420
421    // `core.hooksPath` is a single global slot. Overwriting someone else's value
422    // disables husky / pre-commit / lefthook in *every* repository on the machine.
423    // Refuse rather than break their setup — unless asked to chain, which preserves it.
424    let previous = match global_hooks_path() {
425        Some(existing) if Path::new(&existing) != dir => {
426            if !chain {
427                anyhow::bail!(
428                    "`core.hooksPath` is already set globally to `{existing}`.\n\
429                     Git only supports one hooks directory, so installing here would disable \
430                     those hooks in every repo on this machine.\n\
431                     Run `devp hook install --chain` to install in front of it instead: \
432                     dev-prune registers the repo, then hands every hook on to `{existing}`.\n\
433                     Or unset it first:\n    git config --global --unset core.hooksPath\n\
434                     Or do nothing — `devp link .` in new repos does the same job by hand."
435                );
436            }
437            let path = PathBuf::from(&existing);
438            // A relative `core.hooksPath` resolves against each repository's own root, so
439            // there is no single directory to forward to and the shim would point at
440            // whichever repo happened to be current when we installed.
441            if path.is_relative() {
442                anyhow::bail!(
443                    "`core.hooksPath` is set to the relative path `{existing}`, which Git \
444                     resolves separately inside every repository. There is no one directory \
445                     to chain to.\n\
446                     Set it to an absolute path first, or leave it alone and use `devp link .`."
447                );
448            }
449            Some(path)
450        }
451        // Already ours: keep whatever chain is in place, so a plain `devp hook install`
452        // re-run repairs the shims instead of silently unchaining the other tool.
453        _ => chain.then(chain_target).flatten(),
454    };
455
456    fs::create_dir_all(&dir)
457        .with_context(|| format!("Failed to create hooks directory at {}", dir.display()))?;
458
459    // Resolve the binary by absolute path. Git runs hooks with a minimal environment,
460    // and a bare `devp` is frequently not on the PATH it sees — which turns the hook
461    // into a silent no-op, since it discards its own output by design.
462    //
463    // A *durable* absolute path, not `current_exe()`: these scripts stay on disk long
464    // after the process that wrote them, so `npx dev-prune link .` must not bake in a
465    // path inside npm's cache. See `setup::stable_exe_path`.
466    let exe = crate::setup::stable_exe_path()
467        .to_string_lossy()
468        .into_owned();
469
470    match &previous {
471        None => {
472            let names = shadowing_hooks();
473            for hook in &names {
474                let content = build_hook_script(&exe, hook, HOOKS.contains(hook));
475                write_hook(&dir.join(hook), &content)?;
476            }
477            // Left over from a chained install, or from a Git version that had a name
478            // this one does not. Either way they are shims for hooks nothing runs.
479            for stale in hook_names_in(&dir) {
480                if !names.contains(&stale.as_str()) {
481                    let _ = fs::remove_file(dir.join(&stale));
482                }
483            }
484            // Any stale chain is gone now, and a marker left behind would make
485            // `devp hook uninstall` restore a path nothing forwards to any more.
486            let _ = fs::remove_file(dir.join(CHAIN_MARKER));
487        }
488        Some(prev) => {
489            // Ours first, then every hook the other tool actually has. A name it does not
490            // have gets no shim: `reference-transaction` fires several times per Git
491            // operation, and a shell that only exits 0 is not free.
492            let mut names: Vec<String> = HOOKS.iter().map(|h| h.to_string()).collect();
493            for name in hook_names_in(prev) {
494                if !names.contains(&name) {
495                    names.push(name);
496                }
497            }
498            // Drop shims for hooks the other tool has since removed, otherwise they
499            // linger as no-ops for hooks nobody installed.
500            for stale in hook_names_in(&dir) {
501                if !names.contains(&stale) {
502                    let _ = fs::remove_file(dir.join(&stale));
503                }
504            }
505            for name in &names {
506                let register = HOOKS.contains(&name.as_str());
507                let content = build_chained_hook_script(&exe, prev, name, register);
508                write_hook(&dir.join(name), &content)?;
509            }
510            fs::write(dir.join(CHAIN_MARKER), format!("{}\n", prev.display())).with_context(
511                || "Failed to record the chained hooks path; refusing a chain we cannot undo",
512            )?;
513        }
514    }
515
516    // Set global git config core.hooksPath
517    let status = crate::spawn::command("git")
518        .args([
519            "config",
520            "--global",
521            "core.hooksPath",
522            &dir.to_string_lossy(),
523        ])
524        .status()
525        .with_context(|| "Failed to execute `git config --global core.hooksPath`")?;
526
527    if !status.success() {
528        anyhow::bail!("Failed to update git global configuration.");
529    }
530
531    Ok(())
532}
533
534/// Write one hook file, executable on the platforms that care.
535fn write_hook(path: &Path, content: &str) -> Result<()> {
536    fs::write(path, content).with_context(|| format!("Failed to write hook {}", path.display()))?;
537    #[cfg(unix)]
538    {
539        use std::os::unix::fs::PermissionsExt;
540        let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o755));
541    }
542    Ok(())
543}
544
545/// Delete the hook scripts and chain marker from dev-prune's own hooks directory.
546///
547/// Called once `core.hooksPath` no longer points here. Left behind, the dead scripts
548/// make `devp hook status` warn "files exist but never run" forever, and a stale
549/// `.chain-target` would make the *next* uninstall "restore" a path nothing forwards
550/// to any more.
551fn remove_hook_files(dir: &Path) {
552    let _ = fs::remove_file(dir.join(CHAIN_MARKER));
553    for name in hook_names_in(dir) {
554        let _ = fs::remove_file(dir.join(name));
555    }
556}
557
558/// Uninstall non-blocking global Git hooks.
559pub fn run_uninstall() -> Result<()> {
560    // Only clear the setting if it still points at us. Blindly unsetting would delete
561    // a value the user set for something else entirely.
562    let dir = hooks_dir()?;
563
564    // A chained install borrowed the slot from another tool. Handing it back is the
565    // whole reason the chain was allowed in the first place — unsetting would leave
566    // that tool's hooks configured nowhere and silently dead.
567    if let Some(previous) = chain_target()
568        && global_hooks_path().is_some_and(|c| Path::new(&c) == dir)
569    {
570        let restored = crate::spawn::command("git")
571            .args([
572                "config",
573                "--global",
574                "core.hooksPath",
575                &previous.to_string_lossy(),
576            ])
577            .status();
578        match restored {
579            Ok(status) if status.success() => {
580                remove_hook_files(&dir);
581                output::print_success(&format!(
582                    "Restored `core.hooksPath` to `{}`.",
583                    output::clean_path(&previous)
584                ));
585                return Ok(());
586            }
587            Ok(status) => anyhow::bail!(
588                "`git config --global core.hooksPath` exited with {status} while restoring \
589                     `{}`. Set it by hand to bring those hooks back.",
590                output::clean_path(&previous)
591            ),
592            Err(e) => anyhow::bail!("Could not run `git config --global core.hooksPath`: {e}"),
593        }
594    }
595
596    match global_hooks_path() {
597        Some(current) if Path::new(&current) != dir => {
598            output::print_info(&format!(
599                "`core.hooksPath` is set to `{current}`, which is not dev-prune's — leaving it alone."
600            ));
601            // Our own directory can still hold dead scripts (and a stale chain marker)
602            // from an earlier install — the setting was changed out from under them.
603            if !hook_names_in(&dir).is_empty() || dir.join(CHAIN_MARKER).exists() {
604                remove_hook_files(&dir);
605                output::print_info("Removed dev-prune's leftover hook scripts.");
606            }
607            return Ok(());
608        }
609        None => {
610            if !hook_names_in(&dir).is_empty() || dir.join(CHAIN_MARKER).exists() {
611                remove_hook_files(&dir);
612                output::print_success(
613                    "`core.hooksPath` was not set globally; removed dev-prune's leftover \
614                     hook scripts.",
615                );
616            } else {
617                output::print_info("`core.hooksPath` is not set globally — nothing to remove.");
618            }
619            return Ok(());
620        }
621        Some(_) => {}
622    }
623
624    // Reported honestly: a failed unset leaves `core.hooksPath` pointing at a directory
625    // whose hook files may already be gone, which is the one state that silently breaks
626    // Git hooks machine-wide. Saying "removed" when it was not would hide exactly that.
627    let unset = crate::spawn::command("git")
628        .args(["config", "--global", "--unset", "core.hooksPath"])
629        .status();
630    match unset {
631        Ok(status) if status.success() => {
632            remove_hook_files(&dir);
633            output::print_success(
634                "Removed global Git hook configuration (`git config --global --unset core.hooksPath`).",
635            );
636            Ok(())
637        }
638        Ok(status) => anyhow::bail!(
639            "`git config --global --unset core.hooksPath` exited with {status}. \
640             Run it by hand to finish removing the hooks."
641        ),
642        Err(e) => anyhow::bail!("Could not run `git config --global --unset core.hooksPath`: {e}"),
643    }
644}
645
646/// Show status of global Git hooks.
647pub fn run_status() -> Result<()> {
648    let dir = hooks_dir()?;
649
650    if !git_available() {
651        output::print_header("dev-prune Git Hooks Status");
652        output::print_error(GIT_MISSING_HELP);
653        return Ok(());
654    }
655
656    let configured = global_hooks_path();
657    let on_disk = HOOKS.iter().all(|hook| dir.join(hook).exists());
658
659    // Both halves must hold. Hook files with `core.hooksPath` pointing elsewhere are
660    // dead files, and a `core.hooksPath` merely *containing* the string "dev-prune"
661    // could just as easily be some other tool living under a `dev-prune` directory.
662    let points_at_us = configured
663        .as_deref()
664        .is_some_and(|current| Path::new(current) == dir);
665
666    output::print_header("dev-prune Git Hooks Status");
667    println!(
668        "  Configured core.hooksPath: {}",
669        configured.as_deref().unwrap_or("Not set")
670    );
671    println!("  DevPrune Hooks Directory:  {}", dir.display());
672    println!(
673        "  Hooks Installed on Disk:   {}",
674        if on_disk {
675            format!("Yes ({})", HOOKS.join(", "))
676        } else {
677            "No".to_string()
678        }
679    );
680    if let Some(previous) = chain_target() {
681        println!(
682            "  Chained To:                {}",
683            output::clean_path(&previous)
684        );
685        let forwarded = hook_names_in(&previous);
686        println!(
687            "  Forwarded Hooks:           {}",
688            if forwarded.is_empty() {
689                "none".to_string()
690            } else {
691                forwarded.join(", ")
692            }
693        );
694        let drifted = chain_drift(&dir, &previous);
695        if !drifted.is_empty() {
696            println!();
697            output::print_warning(&format!(
698                "`{}` now has hooks the chain does not forward: {}.\n  \
699                 They are not running. Rebuild with `devp hook install --chain`.",
700                output::clean_path(&previous),
701                drifted.join(", ")
702            ));
703        }
704    }
705    println!();
706    match (points_at_us, on_disk) {
707        (true, true) => output::print_success("Global background auto-registration is ACTIVE."),
708        (true, false) => output::print_warning(
709            "`core.hooksPath` points here but the hook files are missing. \
710             Re-run `devp hook install`.",
711        ),
712        (false, true) => output::print_warning(
713            "Hook files exist but `core.hooksPath` points elsewhere — they never run. \
714             Re-run `devp hook install`, or delete the directory.",
715        ),
716        (false, false) => output::print_info(
717            "Global background hook is inactive. Run `devp hook install` to enable.",
718        ),
719    }
720
721    Ok(())
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727
728    #[test]
729    fn hook_script_single_quotes_the_executable_path() {
730        let script = build_hook_script("/usr/local/bin/dev-prune", "post-commit", true);
731        assert!(script.contains("('/usr/local/bin/dev-prune' link . --quiet"));
732    }
733
734    #[test]
735    fn hook_script_neutralises_shell_metacharacters_in_the_path() {
736        // All legal in a Windows path, and all live inside sh double quotes.
737        let script = build_hook_script(r"C:\Users\a$b\`whoami`\dev-prune.exe", "post-commit", true);
738        assert!(script.contains(r"('C:\Users\a$b\`whoami`\dev-prune.exe' link ."));
739    }
740
741    #[test]
742    fn hook_script_escapes_an_embedded_single_quote() {
743        let script = build_hook_script("/home/o'brien/dev-prune", "post-commit", true);
744        assert!(script.contains(r"('/home/o'\''brien/dev-prune' link ."));
745    }
746
747    #[test]
748    fn hook_script_starts_with_a_shebang_and_backgrounds_the_call() {
749        let script = build_hook_script("devp", "post-commit", true);
750        assert!(script.starts_with("#!/usr/bin/env sh\n"));
751        // Backgrounded in a subshell so a commit never waits on registration.
752        assert!(script.contains(">/dev/null 2>&1 &)"));
753    }
754
755    #[test]
756    fn a_hooks_path_git_reported_with_forward_slashes_is_still_ours() {
757        // `state()` and `run_uninstall()` both decide whether the global hooks directory
758        // belongs to dev-prune by comparing `Path`s, not strings. Git for Windows hands
759        // config values back with forward slashes, so a string compare would classify our
760        // own directory as Foreign — refusing to install, and refusing to clean up.
761        // `Path` compares by component, which is why this holds.
762        #[cfg(windows)]
763        assert_eq!(
764            Path::new("C:/Users/dev/AppData/Roaming/dev-prune/hooks"),
765            Path::new(r"C:\Users\dev\AppData\Roaming\dev-prune\hooks")
766        );
767
768        // And the negative case, on every platform: a different directory is Foreign.
769        assert_ne!(
770            Path::new("/home/dev/.config/dev-prune/hooks"),
771            Path::new("/home/dev/.config/husky/hooks")
772        );
773    }
774
775    #[test]
776    fn a_chained_hook_execs_the_hook_it_displaced() {
777        let script = build_chained_hook_script(
778            "/usr/local/bin/dev-prune",
779            Path::new("/home/dev/.husky"),
780            "post-commit",
781            true,
782        );
783        assert!(script.contains("('/usr/local/bin/dev-prune' link . --quiet"));
784        // `exec`, so the real hook keeps stdin and owns the exit code.
785        assert!(script.contains(r#"exec "$next" "$@""#));
786        assert!(script.contains("post-commit'"));
787        // A missing target is not a failure — the other tool simply has no such hook.
788        assert!(script.trim_end().ends_with("exit 0"));
789    }
790
791    #[test]
792    fn the_binary_is_recoverable_from_a_plain_hook() {
793        let script = build_hook_script("/usr/local/bin/dev-prune", "post-commit", true);
794        assert_eq!(
795            parse_hook_exe(&script),
796            Some(PathBuf::from("/usr/local/bin/dev-prune"))
797        );
798    }
799
800    #[test]
801    fn the_binary_is_recoverable_from_a_chained_hook() {
802        let script = build_chained_hook_script(
803            "C:\\Users\\a\\AppData\\Roaming\\dev-prune\\bin\\dev-prune.exe",
804            Path::new("/home/dev/.husky"),
805            "post-commit",
806            true,
807        );
808        assert_eq!(
809            parse_hook_exe(&script),
810            Some(PathBuf::from(
811                "C:\\Users\\a\\AppData\\Roaming\\dev-prune\\bin\\dev-prune.exe"
812            ))
813        );
814    }
815
816    #[test]
817    fn a_quote_in_the_path_survives_the_round_trip() {
818        // `sq` escapes it as `'\''`; splitting on the first quote instead of the one
819        // before ` link` would cut the path in half here.
820        let script = build_hook_script("/home/o'brien/dev-prune", "post-commit", true);
821        assert_eq!(
822            parse_hook_exe(&script),
823            Some(PathBuf::from("/home/o'brien/dev-prune"))
824        );
825    }
826
827    #[test]
828    fn a_script_that_is_not_ours_answers_nothing() {
829        assert!(parse_hook_exe("#!/bin/sh\nnpm test\n").is_none());
830    }
831
832    #[test]
833    fn a_forwarded_hook_we_do_not_own_only_forwards() {
834        let script = build_chained_hook_script(
835            "/usr/local/bin/dev-prune",
836            Path::new("/home/dev/.husky"),
837            "pre-commit",
838            false,
839        );
840        // Registering on `pre-commit` would put dev-prune in front of a hook that can
841        // reject the commit, for no benefit: `post-commit` already covers the repo.
842        assert!(!script.contains("link ."));
843        assert!(script.contains(r#"exec "$next" "$@""#));
844    }
845
846    #[test]
847    fn chaining_never_shims_a_file_that_is_not_a_git_hook() {
848        let tmp = tempfile::TempDir::new().unwrap();
849        // What a husky directory actually looks like.
850        fs::write(tmp.path().join("pre-commit"), "#!/bin/sh\nnpm test\n").unwrap();
851        fs::write(tmp.path().join("commit-msg"), "#!/bin/sh\ncommitlint\n").unwrap();
852        fs::write(tmp.path().join(".gitignore"), "_\n").unwrap();
853        fs::write(tmp.path().join("README.md"), "hooks\n").unwrap();
854        fs::create_dir(tmp.path().join("_")).unwrap();
855
856        let found = hook_names_in(tmp.path());
857        assert_eq!(
858            found,
859            vec!["pre-commit".to_string(), "commit-msg".to_string()]
860        );
861    }
862
863    #[test]
864    fn drift_is_a_hook_the_other_tool_added_after_the_chain_was_built() {
865        let ours = tempfile::TempDir::new().unwrap();
866        let theirs = tempfile::TempDir::new().unwrap();
867        fs::write(theirs.path().join("pre-commit"), "x").unwrap();
868        fs::write(theirs.path().join("pre-push"), "x").unwrap();
869        fs::write(ours.path().join("pre-commit"), "shim").unwrap();
870
871        assert_eq!(
872            chain_drift(ours.path(), theirs.path()),
873            vec!["pre-push".to_string()]
874        );
875    }
876
877    #[test]
878    fn every_installed_hook_runs_after_the_operation_it_follows() {
879        // A pre-* hook can abort a commit; none of ours may.
880        assert!(HOOKS.iter().all(|hook| hook.starts_with("post-")));
881        assert_eq!(HOOKS.len(), 3);
882    }
883
884    #[test]
885    fn every_shim_hands_control_back_to_the_repositorys_own_hook() {
886        // `core.hooksPath` replaces `.git/hooks` rather than adding to it. Without this
887        // line an install silently kills every repo-local pre-commit on the machine.
888        for hook in shadowing_hooks() {
889            let script = build_hook_script("/usr/local/bin/dev-prune", hook, false);
890            assert!(
891                script.contains(&format!("hooks/{hook}")),
892                "{hook} does not forward to the repository's own hook"
893            );
894            assert!(script.contains("exec"), "{hook} must exec, not call");
895            // `--git-path hooks/<name>` resolves through `core.hooksPath` and so points
896            // back at this shim: the script would exec itself forever.
897            assert!(
898                !script.contains("--git-path"),
899                "{hook} must not resolve its target through core.hooksPath"
900            );
901            assert!(
902                script.trim_end().ends_with("exit 0"),
903                "{hook} must succeed when the repository has no hook of that name"
904            );
905        }
906    }
907
908    #[test]
909    fn only_the_three_registration_hooks_register() {
910        // A shim for `pre-push` exists to stop dev-prune breaking someone's push, not to
911        // run `devp link` on every push.
912        let registering = build_hook_script("/bin/devp", "post-commit", true);
913        assert!(registering.contains("link . --quiet"));
914        let passthrough = build_hook_script("/bin/devp", "pre-push", false);
915        assert!(!passthrough.contains("link . --quiet"));
916    }
917
918    #[test]
919    fn the_high_frequency_hooks_are_left_alone() {
920        // `reference-transaction` fires once per ref per transaction. Shimming it means
921        // spawning a shell hundreds of times on a single fetch, to discover there is
922        // nothing to run.
923        let names = shadowing_hooks();
924        assert!(!names.contains(&"reference-transaction"));
925        assert!(!names.contains(&"post-index-change"));
926        // Everything anyone actually writes by hand is still covered.
927        for expected in ["pre-commit", "commit-msg", "pre-push", "prepare-commit-msg"] {
928            assert!(names.contains(&expected), "{expected} must be shimmed");
929        }
930    }
931
932    #[test]
933    fn a_pre_1_4_0_hook_set_is_recognised_as_incomplete() {
934        // Exactly what an install from 1.3.1 left behind: three registration hooks and
935        // nothing forwarding, which `state()` still reports as a healthy `Active`.
936        let tmp = tempfile::tempdir().unwrap();
937        for name in HOOKS {
938            std::fs::write(
939                tmp.path().join(name),
940                "#!/bin/sh
941",
942            )
943            .unwrap();
944        }
945        assert!(
946            shims_missing_in(tmp.path()),
947            "a three-file hooks directory must be reported as needing repair"
948        );
949    }
950
951    #[test]
952    fn a_full_shim_set_needs_no_repair() {
953        let tmp = tempfile::tempdir().unwrap();
954        for name in shadowing_hooks() {
955            std::fs::write(
956                tmp.path().join(name),
957                "#!/bin/sh
958",
959            )
960            .unwrap();
961        }
962        assert!(!shims_missing_in(tmp.path()));
963        // And one missing name is enough to bring it back.
964        std::fs::remove_file(tmp.path().join("pre-commit")).unwrap();
965        assert!(shims_missing_in(tmp.path()));
966    }
967}