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