use std::path::{Path, PathBuf};
use std::process::Command;
use crate::bookmark_updates::BookmarkUpdate;
use crate::error::{JjHooksError, Result};
use crate::jj::JjCli;
use crate::runner::{
Runner, Stage, hook_command, hook_command_all_files, lefthook_command,
lefthook_command_all_files,
};
use crate::setup::{self, SetupStep};
use crate::worktree::Worktree;
#[derive(Debug, Clone)]
pub struct HookOutcome {
pub success: bool,
pub fixup_commit: Option<String>,
pub retried: bool,
pub initial_failure: bool,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RunOpts {
pub retry_after_fixup: bool,
pub all_files: bool,
}
pub fn run_for_update(
jj: &JjCli,
primary_git_dir: &Path,
workspace_root: &Path,
cli_runner: Option<Runner>,
stage: Stage,
update: &BookmarkUpdate,
opts: RunOpts,
) -> Result<HookOutcome> {
let Some(new_commit) = update.new_commit.as_ref() else {
return Ok(HookOutcome {
success: true,
fixup_commit: None,
retried: false,
initial_failure: false,
});
};
let from_refs = resolve_from_refs(jj, update)?;
let setup_steps = setup::load_steps(jj)?;
let initial = run_once(
jj,
primary_git_dir,
workspace_root,
cli_runner,
stage,
update,
new_commit,
&from_refs,
&setup_steps,
opts.all_files,
)?;
if !opts.retry_after_fixup || initial.success || initial.fixup_commit.is_none() {
return Ok(HookOutcome {
success: initial.success,
fixup_commit: initial.fixup_commit,
retried: false,
initial_failure: !initial.success,
});
}
let fixup = initial.fixup_commit.as_ref().expect("checked Some above");
tracing::info!(
"{update}: re-running hooks against fixup commit {fixup} to check for transient failure"
);
let retry = run_once(
jj,
primary_git_dir,
workspace_root,
cli_runner,
stage,
update,
fixup,
&from_refs,
&setup_steps,
opts.all_files,
)?;
let healed = retry.success && retry.fixup_commit.is_none();
Ok(HookOutcome {
success: if healed { true } else { retry.success },
fixup_commit: if healed {
initial.fixup_commit
} else {
retry.fixup_commit.or(initial.fixup_commit)
},
retried: true,
initial_failure: true,
})
}
struct OnceOutcome {
success: bool,
fixup_commit: Option<String>,
}
#[allow(clippy::too_many_arguments)]
fn run_once(
jj: &JjCli,
primary_git_dir: &Path,
workspace_root: &Path,
cli_runner: Option<Runner>,
stage: Stage,
update: &BookmarkUpdate,
target_commit: &str,
from_refs: &[String],
setup_steps: &[SetupStep],
all_files: bool,
) -> Result<OnceOutcome> {
let wt = Worktree::create(primary_git_dir, target_commit)?;
setup::run_steps(setup_steps, wt.path(), workspace_root)?;
let runner = match cli_runner {
Some(r) => r,
None => {
let Some(r) = Runner::autodetect(wt.path())? else {
tracing::info!("{update}: no hook-runner config in target commit; skipping hooks");
return Ok(OnceOutcome {
success: true,
fixup_commit: None,
});
};
crate::runner::prefer_prek_when_available(r, crate::runner::prek_on_path())
}
};
let mut success = true;
if all_files {
let argv = match runner {
Runner::Lefthook => lefthook_command_all_files(stage),
_ => hook_command_all_files(runner, stage),
};
tracing::info!("running (--all-files): {:?}", argv);
let status = Command::new(&argv[0])
.args(&argv[1..])
.current_dir(wt.path())
.env("JJ_HOOKS_WORKSPACE", workspace_root)
.status()?;
if !status.success() {
success = false;
}
} else {
for from_ref in from_refs {
let argv = match runner {
Runner::Lefthook => {
let files = changed_files(wt.path(), from_ref, target_commit)?;
lefthook_command(stage, &files)
}
_ => hook_command(runner, stage, from_ref, target_commit),
};
tracing::info!("running: {:?}", argv);
let status = Command::new(&argv[0])
.args(&argv[1..])
.current_dir(wt.path())
.env("JJ_HOOKS_WORKSPACE", workspace_root)
.status()?;
if !status.success() {
success = false;
}
}
}
let fixup_commit =
maybe_build_fixup_commit(primary_git_dir, wt.path(), target_commit, &update.bookmark)?;
if fixup_commit.is_some() {
jj.run(&["git", "import", "--ignore-working-copy"])?;
let temp_bookmark = fixup_bookmark(&update.bookmark);
let _ = jj.run(&[
"bookmark",
"forget",
&temp_bookmark,
"--ignore-working-copy",
]);
let _ = delete_git_ref(primary_git_dir, &fixup_ref(&update.bookmark));
}
Ok(OnceOutcome {
success,
fixup_commit,
})
}
fn resolve_from_refs(jj: &JjCli, update: &BookmarkUpdate) -> Result<Vec<String>> {
if let Some(old) = update.old_commit.as_ref() {
return Ok(vec![old.clone()]);
}
let new = update.new_commit.as_ref().expect("not a delete here");
let revset = format!(
"heads(::{new} & ::remote_bookmarks(remote=exact:{}))",
update.remote
);
let template = r#"commit_id ++ "\n""#;
let out = jj.run(&[
"log",
"--no-graph",
"-r",
&revset,
"-T",
template,
"--ignore-working-copy",
])?;
let refs: Vec<String> = out
.lines()
.map(|l| l.trim().to_owned())
.filter(|l| !l.is_empty())
.collect();
if refs.is_empty() {
return Ok(vec![format!("{new}^")]);
}
Ok(refs)
}
fn changed_files(worktree: &Path, from: &str, to: &str) -> Result<Vec<PathBuf>> {
let out = Command::new("git")
.args(["diff", "--name-only", "--diff-filter=ACMR"])
.arg(format!("{from}..{to}"))
.current_dir(worktree)
.output()?;
if !out.status.success() {
return Err(JjHooksError::JjFailed {
status: out.status.code().unwrap_or(-1),
stderr: format!(
"git diff --name-only failed: {}",
String::from_utf8_lossy(&out.stderr)
),
});
}
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| PathBuf::from(l.trim()))
.filter(|p| !p.as_os_str().is_empty())
.collect())
}
fn maybe_build_fixup_commit(
primary_git_dir: &Path,
worktree: &Path,
parent: &str,
bookmark: &str,
) -> Result<Option<String>> {
run_git(worktree, &["add", "-A"])?;
let tree = run_git_capture(worktree, &["write-tree"])?;
let parent_tree_spec = format!("{parent}^{{tree}}");
let parent_tree = run_git_capture(worktree, &["rev-parse", &parent_tree_spec])?;
if tree == parent_tree {
return Ok(None);
}
let message = format!("jj-hooks: autofixes for {bookmark}");
let commit = run_git_capture_with_git_dir(
primary_git_dir,
worktree,
&["commit-tree", &tree, "-p", parent, "-m", &message],
)?;
let ref_name = fixup_ref(bookmark);
run_git_capture_with_git_dir(
primary_git_dir,
worktree,
&["update-ref", &ref_name, &commit],
)?;
Ok(Some(commit))
}
pub fn fixup_ref(bookmark: &str) -> String {
format!("refs/heads/jj-hooks-fixup/{}", sanitize_for_ref(bookmark))
}
pub fn fixup_bookmark(bookmark: &str) -> String {
format!("jj-hooks-fixup/{}", sanitize_for_ref(bookmark))
}
fn sanitize_for_ref(s: &str) -> String {
let mut out: String = s
.chars()
.map(|c| match c {
' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\' | '\x7f' => '_',
c if (c as u32) < 0x20 => '_',
c => c,
})
.collect();
while out.contains("..") {
out = out.replace("..", "__");
}
while out.contains("@{") {
out = out.replace("@{", "@_");
}
if out.starts_with('-') {
out.replace_range(0..1, "_");
}
if out.starts_with('.') {
out.replace_range(0..1, "_");
}
if out.ends_with('.') {
let n = out.len();
out.replace_range(n - 1..n, "_");
}
if out.ends_with(".lock") {
let n = out.len();
out.replace_range(n - 5..n - 4, "_");
}
if out.ends_with('/') {
let n = out.len();
out.replace_range(n - 1..n, "_");
}
while out.contains("//") {
out = out.replace("//", "/_");
}
if out.is_empty() {
return "_".into();
}
out
}
fn delete_git_ref(git_dir: &Path, ref_name: &str) -> Result<()> {
let out = Command::new("git")
.arg(format!("--git-dir={}", git_dir.display()))
.args(["update-ref", "-d", ref_name])
.output()?;
if !out.status.success() {
tracing::debug!(
"git update-ref -d {ref_name} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
Ok(())
}
fn run_git(cwd: &Path, args: &[&str]) -> Result<()> {
let out = Command::new("git").args(args).current_dir(cwd).output()?;
if !out.status.success() {
return Err(JjHooksError::JjFailed {
status: out.status.code().unwrap_or(-1),
stderr: format!(
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
),
});
}
Ok(())
}
fn run_git_capture(cwd: &Path, args: &[&str]) -> Result<String> {
let out = Command::new("git").args(args).current_dir(cwd).output()?;
if !out.status.success() {
return Err(JjHooksError::JjFailed {
status: out.status.code().unwrap_or(-1),
stderr: format!(
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
),
});
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
}
fn run_git_capture_with_git_dir(git_dir: &Path, cwd: &Path, args: &[&str]) -> Result<String> {
let out = Command::new("git")
.arg(format!("--git-dir={}", git_dir.display()))
.args(args)
.current_dir(cwd)
.output()?;
if !out.status.success() {
return Err(JjHooksError::JjFailed {
status: out.status.code().unwrap_or(-1),
stderr: format!(
"git --git-dir={} {args:?} failed: {}",
git_dir.display(),
String::from_utf8_lossy(&out.stderr)
),
});
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fixup_ref_for_plain_bookmark() {
assert_eq!(fixup_ref("main"), "refs/heads/jj-hooks-fixup/main");
}
#[test]
fn fixup_ref_keeps_internal_slash() {
assert_eq!(
fixup_ref("feature/foo"),
"refs/heads/jj-hooks-fixup/feature/foo"
);
}
#[test]
fn fixup_ref_scrubs_colon() {
assert_eq!(fixup_ref("revset:@"), "refs/heads/jj-hooks-fixup/revset_@");
}
#[test]
fn sanitize_replaces_each_invalid_char() {
assert_eq!(sanitize_for_ref("a:b"), "a_b");
assert_eq!(sanitize_for_ref("a~b"), "a_b");
assert_eq!(sanitize_for_ref("a^b"), "a_b");
assert_eq!(sanitize_for_ref("a?b"), "a_b");
assert_eq!(sanitize_for_ref("a*b"), "a_b");
assert_eq!(sanitize_for_ref("a[b"), "a_b");
assert_eq!(sanitize_for_ref("a\\b"), "a_b");
assert_eq!(sanitize_for_ref("a b"), "a_b");
assert_eq!(sanitize_for_ref("a\tb"), "a_b");
assert_eq!(sanitize_for_ref("a\x7fb"), "a_b");
}
#[test]
fn sanitize_collapses_double_dot() {
assert_eq!(sanitize_for_ref("a..b"), "a__b");
assert_eq!(sanitize_for_ref("a...b"), "a__.b");
assert!(!sanitize_for_ref("a....b").contains(".."));
}
#[test]
fn sanitize_collapses_at_brace() {
assert_eq!(sanitize_for_ref("a@{b"), "a@_b");
}
#[test]
fn sanitize_strips_leading_dash() {
assert_eq!(sanitize_for_ref("-foo"), "_foo");
}
#[test]
fn sanitize_strips_leading_dot() {
assert_eq!(sanitize_for_ref(".foo"), "_foo");
}
#[test]
fn sanitize_strips_trailing_dot() {
assert_eq!(sanitize_for_ref("foo."), "foo_");
}
#[test]
fn sanitize_strips_trailing_dot_lock() {
assert_eq!(sanitize_for_ref("foo.lock"), "foo_lock");
}
#[test]
fn sanitize_strips_trailing_slash() {
assert_eq!(sanitize_for_ref("foo/"), "foo_");
}
#[test]
fn sanitize_collapses_double_slash() {
assert_eq!(sanitize_for_ref("a//b"), "a/_b");
}
#[test]
fn sanitize_empty_becomes_underscore() {
assert_eq!(sanitize_for_ref(""), "_");
}
#[test]
fn fixup_bookmark_uses_same_sanitizer() {
assert_eq!(fixup_bookmark("revset:@"), "jj-hooks-fixup/revset_@");
}
}