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