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}