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/// Delete the hook scripts and chain marker from dev-prune's own hooks directory.
444///
445/// Called once `core.hooksPath` no longer points here. Left behind, the dead scripts
446/// make `devp hook status` warn "files exist but never run" forever, and a stale
447/// `.chain-target` would make the *next* uninstall "restore" a path nothing forwards
448/// to any more.
449fn remove_hook_files(dir: &Path) {
450    let _ = fs::remove_file(dir.join(CHAIN_MARKER));
451    for name in hook_names_in(dir) {
452        let _ = fs::remove_file(dir.join(name));
453    }
454}
455
456/// Uninstall non-blocking global Git hooks.
457pub fn run_uninstall() -> Result<()> {
458    // Only clear the setting if it still points at us. Blindly unsetting would delete
459    // a value the user set for something else entirely.
460    let dir = hooks_dir()?;
461
462    // A chained install borrowed the slot from another tool. Handing it back is the
463    // whole reason the chain was allowed in the first place — unsetting would leave
464    // that tool's hooks configured nowhere and silently dead.
465    if let Some(previous) = chain_target() {
466        if global_hooks_path().is_some_and(|c| Path::new(&c) == dir) {
467            let restored = Command::new("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
494    match global_hooks_path() {
495        Some(current) if Path::new(&current) != dir => {
496            output::print_info(&format!(
497                "`core.hooksPath` is set to `{current}`, which is not dev-prune's — leaving it alone."
498            ));
499            // Our own directory can still hold dead scripts (and a stale chain marker)
500            // from an earlier install — the setting was changed out from under them.
501            if !hook_names_in(&dir).is_empty() || dir.join(CHAIN_MARKER).exists() {
502                remove_hook_files(&dir);
503                output::print_info("Removed dev-prune's leftover hook scripts.");
504            }
505            return Ok(());
506        }
507        None => {
508            if !hook_names_in(&dir).is_empty() || dir.join(CHAIN_MARKER).exists() {
509                remove_hook_files(&dir);
510                output::print_success(
511                    "`core.hooksPath` was not set globally; removed dev-prune's leftover \
512                     hook scripts.",
513                );
514            } else {
515                output::print_info("`core.hooksPath` is not set globally — nothing to remove.");
516            }
517            return Ok(());
518        }
519        Some(_) => {}
520    }
521
522    // Reported honestly: a failed unset leaves `core.hooksPath` pointing at a directory
523    // whose hook files may already be gone, which is the one state that silently breaks
524    // Git hooks machine-wide. Saying "removed" when it was not would hide exactly that.
525    let unset = Command::new("git")
526        .args(["config", "--global", "--unset", "core.hooksPath"])
527        .status();
528    match unset {
529        Ok(status) if status.success() => {
530            remove_hook_files(&dir);
531            output::print_success(
532                "Removed global Git hook configuration (`git config --global --unset core.hooksPath`).",
533            );
534            Ok(())
535        }
536        Ok(status) => anyhow::bail!(
537            "`git config --global --unset core.hooksPath` exited with {status}. \
538             Run it by hand to finish removing the hooks."
539        ),
540        Err(e) => anyhow::bail!("Could not run `git config --global --unset core.hooksPath`: {e}"),
541    }
542}
543
544/// Show status of global Git hooks.
545pub fn run_status() -> Result<()> {
546    let dir = hooks_dir()?;
547
548    if !git_available() {
549        output::print_header("dev-prune Git Hooks Status");
550        output::print_error(GIT_MISSING_HELP);
551        return Ok(());
552    }
553
554    let configured = global_hooks_path();
555    let on_disk = HOOKS.iter().all(|hook| dir.join(hook).exists());
556
557    // Both halves must hold. Hook files with `core.hooksPath` pointing elsewhere are
558    // dead files, and a `core.hooksPath` merely *containing* the string "dev-prune"
559    // could just as easily be some other tool living under a `dev-prune` directory.
560    let points_at_us = configured
561        .as_deref()
562        .is_some_and(|current| Path::new(current) == dir);
563
564    output::print_header("dev-prune Git Hooks Status");
565    println!(
566        "  Configured core.hooksPath: {}",
567        configured.as_deref().unwrap_or("Not set")
568    );
569    println!("  DevPrune Hooks Directory:  {}", dir.display());
570    println!(
571        "  Hooks Installed on Disk:   {}",
572        if on_disk {
573            format!("Yes ({})", HOOKS.join(", "))
574        } else {
575            "No".to_string()
576        }
577    );
578    if let Some(previous) = chain_target() {
579        println!(
580            "  Chained To:                {}",
581            output::clean_path(&previous)
582        );
583        let forwarded = hook_names_in(&previous);
584        println!(
585            "  Forwarded Hooks:           {}",
586            if forwarded.is_empty() {
587                "none".to_string()
588            } else {
589                forwarded.join(", ")
590            }
591        );
592        let drifted = chain_drift(&dir, &previous);
593        if !drifted.is_empty() {
594            println!();
595            output::print_warning(&format!(
596                "`{}` now has hooks the chain does not forward: {}.\n  \
597                 They are not running. Rebuild with `devp hook install --chain`.",
598                output::clean_path(&previous),
599                drifted.join(", ")
600            ));
601        }
602    }
603    println!();
604    match (points_at_us, on_disk) {
605        (true, true) => output::print_success("Global background auto-registration is ACTIVE."),
606        (true, false) => output::print_warning(
607            "`core.hooksPath` points here but the hook files are missing. \
608             Re-run `devp hook install`.",
609        ),
610        (false, true) => output::print_warning(
611            "Hook files exist but `core.hooksPath` points elsewhere — they never run. \
612             Re-run `devp hook install`, or delete the directory.",
613        ),
614        (false, false) => output::print_info(
615            "Global background hook is inactive. Run `devp hook install` to enable.",
616        ),
617    }
618
619    Ok(())
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    #[test]
627    fn hook_script_single_quotes_the_executable_path() {
628        let script = build_hook_script("/usr/local/bin/dev-prune");
629        assert!(script.contains("('/usr/local/bin/dev-prune' link . --quiet"));
630    }
631
632    #[test]
633    fn hook_script_neutralises_shell_metacharacters_in_the_path() {
634        // All legal in a Windows path, and all live inside sh double quotes.
635        let script = build_hook_script(r"C:\Users\a$b\`whoami`\dev-prune.exe");
636        assert!(script.contains(r"('C:\Users\a$b\`whoami`\dev-prune.exe' link ."));
637    }
638
639    #[test]
640    fn hook_script_escapes_an_embedded_single_quote() {
641        let script = build_hook_script("/home/o'brien/dev-prune");
642        assert!(script.contains(r"('/home/o'\''brien/dev-prune' link ."));
643    }
644
645    #[test]
646    fn hook_script_starts_with_a_shebang_and_backgrounds_the_call() {
647        let script = build_hook_script("devp");
648        assert!(script.starts_with("#!/usr/bin/env sh\n"));
649        // Backgrounded in a subshell so a commit never waits on registration.
650        assert!(script.contains(">/dev/null 2>&1 &)"));
651    }
652
653    #[test]
654    fn a_hooks_path_git_reported_with_forward_slashes_is_still_ours() {
655        // `state()` and `run_uninstall()` both decide whether the global hooks directory
656        // belongs to dev-prune by comparing `Path`s, not strings. Git for Windows hands
657        // config values back with forward slashes, so a string compare would classify our
658        // own directory as Foreign — refusing to install, and refusing to clean up.
659        // `Path` compares by component, which is why this holds.
660        #[cfg(windows)]
661        assert_eq!(
662            Path::new("C:/Users/dev/AppData/Roaming/dev-prune/hooks"),
663            Path::new(r"C:\Users\dev\AppData\Roaming\dev-prune\hooks")
664        );
665
666        // And the negative case, on every platform: a different directory is Foreign.
667        assert_ne!(
668            Path::new("/home/dev/.config/dev-prune/hooks"),
669            Path::new("/home/dev/.config/husky/hooks")
670        );
671    }
672
673    #[test]
674    fn a_chained_hook_execs_the_hook_it_displaced() {
675        let script = build_chained_hook_script(
676            "/usr/local/bin/dev-prune",
677            Path::new("/home/dev/.husky"),
678            "post-commit",
679            true,
680        );
681        assert!(script.contains("('/usr/local/bin/dev-prune' link . --quiet"));
682        // `exec`, so the real hook keeps stdin and owns the exit code.
683        assert!(script.contains(r#"exec "$next" "$@""#));
684        assert!(script.contains("post-commit'"));
685        // A missing target is not a failure — the other tool simply has no such hook.
686        assert!(script.trim_end().ends_with("exit 0"));
687    }
688
689    #[test]
690    fn the_binary_is_recoverable_from_a_plain_hook() {
691        let script = build_hook_script("/usr/local/bin/dev-prune");
692        assert_eq!(
693            parse_hook_exe(&script),
694            Some(PathBuf::from("/usr/local/bin/dev-prune"))
695        );
696    }
697
698    #[test]
699    fn the_binary_is_recoverable_from_a_chained_hook() {
700        let script = build_chained_hook_script(
701            "C:\\Users\\a\\AppData\\Roaming\\dev-prune\\bin\\dev-prune.exe",
702            Path::new("/home/dev/.husky"),
703            "post-commit",
704            true,
705        );
706        assert_eq!(
707            parse_hook_exe(&script),
708            Some(PathBuf::from(
709                "C:\\Users\\a\\AppData\\Roaming\\dev-prune\\bin\\dev-prune.exe"
710            ))
711        );
712    }
713
714    #[test]
715    fn a_quote_in_the_path_survives_the_round_trip() {
716        // `sq` escapes it as `'\''`; splitting on the first quote instead of the one
717        // before ` link` would cut the path in half here.
718        let script = build_hook_script("/home/o'brien/dev-prune");
719        assert_eq!(
720            parse_hook_exe(&script),
721            Some(PathBuf::from("/home/o'brien/dev-prune"))
722        );
723    }
724
725    #[test]
726    fn a_script_that_is_not_ours_answers_nothing() {
727        assert!(parse_hook_exe("#!/bin/sh\nnpm test\n").is_none());
728    }
729
730    #[test]
731    fn a_forwarded_hook_we_do_not_own_only_forwards() {
732        let script = build_chained_hook_script(
733            "/usr/local/bin/dev-prune",
734            Path::new("/home/dev/.husky"),
735            "pre-commit",
736            false,
737        );
738        // Registering on `pre-commit` would put dev-prune in front of a hook that can
739        // reject the commit, for no benefit: `post-commit` already covers the repo.
740        assert!(!script.contains("link ."));
741        assert!(script.contains(r#"exec "$next" "$@""#));
742    }
743
744    #[test]
745    fn chaining_never_shims_a_file_that_is_not_a_git_hook() {
746        let tmp = tempfile::TempDir::new().unwrap();
747        // What a husky directory actually looks like.
748        fs::write(tmp.path().join("pre-commit"), "#!/bin/sh\nnpm test\n").unwrap();
749        fs::write(tmp.path().join("commit-msg"), "#!/bin/sh\ncommitlint\n").unwrap();
750        fs::write(tmp.path().join(".gitignore"), "_\n").unwrap();
751        fs::write(tmp.path().join("README.md"), "hooks\n").unwrap();
752        fs::create_dir(tmp.path().join("_")).unwrap();
753
754        let found = hook_names_in(tmp.path());
755        assert_eq!(
756            found,
757            vec!["pre-commit".to_string(), "commit-msg".to_string()]
758        );
759    }
760
761    #[test]
762    fn drift_is_a_hook_the_other_tool_added_after_the_chain_was_built() {
763        let ours = tempfile::TempDir::new().unwrap();
764        let theirs = tempfile::TempDir::new().unwrap();
765        fs::write(theirs.path().join("pre-commit"), "x").unwrap();
766        fs::write(theirs.path().join("pre-push"), "x").unwrap();
767        fs::write(ours.path().join("pre-commit"), "shim").unwrap();
768
769        assert_eq!(
770            chain_drift(ours.path(), theirs.path()),
771            vec!["pre-push".to_string()]
772        );
773    }
774
775    #[test]
776    fn every_installed_hook_runs_after_the_operation_it_follows() {
777        // A pre-* hook can abort a commit; none of ours may.
778        assert!(HOOKS.iter().all(|hook| hook.starts_with("post-")));
779        assert_eq!(HOOKS.len(), 3);
780    }
781}