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