use std::path::{Path, PathBuf};
use crate::error::{JjHooksError, Result};
use crate::jj::JjCli;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Runner {
PreCommit,
Prek,
Lefthook,
Hk,
}
impl Runner {
pub fn bin(self) -> &'static str {
match self {
Runner::PreCommit => "pre-commit",
Runner::Prek => "prek",
Runner::Lefthook => "lefthook",
Runner::Hk => "hk",
}
}
pub fn autodetect(root: &Path) -> Result<Option<Runner>> {
let candidates = [
(Runner::Hk, &["hk.pkl"][..]),
(
Runner::Lefthook,
&[
"lefthook.yml",
"lefthook.yaml",
".lefthook.yml",
".lefthook.yaml",
][..],
),
(
Runner::PreCommit,
&[".pre-commit-config.yaml", ".pre-commit-config.yml"][..],
),
(Runner::Prek, &["prek.toml", ".prek.toml"][..]),
];
let mut found: Vec<Runner> = Vec::new();
for (runner, files) in candidates {
if files.iter().any(|f| root.join(f).exists()) {
found.push(runner);
}
}
if found.contains(&Runner::Prek) && found.contains(&Runner::PreCommit) {
found.retain(|r| *r != Runner::PreCommit);
}
match found.as_slice() {
[] => Ok(None),
[one] => Ok(Some(*one)),
many => Err(crate::error::JjHooksError::Parse(format!(
"multiple hook-runner configs found at workspace root: {:?}. Use --runner to pick one.",
many.iter().map(|r| r.bin()).collect::<Vec<_>>()
))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stage {
PreCommit,
PrePush,
}
impl Stage {
pub fn as_str(self) -> &'static str {
match self {
Stage::PreCommit => "pre-commit",
Stage::PrePush => "pre-push",
}
}
}
pub fn hook_command(runner: Runner, stage: Stage, from: &str, to: &str) -> Vec<String> {
match runner {
Runner::PreCommit | Runner::Prek => vec![
runner.bin().into(),
"run".into(),
"--hook-stage".into(),
stage.as_str().into(),
"--from-ref".into(),
from.into(),
"--to-ref".into(),
to.into(),
],
Runner::Hk => vec![
runner.bin().into(),
"run".into(),
stage.as_str().into(),
"--from-ref".into(),
from.into(),
"--to-ref".into(),
to.into(),
],
Runner::Lefthook => panic!(
"lefthook does not take ref bounds; use lefthook_command with a file list instead"
),
}
}
pub fn lefthook_command(stage: Stage, files: &[PathBuf]) -> Vec<String> {
let mut argv = vec!["lefthook".into(), "run".into(), stage.as_str().into()];
for f in files {
argv.push("--file".into());
argv.push(f.to_string_lossy().into_owned());
}
argv
}
pub fn hook_command_all_files(runner: Runner, stage: Stage) -> Vec<String> {
match runner {
Runner::PreCommit | Runner::Prek => vec![
runner.bin().into(),
"run".into(),
"--hook-stage".into(),
stage.as_str().into(),
"--all-files".into(),
],
Runner::Hk => vec![
runner.bin().into(),
"run".into(),
stage.as_str().into(),
"--glob".into(),
"*".into(),
],
Runner::Lefthook => {
panic!("lefthook is built via lefthook_command_all_files, not hook_command_all_files")
}
}
}
pub fn lefthook_command_all_files(stage: Stage) -> Vec<String> {
vec![
"lefthook".into(),
"run".into(),
stage.as_str().into(),
"--all-files".into(),
]
}
pub fn prefer_prek_when_available(autodetected: Runner, prek_present: bool) -> Runner {
match (autodetected, prek_present) {
(Runner::PreCommit, true) => Runner::Prek,
_ => autodetected,
}
}
pub fn prek_on_path() -> bool {
which("prek").is_some()
}
fn which(bin: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(bin);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
pub fn resolve_runner_argv(
runner: Runner,
jj: &JjCli,
workspace_root: &Path,
primary_git_dir: &Path,
stage: Stage,
) -> Result<Vec<String>> {
if let Some(argv) = read_runner_bin_config(jj, runner, workspace_root)? {
tracing::debug!("runner {}: resolved via config: {argv:?}", runner.bin());
return Ok(argv);
}
if let Some(argv) = read_shim_argv(primary_git_dir, stage, runner) {
tracing::debug!(
"runner {}: resolved via .git/hooks/{} shim: {argv:?}",
runner.bin(),
stage.as_str(),
);
return Ok(argv);
}
if matches!(runner, Runner::PreCommit | Runner::Prek)
&& workspace_root.join("uv.lock").exists()
&& which("uv").is_some()
{
tracing::debug!("runner {}: resolved via `uv run --project`", runner.bin());
return Ok(vec![
"uv".into(),
"run".into(),
"--project".into(),
workspace_root.to_string_lossy().into_owned(),
"--".into(),
runner.bin().into(),
]);
}
if which(runner.bin()).is_some() {
return Ok(vec![runner.bin().into()]);
}
Err(JjHooksError::RunnerNotFound {
bin: runner.bin().to_owned(),
})
}
fn read_runner_bin_config(
jj: &JjCli,
runner: Runner,
workspace_root: &Path,
) -> Result<Option<Vec<String>>> {
let key = format!("jj-hooks.runner-bin.{}", runner.bin());
let Ok(raw) = jj.run(&["config", "get", &key]) else {
return Ok(None);
};
let raw = raw.trim();
if raw.is_empty() {
return Ok(None);
}
let argv =
parse_runner_bin_value(raw).map_err(|e| JjHooksError::Parse(format!("{key}: {e}")))?;
let mut out = argv;
if let Some(first) = out.first_mut() {
let p = Path::new(first);
if p.is_relative() {
*first = workspace_root.join(p).to_string_lossy().into_owned();
}
}
Ok(Some(out))
}
fn parse_runner_bin_value(raw: &str) -> std::result::Result<Vec<String>, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("must not be empty".into());
}
if trimmed.starts_with('[') {
let wrapped = format!("v = {trimmed}");
#[derive(serde::Deserialize)]
struct Wrap {
v: Vec<String>,
}
let parsed: Wrap = toml::from_str(&wrapped).map_err(|e| {
format!("array form must be all strings (e.g. [\"uv\", \"run\", \"--\", \"prek\"]); got {raw:?}: {e}")
})?;
if parsed.v.is_empty() {
return Err("array must have at least one element".into());
}
if parsed.v.iter().any(String::is_empty) {
return Err("array elements must be non-empty strings".into());
}
return Ok(parsed.v);
}
let unquoted = trimmed
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(trimmed);
Ok(vec![unquoted.to_owned()])
}
fn read_shim_argv(primary_git_dir: &Path, stage: Stage, runner: Runner) -> Option<Vec<String>> {
let (var_name, build_argv): (&str, fn(PathBuf) -> Vec<String>) = match runner {
Runner::Prek => ("PREK", |p| vec![p.to_string_lossy().into_owned()]),
Runner::PreCommit => ("INSTALL_PYTHON", |p| {
vec![p.to_string_lossy().into_owned(), "-mpre_commit".into()]
}),
Runner::Hk | Runner::Lefthook => return None,
};
let shim = primary_git_dir.join("hooks").join(stage.as_str());
let body = std::fs::read_to_string(&shim).ok()?;
for line in body.lines() {
let trimmed = line.trim();
let after_export = trimmed.strip_prefix("export ").unwrap_or(trimmed);
let Some(rest) = after_export.strip_prefix(var_name) else {
continue;
};
let Some(rest) = rest.strip_prefix('=') else {
continue;
};
let path_str = rest
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(rest);
let candidate = PathBuf::from(path_str);
if !candidate.is_absolute() {
continue;
}
if candidate.is_file() {
return Some(build_argv(candidate));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_runner_bin_value_bare_unquoted_string() {
let argv = parse_runner_bin_value("prek").unwrap();
assert_eq!(argv, vec!["prek"]);
}
#[test]
fn parse_runner_bin_value_absolute_path() {
let argv = parse_runner_bin_value("/abs/path/to/prek").unwrap();
assert_eq!(argv, vec!["/abs/path/to/prek"]);
}
#[test]
fn parse_runner_bin_value_quoted_string_strips_quotes() {
let argv = parse_runner_bin_value(r#""/abs/path/to/prek""#).unwrap();
assert_eq!(argv, vec!["/abs/path/to/prek"]);
}
#[test]
fn parse_runner_bin_value_array() {
let argv = parse_runner_bin_value(r#"["uv", "run", "--", "prek"]"#).unwrap();
assert_eq!(argv, vec!["uv", "run", "--", "prek"]);
}
#[test]
fn parse_runner_bin_value_empty_string_errors() {
let err = parse_runner_bin_value("").unwrap_err();
assert!(err.contains("empty"), "expected empty-string error: {err}");
}
#[test]
fn parse_runner_bin_value_empty_array_errors() {
let err = parse_runner_bin_value("[]").unwrap_err();
assert!(err.contains("at least one"), "got: {err}");
}
#[test]
fn parse_runner_bin_value_array_with_empty_element_errors() {
let err = parse_runner_bin_value(r#"["uv", ""]"#).unwrap_err();
assert!(err.contains("non-empty"), "got: {err}");
}
#[test]
fn parse_runner_bin_value_non_string_array_errors() {
let err = parse_runner_bin_value(r#"["uv", 42]"#).unwrap_err();
assert!(
err.contains("string"),
"expected string-related error: {err}"
);
}
fn write_shim(stage: Stage, body: &str) -> tempfile::TempDir {
let dir = tempfile::TempDir::new().unwrap();
let hooks = dir.path().join("hooks");
std::fs::create_dir(&hooks).unwrap();
std::fs::write(hooks.join(stage.as_str()), body).unwrap();
dir
}
#[test]
fn read_shim_argv_returns_none_when_shim_missing() {
let dir = tempfile::TempDir::new().unwrap();
assert_eq!(
read_shim_argv(dir.path(), Stage::PreCommit, Runner::Prek),
None
);
}
#[test]
fn read_shim_argv_returns_none_when_shim_unrecognised() {
let dir = write_shim(Stage::PreCommit, "#!/bin/sh\nexec prek hook-impl\n");
assert_eq!(
read_shim_argv(dir.path(), Stage::PreCommit, Runner::Prek),
None
);
}
#[test]
fn read_shim_argv_picks_up_prek_install_format() {
let body = r#"#!/bin/sh
HERE="$(cd "$(dirname "$0")" && pwd)"
PREK="/bin/sh"
if [ ! -x "$PREK" ]; then
PREK="prek"
fi
exec "$PREK" hook-impl --hook-dir "$HERE" --script-version 4 --hook-type=pre-commit -- "$@"
"#;
let dir = write_shim(Stage::PreCommit, body);
let argv = read_shim_argv(dir.path(), Stage::PreCommit, Runner::Prek);
assert_eq!(argv, Some(vec!["/bin/sh".to_owned()]));
}
#[test]
fn read_shim_argv_picks_up_pre_commit_install_format() {
let body = r#"#!/usr/bin/env bash
# start templated
INSTALL_PYTHON=/bin/sh
ARGS=(hook-impl --config=.pre-commit-config.yaml --hook-type=pre-commit)
# end templated
HERE="$(cd "$(dirname "$0")" && pwd)"
ARGS+=(--hook-dir "$HERE" -- "$@")
exec "$INSTALL_PYTHON" -mpre_commit "${ARGS[@]}"
"#;
let dir = write_shim(Stage::PreCommit, body);
let argv = read_shim_argv(dir.path(), Stage::PreCommit, Runner::PreCommit);
assert_eq!(
argv,
Some(vec!["/bin/sh".to_owned(), "-mpre_commit".to_owned()])
);
}
#[test]
fn read_shim_argv_runner_specific_var_name() {
let prek_body = r#"PREK="/bin/sh""#;
let dir = write_shim(Stage::PreCommit, prek_body);
assert_eq!(
read_shim_argv(dir.path(), Stage::PreCommit, Runner::PreCommit),
None,
"PREK= line must not satisfy the pre-commit shim probe"
);
let pc_body = "INSTALL_PYTHON=/bin/sh";
let dir = write_shim(Stage::PreCommit, pc_body);
assert_eq!(
read_shim_argv(dir.path(), Stage::PreCommit, Runner::Prek),
None,
"INSTALL_PYTHON= line must not satisfy the prek shim probe"
);
}
#[test]
fn read_shim_argv_skips_assignments_pointing_at_nonexistent_path() {
let body = r#"PREK="/nonexistent/path/to/prek""#;
let dir = write_shim(Stage::PreCommit, body);
assert_eq!(
read_shim_argv(dir.path(), Stage::PreCommit, Runner::Prek),
None
);
}
#[test]
fn read_shim_argv_skips_relative_paths() {
let body = r#"PREK="prek""#;
let dir = write_shim(Stage::PreCommit, body);
assert_eq!(
read_shim_argv(dir.path(), Stage::PreCommit, Runner::Prek),
None
);
}
#[test]
fn read_shim_argv_honours_stage() {
let body = r#"PREK="/bin/sh""#;
let dir = write_shim(Stage::PreCommit, body);
assert_eq!(
read_shim_argv(dir.path(), Stage::PreCommit, Runner::Prek),
Some(vec!["/bin/sh".to_owned()])
);
assert_eq!(
read_shim_argv(dir.path(), Stage::PrePush, Runner::Prek),
None
);
}
#[test]
fn read_shim_argv_accepts_export_prefix() {
let body = r#"export PREK="/bin/sh""#;
let dir = write_shim(Stage::PreCommit, body);
assert_eq!(
read_shim_argv(dir.path(), Stage::PreCommit, Runner::Prek),
Some(vec!["/bin/sh".to_owned()])
);
}
#[test]
fn read_shim_argv_only_matches_exact_variable_name() {
let body = r#"PREKABLE="/bin/sh""#;
let dir = write_shim(Stage::PreCommit, body);
assert_eq!(
read_shim_argv(dir.path(), Stage::PreCommit, Runner::Prek),
None
);
}
#[test]
fn read_shim_argv_returns_none_for_lefthook_and_hk() {
let body = r#"PREK="/bin/sh""#;
let dir = write_shim(Stage::PreCommit, body);
assert_eq!(
read_shim_argv(dir.path(), Stage::PreCommit, Runner::Lefthook),
None
);
assert_eq!(
read_shim_argv(dir.path(), Stage::PreCommit, Runner::Hk),
None
);
}
}