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::Result;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Runner {
14 PreCommit,
15 Prek,
16 Lefthook,
17 Hk,
18}
19
20impl Runner {
21 pub fn bin(self) -> &'static str {
22 match self {
23 Runner::PreCommit => "pre-commit",
24 Runner::Prek => "prek",
25 Runner::Lefthook => "lefthook",
26 Runner::Hk => "hk",
27 }
28 }
29
30 /// Filesystem probe for runner config files at `root`. Returns Ok(Some)
31 /// for a single match, Ok(None) for no match, Err for ambiguous.
32 pub fn autodetect(root: &Path) -> Result<Option<Runner>> {
33 let candidates = [
34 (Runner::Hk, &["hk.pkl"][..]),
35 (
36 Runner::Lefthook,
37 &[
38 "lefthook.yml",
39 "lefthook.yaml",
40 ".lefthook.yml",
41 ".lefthook.yaml",
42 ][..],
43 ),
44 (
45 Runner::PreCommit,
46 &[".pre-commit-config.yaml", ".pre-commit-config.yml"][..],
47 ),
48 ];
49
50 let mut found: Vec<Runner> = Vec::new();
51 for (runner, files) in candidates {
52 if files.iter().any(|f| root.join(f).exists()) {
53 found.push(runner);
54 }
55 }
56
57 match found.as_slice() {
58 [] => Ok(None),
59 [one] => Ok(Some(*one)),
60 many => Err(crate::error::JjHooksError::Parse(format!(
61 "multiple hook-runner configs found at workspace root: {:?}. Use --runner to pick one.",
62 many.iter().map(|r| r.bin()).collect::<Vec<_>>()
63 ))),
64 }
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum Stage {
70 PreCommit,
71 PrePush,
72}
73
74impl Stage {
75 pub fn as_str(self) -> &'static str {
76 match self {
77 Stage::PreCommit => "pre-commit",
78 Stage::PrePush => "pre-push",
79 }
80 }
81}
82
83/// Build the argv for a hook invocation against the from..to ref range.
84///
85/// pre-commit / prek: `<bin> run --hook-stage <stage> --from-ref <from> --to-ref <to>`.
86/// hk: `hk run <stage> --from-ref <from> --to-ref <to>` — hk takes the
87/// same `--from-ref` / `--to-ref` flags as pre-commit, and *needs* them
88/// when running in an ephemeral worktree (otherwise hk tries to resolve
89/// `refs/remotes/origin/HEAD` and errors out).
90///
91/// Lefthook needs a file list, not refs — use [`lefthook_command`] instead.
92pub fn hook_command(runner: Runner, stage: Stage, from: &str, to: &str) -> Vec<String> {
93 match runner {
94 Runner::PreCommit | Runner::Prek => vec![
95 runner.bin().into(),
96 "run".into(),
97 "--hook-stage".into(),
98 stage.as_str().into(),
99 "--from-ref".into(),
100 from.into(),
101 "--to-ref".into(),
102 to.into(),
103 ],
104 Runner::Hk => vec![
105 runner.bin().into(),
106 "run".into(),
107 stage.as_str().into(),
108 "--from-ref".into(),
109 from.into(),
110 "--to-ref".into(),
111 to.into(),
112 ],
113 Runner::Lefthook => panic!(
114 "lefthook does not take ref bounds; use lefthook_command with a file list instead"
115 ),
116 }
117}
118
119/// Build the argv for a lefthook invocation. Lefthook accepts repeated
120/// `--file <path>` flags (one per changed file). When the file list is
121/// empty we omit the flags entirely and let lefthook decide whether
122/// "nothing to do" is a success or no-op.
123pub fn lefthook_command(stage: Stage, files: &[PathBuf]) -> Vec<String> {
124 let mut argv = vec!["lefthook".into(), "run".into(), stage.as_str().into()];
125 for f in files {
126 argv.push("--file".into());
127 argv.push(f.to_string_lossy().into_owned());
128 }
129 argv
130}
131
132/// Build the argv for a runner invocation in `--all-files` mode. The
133/// runner's own "ignore the diff, lint every tracked file" flag replaces
134/// the `--from-ref`/`--to-ref` selection [`hook_command`] would normally
135/// pass.
136///
137/// Per-runner mapping (verified against each tool):
138/// pre-commit / prek: `--all-files`
139/// hk: `--glob '*'` (hk's `-a/--all` does NOT override
140/// its from/to-ref defaults on stage hooks, despite
141/// what `hk run --help` implies; `--glob '*'` is the
142/// only flag that actually replaces the file
143/// selection. Verified with hk 1.45.0.)
144///
145/// Lefthook is symmetric to [`hook_command`] — it needs its own builder
146/// (`lefthook_command_all_files`) because the all-files form replaces
147/// the per-file selection rather than the ref bounds.
148pub fn hook_command_all_files(runner: Runner, stage: Stage) -> Vec<String> {
149 match runner {
150 Runner::PreCommit | Runner::Prek => vec![
151 runner.bin().into(),
152 "run".into(),
153 "--hook-stage".into(),
154 stage.as_str().into(),
155 "--all-files".into(),
156 ],
157 Runner::Hk => vec![
158 runner.bin().into(),
159 "run".into(),
160 stage.as_str().into(),
161 "--glob".into(),
162 "*".into(),
163 ],
164 Runner::Lefthook => {
165 panic!("lefthook is built via lefthook_command_all_files, not hook_command_all_files")
166 }
167 }
168}
169
170/// Build the argv for a lefthook invocation in all-files mode.
171/// Lefthook's `--all-files` flag replaces the per-`--file` selection
172/// [`lefthook_command`] would otherwise build.
173pub fn lefthook_command_all_files(stage: Stage) -> Vec<String> {
174 vec![
175 "lefthook".into(),
176 "run".into(),
177 stage.as_str().into(),
178 "--all-files".into(),
179 ]
180}
181
182/// Swap `Runner::PreCommit` for `Runner::Prek` when prek is on the user's
183/// PATH. prek is a drop-in pre-commit replacement that's much faster, so
184/// users who happen to have both installed should get the faster one
185/// automatically. An explicit `--runner pre-commit` short-circuits this
186/// (callers should only invoke `prefer_prek_when_available` on the
187/// autodetected result, not on a user-supplied override).
188pub fn prefer_prek_when_available(autodetected: Runner, prek_present: bool) -> Runner {
189 match (autodetected, prek_present) {
190 (Runner::PreCommit, true) => Runner::Prek,
191 _ => autodetected,
192 }
193}
194
195/// Probe `$PATH` for the `prek` binary.
196pub fn prek_on_path() -> bool {
197 which("prek").is_some()
198}
199
200fn which(bin: &str) -> Option<PathBuf> {
201 let path = std::env::var_os("PATH")?;
202 for dir in std::env::split_paths(&path) {
203 let candidate = dir.join(bin);
204 if candidate.is_file() {
205 return Some(candidate);
206 }
207 }
208 None
209}