use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
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, Default)]
pub struct Cancel(Arc<AtomicBool>);
impl Cancel {
pub fn new() -> Self {
Self(Arc::new(AtomicBool::new(false)))
}
pub fn never() -> Self {
Self::new()
}
pub fn cancel(&self) {
self.0.store(true, Ordering::Relaxed);
}
pub fn is_cancelled(&self) -> bool {
self.0.load(Ordering::Relaxed)
}
}
#[derive(Debug, Clone)]
pub struct HookOutcome {
pub success: bool,
pub fixup_commit: Option<String>,
pub retried: bool,
pub initial_failure: bool,
pub captured_output: Option<String>,
pub cancelled: bool,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RunOpts {
pub retry_after_fixup: bool,
pub all_files: bool,
pub capture_output: 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> {
run_for_update_with_cancel(
jj,
primary_git_dir,
workspace_root,
cli_runner,
stage,
update,
opts,
&Cancel::never(),
)
}
#[allow(clippy::too_many_arguments)]
pub fn run_for_update_with_cancel(
jj: &JjCli,
primary_git_dir: &Path,
workspace_root: &Path,
cli_runner: Option<Runner>,
stage: Stage,
update: &BookmarkUpdate,
opts: RunOpts,
cancel: &Cancel,
) -> 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,
captured_output: None,
cancelled: 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,
opts.capture_output,
cancel,
)?;
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,
captured_output: initial.captured_output,
cancelled: initial.cancelled,
});
}
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,
opts.capture_output,
cancel,
)?;
let healed = retry.success && retry.fixup_commit.is_none();
let captured_output = match (initial.captured_output, retry.captured_output) {
(Some(mut a), Some(b)) => {
a.push_str(&b);
Some(a)
}
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => 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,
captured_output,
cancelled: initial.cancelled || retry.cancelled,
})
}
#[allow(clippy::too_many_arguments)]
pub fn run_for_updates_parallel<S, F>(
jj: &JjCli,
primary_git_dir: &Path,
workspace_root: &Path,
cli_runner: Option<Runner>,
stage: Stage,
updates: &[BookmarkUpdate],
opts: RunOpts,
progress_start: S,
progress: F,
) -> Result<Vec<HookOutcome>>
where
S: Fn(usize, &BookmarkUpdate) + Send + Sync,
F: Fn(usize, &BookmarkUpdate, &HookOutcome) + Send + Sync,
{
assert!(
opts.capture_output,
"run_for_updates_parallel requires capture_output=true; parallel runs without capture garble the terminal",
);
use std::sync::Mutex;
let progress_start = &progress_start;
let progress = &progress;
let results: Vec<Mutex<Option<Result<HookOutcome>>>> =
(0..updates.len()).map(|_| Mutex::new(None)).collect();
let results_ref = &results;
let cancel = Cancel::new();
let cancel_ref = &cancel;
std::thread::scope(|s| {
for (idx, update) in updates.iter().enumerate() {
s.spawn(move || {
progress_start(idx, update);
let outcome = run_for_update_with_cancel(
jj,
primary_git_dir,
workspace_root,
cli_runner,
stage,
update,
opts,
cancel_ref,
);
if let Ok(o) = &outcome {
if !o.success && !o.cancelled {
cancel_ref.cancel();
}
progress(idx, update, o);
}
*results_ref[idx].lock().unwrap() = Some(outcome);
});
}
});
let mut out = Vec::with_capacity(updates.len());
for slot in results {
let result = slot
.into_inner()
.unwrap()
.expect("thread::scope joined all threads but a slot is still None");
out.push(result?);
}
Ok(out)
}
#[allow(clippy::too_many_arguments)]
pub fn run_for_partitioned_updates_parallel<S, F>(
jj: &JjCli,
primary_git_dir: &Path,
workspace_root: &Path,
cli_runner: Option<Runner>,
stage: Stage,
partitions: &[Vec<BookmarkUpdate>],
opts: RunOpts,
progress_start: S,
progress: F,
) -> Result<Vec<Vec<HookOutcome>>>
where
S: Fn(usize, usize, &BookmarkUpdate) + Send + Sync,
F: Fn(usize, usize, &BookmarkUpdate, &HookOutcome) + Send + Sync,
{
assert!(
opts.capture_output,
"run_for_partitioned_updates_parallel requires capture_output=true",
);
use std::sync::Mutex;
let progress_start = &progress_start;
let progress = &progress;
let results: Vec<Vec<Mutex<Option<Result<HookOutcome>>>>> = partitions
.iter()
.map(|p| (0..p.len()).map(|_| Mutex::new(None)).collect())
.collect();
let results_ref = &results;
std::thread::scope(|s| {
for (p_idx, partition) in partitions.iter().enumerate() {
let cancel = Cancel::new();
for (u_idx, update) in partition.iter().enumerate() {
let cancel = cancel.clone();
s.spawn(move || {
progress_start(p_idx, u_idx, update);
let outcome = run_for_update_with_cancel(
jj,
primary_git_dir,
workspace_root,
cli_runner,
stage,
update,
opts,
&cancel,
);
if let Ok(o) = &outcome {
if !o.success && !o.cancelled {
cancel.cancel();
}
progress(p_idx, u_idx, update, o);
}
*results_ref[p_idx][u_idx].lock().unwrap() = Some(outcome);
});
}
}
});
let mut out = Vec::with_capacity(partitions.len());
for partition_slots in results {
let mut partition_out = Vec::with_capacity(partition_slots.len());
for slot in partition_slots {
let result = slot
.into_inner()
.unwrap()
.expect("thread::scope joined but a slot is still None");
partition_out.push(result?);
}
out.push(partition_out);
}
Ok(out)
}
#[allow(clippy::too_many_arguments)]
pub fn run_for_updates_sequential<F>(
jj: &JjCli,
primary_git_dir: &Path,
workspace_root: &Path,
cli_runner: Option<Runner>,
stage: Stage,
updates: &[BookmarkUpdate],
opts: RunOpts,
progress: F,
) -> Result<Vec<HookOutcome>>
where
F: Fn(usize, &BookmarkUpdate, &HookOutcome),
{
let mut out = Vec::with_capacity(updates.len());
for (idx, update) in updates.iter().enumerate() {
let outcome = run_for_update(
jj,
primary_git_dir,
workspace_root,
cli_runner,
stage,
update,
opts,
)?;
progress(idx, update, &outcome);
out.push(outcome);
}
Ok(out)
}
struct OnceOutcome {
success: bool,
fixup_commit: Option<String>,
captured_output: Option<String>,
cancelled: bool,
}
fn splice_runner_prefix(prefix: &[String], command_argv: &[String]) -> Vec<String> {
let mut out = Vec::with_capacity(prefix.len() + command_argv.len().saturating_sub(1));
out.extend(prefix.iter().cloned());
if command_argv.len() > 1 {
out.extend(command_argv[1..].iter().cloned());
}
out
}
#[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,
capture_output: bool,
cancel: &Cancel,
) -> Result<OnceOutcome> {
if cancel.is_cancelled() {
return Ok(OnceOutcome {
success: true,
fixup_commit: None,
captured_output: None,
cancelled: true,
});
}
let wt = Worktree::create(primary_git_dir, target_commit)?;
let setup_captured = match setup::run_steps(setup_steps, wt.path(), workspace_root) {
Ok(captured) => captured,
Err(JjHooksError::SetupFailed {
name,
status,
captured,
}) => {
let mut buf = captured;
if !buf.ends_with('\n') {
buf.push('\n');
}
buf.push_str(&format!(
"setup step `{name}` exited with status {status}; \
skipping hook runner for this bookmark\n",
));
return Ok(OnceOutcome {
success: false,
fixup_commit: None,
captured_output: Some(buf),
cancelled: false,
});
}
Err(other) => return Err(other),
};
let runner = match cli_runner {
Some(r) => r,
None => {
let Some(r) = Runner::autodetect(wt.path())? else {
eprintln!(
"jj-hooks: {update}: no hook-runner config in target commit; skipping hooks"
);
return Ok(OnceOutcome {
success: true,
fixup_commit: None,
captured_output: None,
cancelled: false,
});
};
let prek_present = crate::runner::resolve_runner_argv(
Runner::Prek,
jj,
workspace_root,
primary_git_dir,
stage,
)
.is_ok();
crate::runner::prefer_prek_when_available(r, prek_present)
}
};
let runner_argv =
crate::runner::resolve_runner_argv(runner, jj, workspace_root, primary_git_dir, stage)?;
let mut success = true;
let mut captured = if capture_output {
Some(setup_captured)
} else {
None
};
let mut cancelled = false;
if all_files {
if cancel.is_cancelled() {
cancelled = true;
} else {
let argv = match runner {
Runner::Lefthook => lefthook_command_all_files(stage),
_ => hook_command_all_files(runner, stage),
};
let argv = splice_runner_prefix(&runner_argv, &argv);
tracing::info!("running (--all-files): {:?}", argv);
let ok = run_subprocess(&argv, wt.path(), workspace_root, captured.as_mut())?;
if !ok {
success = false;
}
}
} else {
for from_ref in from_refs {
if cancel.is_cancelled() {
cancelled = true;
break;
}
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),
};
let argv = splice_runner_prefix(&runner_argv, &argv);
tracing::info!("running: {:?}", argv);
let ok = run_subprocess(&argv, wt.path(), workspace_root, captured.as_mut())?;
if !ok {
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,
captured_output: captured,
cancelled,
})
}
fn run_subprocess(
argv: &[String],
cwd: &Path,
workspace_root: &Path,
capture: Option<&mut String>,
) -> Result<bool> {
let mut cmd = Command::new(&argv[0]);
cmd.args(&argv[1..])
.current_dir(cwd)
.env("JJ_HOOKS_WORKSPACE", workspace_root);
match capture {
None => {
let status = cmd.status()?;
Ok(status.success())
}
Some(buf) => {
let output = cmd.output()?;
buf.push_str(&format!("$ {}\n", argv.join(" ")));
buf.push_str(&String::from_utf8_lossy(&output.stdout));
if !output.stderr.is_empty() {
buf.push_str(&String::from_utf8_lossy(&output.stderr));
}
if !buf.ends_with('\n') {
buf.push('\n');
}
Ok(output.status.success())
}
}
}
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_@");
}
}