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}