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::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}