Skip to main content

jj_hooks/
runner.rs

1//! Hook runner backends.
2//!
3//! Each runner has slightly different CLI ergonomics, so this module owns
4//! the per-backend knowledge of "what args do I accept". pre-commit and
5//! prek share a CLI shape; hk has its own; lefthook needs a file list
6//! rather than ref bounds.
7
8use std::path::{Path, PathBuf};
9
10use crate::error::{JjHooksError, Result};
11use crate::jj::JjCli;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Runner {
15    PreCommit,
16    Prek,
17    Lefthook,
18    Hk,
19}
20
21impl Runner {
22    pub fn bin(self) -> &'static str {
23        match self {
24            Runner::PreCommit => "pre-commit",
25            Runner::Prek => "prek",
26            Runner::Lefthook => "lefthook",
27            Runner::Hk => "hk",
28        }
29    }
30
31    /// Filesystem probe for runner config files at `root`. Returns Ok(Some)
32    /// for a single match, Ok(None) for no match, Err for ambiguous.
33    pub fn autodetect(root: &Path) -> Result<Option<Runner>> {
34        let candidates = [
35            (Runner::Hk, &["hk.pkl"][..]),
36            // Lefthook autodetects its config as MainConfigNames x
37            // Extensions: names `lefthook` / `.lefthook` / `.config/lefthook`
38            // crossed with `.yml` / `.yaml` / `.json` / `.jsonc` / `.toml`
39            // (see lefthook `internal/config/loader.go`). Mirror that full
40            // 15-path set here so a repo using any of lefthook's own config
41            // forms is detected rather than silently skipped.
42            (
43                Runner::Lefthook,
44                &[
45                    "lefthook.yml",
46                    "lefthook.yaml",
47                    "lefthook.json",
48                    "lefthook.jsonc",
49                    "lefthook.toml",
50                    ".lefthook.yml",
51                    ".lefthook.yaml",
52                    ".lefthook.json",
53                    ".lefthook.jsonc",
54                    ".lefthook.toml",
55                    ".config/lefthook.yml",
56                    ".config/lefthook.yaml",
57                    ".config/lefthook.json",
58                    ".config/lefthook.jsonc",
59                    ".config/lefthook.toml",
60                ][..],
61            ),
62            (
63                Runner::PreCommit,
64                &[".pre-commit-config.yaml", ".pre-commit-config.yml"][..],
65            ),
66            // prek reads its own native `prek.toml` as well as
67            // `.pre-commit-config.yaml`. If only `prek.toml` is present we
68            // need to pick `Runner::Prek` directly — `prefer_prek_when_available`
69            // only swaps in prek when the autodetected runner was PreCommit,
70            // so without this match a prek-native repo silently skips hooks
71            // ("no hook-runner config in target commit").
72            (Runner::Prek, &["prek.toml", ".prek.toml"][..]),
73        ];
74
75        let mut found: Vec<Runner> = Vec::new();
76        for (runner, files) in candidates {
77            if files.iter().any(|f| root.join(f).exists()) {
78                found.push(runner);
79            }
80        }
81
82        // PreCommit + Prek aren't ambiguous — they're the same runner
83        // family. prek consumes both `prek.toml` and `.pre-commit-config.yaml`,
84        // so when both turn up at the same root, collapse to Prek rather
85        // than asking the user to disambiguate.
86        if found.contains(&Runner::Prek) && found.contains(&Runner::PreCommit) {
87            found.retain(|r| *r != Runner::PreCommit);
88        }
89
90        match found.as_slice() {
91            [] => Ok(None),
92            [one] => Ok(Some(*one)),
93            many => Err(crate::error::JjHooksError::Parse(format!(
94                "multiple hook-runner configs found at workspace root: {:?}. Use --runner to pick one.",
95                many.iter().map(|r| r.bin()).collect::<Vec<_>>()
96            ))),
97        }
98    }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum Stage {
103    PreCommit,
104    PrePush,
105}
106
107impl Stage {
108    pub fn as_str(self) -> &'static str {
109        match self {
110            Stage::PreCommit => "pre-commit",
111            Stage::PrePush => "pre-push",
112        }
113    }
114}
115
116/// Build the argv for a hook invocation against the from..to ref range.
117///
118/// pre-commit / prek: `<bin> run --hook-stage <stage> --from-ref <from> --to-ref <to>`.
119/// hk: `hk run <stage> --from-ref <from> --to-ref <to>` — hk takes the
120/// same `--from-ref` / `--to-ref` flags as pre-commit, and *needs* them
121/// when running in an ephemeral worktree (otherwise hk tries to resolve
122/// `refs/remotes/origin/HEAD` and errors out).
123///
124/// Lefthook needs a file list, not refs — use [`lefthook_command`] instead.
125pub fn hook_command(runner: Runner, stage: Stage, from: &str, to: &str) -> Vec<String> {
126    match runner {
127        Runner::PreCommit | Runner::Prek => vec![
128            runner.bin().into(),
129            "run".into(),
130            "--hook-stage".into(),
131            stage.as_str().into(),
132            "--from-ref".into(),
133            from.into(),
134            "--to-ref".into(),
135            to.into(),
136        ],
137        Runner::Hk => vec![
138            runner.bin().into(),
139            "run".into(),
140            stage.as_str().into(),
141            "--from-ref".into(),
142            from.into(),
143            "--to-ref".into(),
144            to.into(),
145        ],
146        Runner::Lefthook => panic!(
147            "lefthook does not take ref bounds; use lefthook_command with a file list instead"
148        ),
149    }
150}
151
152/// Build the argv for a lefthook invocation. Lefthook accepts repeated
153/// `--file <path>` flags (one per changed file). When the file list is
154/// empty we omit the flags entirely and let lefthook decide whether
155/// "nothing to do" is a success or no-op.
156pub fn lefthook_command(stage: Stage, files: &[PathBuf]) -> Vec<String> {
157    let mut argv = vec!["lefthook".into(), "run".into(), stage.as_str().into()];
158    for f in files {
159        argv.push("--file".into());
160        argv.push(f.to_string_lossy().into_owned());
161    }
162    argv
163}
164
165/// Build the argv for a runner invocation in `--all-files` mode. The
166/// runner's own "ignore the diff, lint every tracked file" flag replaces
167/// the `--from-ref`/`--to-ref` selection [`hook_command`] would normally
168/// pass.
169///
170/// Per-runner mapping (verified against each tool):
171///   pre-commit / prek: `--all-files`
172///   hk:                `--glob '*'` (hk's `-a/--all` does NOT override
173///                      its from/to-ref defaults on stage hooks, despite
174///                      what `hk run --help` implies; `--glob '*'` is the
175///                      only flag that actually replaces the file
176///                      selection. Verified with hk 1.45.0.)
177///
178/// Lefthook is symmetric to [`hook_command`] — it needs its own builder
179/// (`lefthook_command_all_files`) because the all-files form replaces
180/// the per-file selection rather than the ref bounds.
181pub fn hook_command_all_files(runner: Runner, stage: Stage) -> Vec<String> {
182    match runner {
183        Runner::PreCommit | Runner::Prek => vec![
184            runner.bin().into(),
185            "run".into(),
186            "--hook-stage".into(),
187            stage.as_str().into(),
188            "--all-files".into(),
189        ],
190        Runner::Hk => vec![
191            runner.bin().into(),
192            "run".into(),
193            stage.as_str().into(),
194            "--glob".into(),
195            "*".into(),
196        ],
197        Runner::Lefthook => {
198            panic!("lefthook is built via lefthook_command_all_files, not hook_command_all_files")
199        }
200    }
201}
202
203/// Build the argv that warms hk's Pkl cache: `hk validate` evaluates
204/// `hk.pkl` (resolving + caching its `package://` imports) without
205/// running any hook or needing git refs. The bare `hk` element is
206/// spliced with the resolved runner prefix by the caller, the same way
207/// [`hook_command`] is.
208pub fn hk_validate_command() -> Vec<String> {
209    vec![Runner::Hk.bin().into(), "validate".into()]
210}
211
212/// Build the argv for a lefthook invocation in all-files mode.
213/// Lefthook's `--all-files` flag replaces the per-`--file` selection
214/// [`lefthook_command`] would otherwise build.
215pub fn lefthook_command_all_files(stage: Stage) -> Vec<String> {
216    vec![
217        "lefthook".into(),
218        "run".into(),
219        stage.as_str().into(),
220        "--all-files".into(),
221    ]
222}
223
224/// Swap `Runner::PreCommit` for `Runner::Prek` when prek is on the user's
225/// PATH. prek is a drop-in pre-commit replacement that's much faster, so
226/// users who happen to have both installed should get the faster one
227/// automatically. An explicit `--runner pre-commit` short-circuits this
228/// (callers should only invoke `prefer_prek_when_available` on the
229/// autodetected result, not on a user-supplied override).
230pub fn prefer_prek_when_available(autodetected: Runner, prek_present: bool) -> Runner {
231    match (autodetected, prek_present) {
232        (Runner::PreCommit, true) => Runner::Prek,
233        _ => autodetected,
234    }
235}
236
237/// Probe `$PATH` for the `prek` binary. Used by [`prefer_prek_when_available`]
238/// in test setups; production code uses [`resolve_runner_argv`] which
239/// covers the wider set of layers.
240pub fn prek_on_path() -> bool {
241    which("prek").is_some()
242}
243
244fn which(bin: &str) -> Option<PathBuf> {
245    let path = std::env::var_os("PATH")?;
246    for dir in std::env::split_paths(&path) {
247        let candidate = dir.join(bin);
248        if candidate.is_file() {
249            return Some(candidate);
250        }
251    }
252    None
253}
254
255/// Resolve the argv prefix to invoke a runner binary inside the
256/// ephemeral worktree. The returned `Vec<String>` is the program +
257/// any wrapper args that should be spliced in *in place of* the bare
258/// binary name (`runner.bin()`) when building hook commands.
259///
260/// Resolution order (first hit wins):
261///
262/// 1. **`jj-hooks.runner-bin.<runner>` config.** Explicit user override.
263///    Accepts a TOML string (single argv element, e.g. `".venv/bin/prek"`)
264///    or array (e.g. `["uv", "run", "prek"]`). Relative paths are resolved
265///    against `workspace_root`. Set in `~/.config/jj/config.toml` or the
266///    repo's `.jj/repo/config.toml`.
267/// 2. **Hook-shim path baked in by `prek install` / `pre-commit install`.**
268///    Parses `primary_git_dir/hooks/<stage>` looking for the canonical
269///    assignment each install script writes:
270///    - prek bakes `PREK="/.../venv/bin/prek"` and `exec`s it directly.
271///    - pre-commit bakes `INSTALL_PYTHON=/.../venv/bin/python` and runs
272///      `"$INSTALL_PYTHON" -mpre_commit …` — the interpreter is in the
273///      venv but the entry point is `python -m pre_commit`.
274///
275///    Either way, the resolved binary matches what your existing
276///    `.git/hooks/<stage>` shim would have used, so jj-hp behaves
277///    identically to `git commit` / `git push` triggering the shim.
278/// 3. **uv-managed venv.** When `workspace_root/uv.lock` exists *and*
279///    `uv` is on `$PATH`, prepend `uv run --` to the bare runner
280///    invocation. uv resolves the project's venv automatically; the
281///    user doesn't have to activate anything. Only fires for pre-commit
282///    and prek (lefthook and hk aren't Python-installable).
283/// 4. **`$PATH` lookup.** The previous behaviour — bare program name,
284///    found via libc's `execvp` PATH walk.
285///
286/// Returns `Ok(argv)` for any hit; returns `Err(RunnerNotFound)` if all
287/// four layers come up empty. Errors from layer 1 (e.g. malformed config
288/// value) propagate so the user gets a clear message instead of silent
289/// fallthrough.
290pub fn resolve_runner_argv(
291    runner: Runner,
292    jj: &JjCli,
293    workspace_root: &Path,
294    primary_git_dir: &Path,
295    stage: Stage,
296) -> Result<Vec<String>> {
297    // (1) Explicit config override.
298    if let Some(argv) = read_runner_bin_config(jj, runner, workspace_root)? {
299        tracing::debug!("runner {}: resolved via config: {argv:?}", runner.bin());
300        return Ok(argv);
301    }
302
303    // (2) Hook-shim path. Both `prek install` and `pre-commit install`
304    // bake the resolved binary into `.git/hooks/<stage>`:
305    //
306    // - prek writes `PREK="/abs/path/to/.venv/bin/prek"` and `exec`s
307    //   it directly.
308    // - pre-commit writes `INSTALL_PYTHON=/abs/path/to/.venv/bin/python`
309    //   and `exec`s it as `"$INSTALL_PYTHON" -mpre_commit "${ARGS[@]}"` —
310    //   `INSTALL_PYTHON` is the interpreter, not pre-commit itself, but
311    //   `python -m pre_commit` is still pre-commit's entry point.
312    //
313    // For prek the resolved argv is a single element (the path);
314    // for pre-commit it's `[python, "-mpre_commit"]`.
315    if let Some(argv) = read_shim_argv(primary_git_dir, stage, runner) {
316        tracing::debug!(
317            "runner {}: resolved via .git/hooks/{} shim: {argv:?}",
318            runner.bin(),
319            stage.as_str(),
320        );
321        return Ok(argv);
322    }
323
324    // (3) uv-managed venv. Only for pre-commit / prek (lefthook and
325    // hk aren't Python tools). Requires both uv.lock in the workspace
326    // and `uv` itself on $PATH.
327    //
328    // We pass `--project <workspace_root>` so uv resolves the venv
329    // relative to the user's actual workspace, not the ephemeral
330    // worktree we run hooks in. The worktree is a fresh git checkout
331    // and (typically) doesn't have `.venv` since `.venv` is gitignored
332    // — without --project, uv would either fail to find the runner
333    // or try to bootstrap a fresh env every push.
334    if matches!(runner, Runner::PreCommit | Runner::Prek)
335        && workspace_root.join("uv.lock").exists()
336        && which("uv").is_some()
337    {
338        tracing::debug!("runner {}: resolved via `uv run --project`", runner.bin());
339        return Ok(vec![
340            "uv".into(),
341            "run".into(),
342            "--project".into(),
343            workspace_root.to_string_lossy().into_owned(),
344            "--".into(),
345            runner.bin().into(),
346        ]);
347    }
348
349    // (4) Plain $PATH.
350    if which(runner.bin()).is_some() {
351        return Ok(vec![runner.bin().into()]);
352    }
353
354    Err(JjHooksError::RunnerNotFound {
355        bin: runner.bin().to_owned(),
356    })
357}
358
359/// Read `jj-hooks.runner-bin.<runner>` from jj config and return it as an
360/// argv prefix. Accepts:
361///
362/// - bare string: `runner-bin.prek = ".venv/bin/prek"`
363///   → `[".venv/bin/prek"]` (or absolute equivalent against `workspace_root`)
364/// - array of strings: `runner-bin.prek = ["uv", "run", "--", "prek"]`
365///   → returned as-is
366///
367/// Returns `Ok(None)` when the key isn't set. Errors when the value is
368/// present but malformed (empty array, non-string element, etc.) so the
369/// user catches typos at config-load time rather than seeing the wrong
370/// binary silently invoked.
371fn read_runner_bin_config(
372    jj: &JjCli,
373    runner: Runner,
374    workspace_root: &Path,
375) -> Result<Option<Vec<String>>> {
376    let key = format!("jj-hooks.runner-bin.{}", runner.bin());
377    let Ok(raw) = jj.run(&["config", "get", &key]) else {
378        // Key missing — `jj config get` exits non-zero. That's the
379        // common no-override path, not an error.
380        return Ok(None);
381    };
382    let raw = raw.trim();
383    if raw.is_empty() {
384        return Ok(None);
385    }
386
387    let argv =
388        parse_runner_bin_value(raw).map_err(|e| JjHooksError::Parse(format!("{key}: {e}")))?;
389
390    // First element gets resolved against workspace_root when relative.
391    // Subsequent elements are plain args (`uv run --`, etc.) and pass
392    // through verbatim — they're not paths.
393    let mut out = argv;
394    if let Some(first) = out.first_mut() {
395        let p = Path::new(first);
396        if p.is_relative() {
397            *first = workspace_root.join(p).to_string_lossy().into_owned();
398        }
399    }
400    Ok(Some(out))
401}
402
403/// Parse a `jj config get jj-hooks.runner-bin.<runner>` value into argv.
404/// Accepts either a bare string (the form `jj config get` uses for
405/// scalar values — unquoted, raw) or a TOML inline array (the form
406/// jj uses for array values, e.g. `["uv", "run", "--", "prek"]`).
407/// Empty arrays and non-string array elements are rejected.
408fn parse_runner_bin_value(raw: &str) -> std::result::Result<Vec<String>, String> {
409    let trimmed = raw.trim();
410    if trimmed.is_empty() {
411        return Err("must not be empty".into());
412    }
413    if trimmed.starts_with('[') {
414        // Array form. Use the standard TOML deserializer.
415        let wrapped = format!("v = {trimmed}");
416        #[derive(serde::Deserialize)]
417        struct Wrap {
418            v: Vec<String>,
419        }
420        let parsed: Wrap = toml::from_str(&wrapped).map_err(|e| {
421            format!("array form must be all strings (e.g. [\"uv\", \"run\", \"--\", \"prek\"]); got {raw:?}: {e}")
422        })?;
423        if parsed.v.is_empty() {
424            return Err("array must have at least one element".into());
425        }
426        if parsed.v.iter().any(String::is_empty) {
427            return Err("array elements must be non-empty strings".into());
428        }
429        return Ok(parsed.v);
430    }
431
432    // Scalar form. `jj config get` prints scalar values raw (no
433    // surrounding quotes), so we take the trimmed string verbatim.
434    // Strip surrounding quotes if present (in case the user copies
435    // the value out of a TOML file and pastes it as-is).
436    let unquoted = trimmed
437        .strip_prefix('"')
438        .and_then(|s| s.strip_suffix('"'))
439        .unwrap_or(trimmed);
440    Ok(vec![unquoted.to_owned()])
441}
442
443/// Parse `primary_git_dir/hooks/<stage>` for the runner path baked in by
444/// `prek install` or `pre-commit install`. Returns the argv prefix that
445/// should be used to invoke the runner.
446///
447/// Shim formats (stable across recent versions of each tool):
448///
449/// prek:
450/// ```sh
451/// PREK="/abs/path/to/.venv/bin/prek"
452/// exec "$PREK" hook-impl …
453/// ```
454///
455/// pre-commit:
456/// ```sh
457/// INSTALL_PYTHON=/abs/path/to/.venv/bin/python
458/// ARGS=(hook-impl …)
459/// exec "$INSTALL_PYTHON" -mpre_commit "${ARGS[@]}"
460/// ```
461///
462/// For prek the returned argv is `[PREK]`; for pre-commit it's
463/// `[INSTALL_PYTHON, "-mpre_commit"]`. The caller splices the runner's
464/// subcommand args (`run --hook-stage …`) on after.
465///
466/// We don't try to be a full shell parser — the install scripts always
467/// emit a simple assignment on its own line. prek quotes the value,
468/// pre-commit does not; both are handled.
469///
470/// Returns `None` for runners that don't have a recognised shim format
471/// (lefthook, hk) or when the shim is missing / unrecognised / points
472/// at a non-existent path.
473fn read_shim_argv(primary_git_dir: &Path, stage: Stage, runner: Runner) -> Option<Vec<String>> {
474    let (var_name, build_argv): (&str, fn(PathBuf) -> Vec<String>) = match runner {
475        Runner::Prek => ("PREK", |p| vec![p.to_string_lossy().into_owned()]),
476        Runner::PreCommit => ("INSTALL_PYTHON", |p| {
477            vec![p.to_string_lossy().into_owned(), "-mpre_commit".into()]
478        }),
479        // hk and lefthook install their own shim formats; we don't try
480        // to parse those.
481        Runner::Hk | Runner::Lefthook => return None,
482    };
483
484    let shim = primary_git_dir.join("hooks").join(stage.as_str());
485    let body = std::fs::read_to_string(&shim).ok()?;
486    for line in body.lines() {
487        let trimmed = line.trim();
488        // Tolerate a leading `export ` (some shim variants emit it).
489        let after_export = trimmed.strip_prefix("export ").unwrap_or(trimmed);
490        let Some(rest) = after_export.strip_prefix(var_name) else {
491            continue;
492        };
493        let Some(rest) = rest.strip_prefix('=') else {
494            // Avoid matching `PREKABLE=…` against `PREK`.
495            continue;
496        };
497        // Strip surrounding double quotes if present (prek quotes,
498        // pre-commit doesn't).
499        let path_str = rest
500            .strip_prefix('"')
501            .and_then(|s| s.strip_suffix('"'))
502            .unwrap_or(rest);
503        let candidate = PathBuf::from(path_str);
504        // Only accept absolute paths — relative paths in a shim
505        // would resolve against $PWD at hook-invocation time, which
506        // is not what we want here. (prek's `if [ ! -x "$PREK" ]`
507        // fallback writes `PREK="prek"`; that bare name is intentionally
508        // ignored — we continue to subsequent resolution layers.)
509        if !candidate.is_absolute() {
510            continue;
511        }
512        if candidate.is_file() {
513            return Some(build_argv(candidate));
514        }
515    }
516    None
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    // -- parse_runner_bin_value --------------------------------------------
524
525    #[test]
526    fn parse_runner_bin_value_bare_unquoted_string() {
527        // `jj config get jj-hooks.runner-bin.prek` for a scalar value
528        // prints the string unquoted. That's the primary form we see
529        // in practice.
530        let argv = parse_runner_bin_value("prek").unwrap();
531        assert_eq!(argv, vec!["prek"]);
532    }
533
534    #[test]
535    fn parse_runner_bin_value_absolute_path() {
536        let argv = parse_runner_bin_value("/abs/path/to/prek").unwrap();
537        assert_eq!(argv, vec!["/abs/path/to/prek"]);
538    }
539
540    #[test]
541    fn parse_runner_bin_value_quoted_string_strips_quotes() {
542        // Defensive: if the user copies the TOML quoted form, accept
543        // it rather than including the literal quotes in argv[0].
544        let argv = parse_runner_bin_value(r#""/abs/path/to/prek""#).unwrap();
545        assert_eq!(argv, vec!["/abs/path/to/prek"]);
546    }
547
548    #[test]
549    fn parse_runner_bin_value_array() {
550        // The shape printed for `runner-bin.prek = ["uv", "run", "--", "prek"]`.
551        let argv = parse_runner_bin_value(r#"["uv", "run", "--", "prek"]"#).unwrap();
552        assert_eq!(argv, vec!["uv", "run", "--", "prek"]);
553    }
554
555    #[test]
556    fn parse_runner_bin_value_empty_string_errors() {
557        // Defensive: an empty string config value is almost certainly a
558        // user typo. Reject so they catch it now rather than seeing the
559        // resolver fall through to PATH and pick up the wrong binary.
560        let err = parse_runner_bin_value("").unwrap_err();
561        assert!(err.contains("empty"), "expected empty-string error: {err}");
562    }
563
564    #[test]
565    fn parse_runner_bin_value_empty_array_errors() {
566        let err = parse_runner_bin_value("[]").unwrap_err();
567        assert!(err.contains("at least one"), "got: {err}");
568    }
569
570    #[test]
571    fn parse_runner_bin_value_array_with_empty_element_errors() {
572        let err = parse_runner_bin_value(r#"["uv", ""]"#).unwrap_err();
573        assert!(err.contains("non-empty"), "got: {err}");
574    }
575
576    #[test]
577    fn parse_runner_bin_value_non_string_array_errors() {
578        // A number masquerading as a binary path is a typo we should
579        // catch loudly, not silently coerce.
580        let err = parse_runner_bin_value(r#"["uv", 42]"#).unwrap_err();
581        assert!(
582            err.contains("string"),
583            "expected string-related error: {err}"
584        );
585    }
586
587    // -- read_shim_argv ----------------------------------------------------
588
589    /// Build a temp `<dir>/hooks/<stage>` shim file with given contents
590    /// and return its parent (the simulated git dir) for `read_shim_argv`.
591    fn write_shim(stage: Stage, body: &str) -> tempfile::TempDir {
592        let dir = tempfile::TempDir::new().unwrap();
593        let hooks = dir.path().join("hooks");
594        std::fs::create_dir(&hooks).unwrap();
595        std::fs::write(hooks.join(stage.as_str()), body).unwrap();
596        dir
597    }
598
599    #[test]
600    fn read_shim_argv_returns_none_when_shim_missing() {
601        let dir = tempfile::TempDir::new().unwrap();
602        assert_eq!(
603            read_shim_argv(dir.path(), Stage::PreCommit, Runner::Prek),
604            None
605        );
606    }
607
608    #[test]
609    fn read_shim_argv_returns_none_when_shim_unrecognised() {
610        // A shim with no recognised assignment — should silently fall
611        // through to layer 3/4 rather than erroring.
612        let dir = write_shim(Stage::PreCommit, "#!/bin/sh\nexec prek hook-impl\n");
613        assert_eq!(
614            read_shim_argv(dir.path(), Stage::PreCommit, Runner::Prek),
615            None
616        );
617    }
618
619    #[test]
620    #[cfg(unix)] // resolves /bin/sh as an executable; Windows has no such path
621    fn read_shim_argv_picks_up_prek_install_format() {
622        // The exact format `prek install` writes (issue #17 repro).
623        // We need the path to resolve to a real executable for the
624        // gate to fire — point at /bin/sh which exists on every Unix.
625        let body = r#"#!/bin/sh
626HERE="$(cd "$(dirname "$0")" && pwd)"
627PREK="/bin/sh"
628if [ ! -x "$PREK" ]; then
629    PREK="prek"
630fi
631exec "$PREK" hook-impl --hook-dir "$HERE" --script-version 4 --hook-type=pre-commit -- "$@"
632"#;
633        let dir = write_shim(Stage::PreCommit, body);
634        let argv = read_shim_argv(dir.path(), Stage::PreCommit, Runner::Prek);
635        assert_eq!(argv, Some(vec!["/bin/sh".to_owned()]));
636    }
637
638    #[test]
639    #[cfg(unix)] // resolves /bin/sh as an executable; Windows has no such path
640    fn read_shim_argv_picks_up_pre_commit_install_format() {
641        // The format `pre-commit install` writes: unquoted
642        // INSTALL_PYTHON=<path>, then exec'd as `python -mpre_commit`.
643        // The resolved argv must include the `-mpre_commit` flag —
644        // running `INSTALL_PYTHON` bare would just give you a Python
645        // REPL, not pre-commit.
646        let body = r#"#!/usr/bin/env bash
647# start templated
648INSTALL_PYTHON=/bin/sh
649ARGS=(hook-impl --config=.pre-commit-config.yaml --hook-type=pre-commit)
650# end templated
651HERE="$(cd "$(dirname "$0")" && pwd)"
652ARGS+=(--hook-dir "$HERE" -- "$@")
653exec "$INSTALL_PYTHON" -mpre_commit "${ARGS[@]}"
654"#;
655        let dir = write_shim(Stage::PreCommit, body);
656        let argv = read_shim_argv(dir.path(), Stage::PreCommit, Runner::PreCommit);
657        assert_eq!(
658            argv,
659            Some(vec!["/bin/sh".to_owned(), "-mpre_commit".to_owned()])
660        );
661    }
662
663    #[test]
664    fn read_shim_argv_runner_specific_var_name() {
665        // A prek-format shim shouldn't satisfy the pre-commit probe,
666        // and vice versa — each runner has its own baked variable.
667        let prek_body = r#"PREK="/bin/sh""#;
668        let dir = write_shim(Stage::PreCommit, prek_body);
669        assert_eq!(
670            read_shim_argv(dir.path(), Stage::PreCommit, Runner::PreCommit),
671            None,
672            "PREK= line must not satisfy the pre-commit shim probe"
673        );
674
675        let pc_body = "INSTALL_PYTHON=/bin/sh";
676        let dir = write_shim(Stage::PreCommit, pc_body);
677        assert_eq!(
678            read_shim_argv(dir.path(), Stage::PreCommit, Runner::Prek),
679            None,
680            "INSTALL_PYTHON= line must not satisfy the prek shim probe"
681        );
682    }
683
684    #[test]
685    fn read_shim_argv_skips_assignments_pointing_at_nonexistent_path() {
686        // The shim's PREK var points at a venv that no longer exists
687        // (user deleted .venv but `prek uninstall` was never run).
688        // We should skip and fall through to subsequent layers, not
689        // resolve to a dead path that would later fail on spawn.
690        let body = r#"PREK="/nonexistent/path/to/prek""#;
691        let dir = write_shim(Stage::PreCommit, body);
692        assert_eq!(
693            read_shim_argv(dir.path(), Stage::PreCommit, Runner::Prek),
694            None
695        );
696    }
697
698    #[test]
699    fn read_shim_argv_skips_relative_paths() {
700        // A relative path in a shim would resolve against $PWD at
701        // hook-invocation time. That's never what we want here — only
702        // accept absolute paths.
703        let body = r#"PREK="prek""#;
704        let dir = write_shim(Stage::PreCommit, body);
705        assert_eq!(
706            read_shim_argv(dir.path(), Stage::PreCommit, Runner::Prek),
707            None
708        );
709    }
710
711    #[test]
712    #[cfg(unix)] // resolves /bin/sh as an executable; Windows has no such path
713    fn read_shim_argv_honours_stage() {
714        // The pre-commit shim must NOT be consulted when we're running
715        // the pre-push stage (and vice versa) — each stage has its own
716        // installed shim with potentially different baked-in paths.
717        let body = r#"PREK="/bin/sh""#;
718        let dir = write_shim(Stage::PreCommit, body);
719        assert_eq!(
720            read_shim_argv(dir.path(), Stage::PreCommit, Runner::Prek),
721            Some(vec!["/bin/sh".to_owned()])
722        );
723        assert_eq!(
724            read_shim_argv(dir.path(), Stage::PrePush, Runner::Prek),
725            None
726        );
727    }
728
729    #[test]
730    #[cfg(unix)] // resolves /bin/sh as an executable; Windows has no such path
731    fn read_shim_argv_accepts_export_prefix() {
732        // Some shim variants emit `export VAR="…"` rather than bare
733        // `VAR="…"`. Tolerate that.
734        let body = r#"export PREK="/bin/sh""#;
735        let dir = write_shim(Stage::PreCommit, body);
736        assert_eq!(
737            read_shim_argv(dir.path(), Stage::PreCommit, Runner::Prek),
738            Some(vec!["/bin/sh".to_owned()])
739        );
740    }
741
742    #[test]
743    fn read_shim_argv_only_matches_exact_variable_name() {
744        // Defensive: `PREKABLE=…` shouldn't match `PREK=`, and
745        // `INSTALL_PYTHON_VERSION=…` shouldn't match `INSTALL_PYTHON=`.
746        let body = r#"PREKABLE="/bin/sh""#;
747        let dir = write_shim(Stage::PreCommit, body);
748        assert_eq!(
749            read_shim_argv(dir.path(), Stage::PreCommit, Runner::Prek),
750            None
751        );
752    }
753
754    #[test]
755    fn read_shim_argv_returns_none_for_lefthook_and_hk() {
756        // We don't try to parse lefthook / hk install shims — their
757        // formats are different and harder to dispatch on. These
758        // runners are only resolved via layers 1 / 4.
759        let body = r#"PREK="/bin/sh""#;
760        let dir = write_shim(Stage::PreCommit, body);
761        assert_eq!(
762            read_shim_argv(dir.path(), Stage::PreCommit, Runner::Lefthook),
763            None
764        );
765        assert_eq!(
766            read_shim_argv(dir.path(), Stage::PreCommit, Runner::Hk),
767            None
768        );
769    }
770
771    #[test]
772    fn hk_validate_command_is_hk_validate() {
773        assert_eq!(
774            hk_validate_command(),
775            vec!["hk".to_string(), "validate".to_string()]
776        );
777    }
778}