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