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, lefthook_command};
use crate::worktree::Worktree;
#[derive(Debug, Clone)]
pub struct HookOutcome {
pub success: bool,
pub fixup_commit: Option<String>,
}
pub fn run_for_update(
jj: &JjCli,
primary_git_dir: &Path,
runner: Runner,
stage: Stage,
update: &BookmarkUpdate,
) -> Result<HookOutcome> {
let Some(new_commit) = update.new_commit.as_ref() else {
return Ok(HookOutcome {
success: true,
fixup_commit: None,
});
};
let from_refs = resolve_from_refs(jj, update)?;
let wt = Worktree::create(primary_git_dir, new_commit)?;
let mut success = true;
for from_ref in &from_refs {
let argv = match runner {
Runner::Lefthook => {
let files = changed_files(wt.path(), from_ref, new_commit)?;
lefthook_command(stage, &files)
}
_ => hook_command(runner, stage, from_ref, new_commit),
};
tracing::info!("running: {:?}", argv);
let status = Command::new(&argv[0])
.args(&argv[1..])
.current_dir(wt.path())
.status()?;
if !status.success() {
success = false;
}
}
let fixup_commit = if worktree_dirty(wt.path())? {
Some(build_fixup_commit(
primary_git_dir,
wt.path(),
new_commit,
&update.bookmark,
)?)
} else {
None
};
if fixup_commit.is_some() {
jj.run(&["git", "import"])?;
}
Ok(HookOutcome {
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 worktree_dirty(worktree: &Path) -> Result<bool> {
let out = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(worktree)
.output()?;
Ok(!out.stdout.is_empty())
}
fn build_fixup_commit(
primary_git_dir: &Path,
worktree: &Path,
parent: &str,
bookmark: &str,
) -> Result<String> {
run_git(worktree, &["add", "-A"])?;
let tree = run_git_capture(worktree, &["write-tree"])?;
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(commit)
}
pub fn fixup_ref(bookmark: &str) -> String {
format!("refs/heads/jj-hooks-fixup/{bookmark}")
}
pub fn fixup_bookmark(bookmark: &str) -> String {
format!("jj-hooks-fixup/{bookmark}")
}
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())
}