use crate::config::ResolvedLauncher;
use crate::error::{GwmError, Result};
use git2::Repository;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct LauncherContext<'a> {
pub worktree_path: &'a Path,
pub base: Option<&'a str>,
pub head: Option<&'a str>,
pub repo_workdir: Option<&'a Path>,
}
#[derive(Debug)]
pub struct ExpandedCommand {
pub argv: Vec<String>,
pub diff_file: Option<tempfile::NamedTempFile>,
}
impl ExpandedCommand {
pub fn binary(&self) -> Option<&str> {
self.argv.first().map(|s| s.as_str())
}
}
pub fn expand_command(template: &str, ctx: &LauncherContext<'_>) -> Result<ExpandedCommand> {
let uses_base = template.contains("{base}");
let uses_head = template.contains("{head}");
let uses_diff = template.contains("{diff}");
if uses_base && ctx.base.is_none() {
return Err(GwmError::Config(
"template uses {base} but no base ref was resolved".into(),
));
}
if uses_head && ctx.head.is_none() {
return Err(GwmError::Config(
"template uses {head} but no head ref was resolved".into(),
));
}
let worktree_path = ctx.worktree_path.to_string_lossy();
let path_str = shell_words::quote(&worktree_path);
let mut expanded = template.replace("{path}", &path_str);
if let Some(b) = ctx.base {
expanded = expanded.replace("{base}", b);
}
if let Some(h) = ctx.head {
expanded = expanded.replace("{head}", h);
}
let diff_file = if uses_diff {
let (b, h) = match (ctx.base, ctx.head) {
(Some(b), Some(h)) => (b, h),
_ => {
return Err(GwmError::Config(
"template uses {diff} but {base}/{head} could not be resolved".into(),
))
}
};
let workdir = ctx.repo_workdir.ok_or_else(|| {
GwmError::Config("template uses {diff} but no repo workdir was provided to the launcher".into())
})?;
let tmp = materialise_diff(workdir, b, h)?;
let diff_path = shell_words::quote(&tmp.path().to_string_lossy()).into_owned();
expanded = expanded.replace("{diff}", &diff_path);
Some(tmp)
} else {
None
};
let argv =
shell_words::split(&expanded).map_err(|e| GwmError::Other(format!("invalid shell line '{}': {}", expanded, e)))?;
Ok(ExpandedCommand { argv, diff_file })
}
pub fn git_diff_argv(base: &str, head: &str) -> Vec<String> {
vec!["diff".into(), "--end-of-options".into(), format!("{}..{}", base, head)]
}
pub fn git_rev_list_count_argv(base: &str, head: &str) -> Vec<String> {
vec![
"rev-list".into(),
"--count".into(),
"--end-of-options".into(),
format!("{}..{}", base, head),
]
}
fn materialise_diff(workdir: &Path, base: &str, head: &str) -> Result<tempfile::NamedTempFile> {
let output = Command::new("git")
.arg("-C")
.arg(workdir)
.args(git_diff_argv(base, head))
.output()
.map_err(|e| GwmError::CommandFailed(format!("git diff failed to spawn: {}", e)))?;
if !output.status.success() {
return Err(GwmError::CommandFailed(format!(
"git diff {}..{} exited with status {:?}: {}",
base,
head,
output.status.code(),
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let mut tmp = tempfile::Builder::new()
.prefix("gwm-review-")
.suffix(".diff")
.tempfile()
.map_err(GwmError::Io)?;
use std::io::Write as _;
tmp.write_all(&output.stdout).map_err(GwmError::Io)?;
tmp.flush().map_err(GwmError::Io)?;
Ok(tmp)
}
pub fn resolve_review_base(repo: &Repository, branch: &str, default_base: Option<&str>) -> String {
if let Some(upstream) = read_branch_merge(repo, branch) {
if upstream != branch {
return upstream;
}
}
if let Some(gwm_base) = read_branch_config(repo, branch, "gwm-base") {
return gwm_base;
}
if let Some(d) = default_base.map(str::trim).filter(|s| !s.is_empty()) {
return d.to_string();
}
if branch_exists(repo, "dev") {
"dev".to_string()
} else {
"main".to_string()
}
}
fn branch_exists(repo: &Repository, name: &str) -> bool {
repo.find_branch(name, git2::BranchType::Local).is_ok()
}
pub fn write_gwm_base(repo: &Repository, branch: &str, base: &str) -> Result<()> {
let mut cfg = repo.config()?;
cfg.set_str(&format!("branch.{}.gwm-base", branch), base)?;
Ok(())
}
fn read_branch_merge(repo: &Repository, branch: &str) -> Option<String> {
let cfg = repo.config().ok()?;
let raw = cfg.get_string(&format!("branch.{}.merge", branch)).ok()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed.strip_prefix("refs/heads/").unwrap_or(trimmed).to_string())
}
fn read_branch_config(repo: &Repository, branch: &str, leaf: &str) -> Option<String> {
let cfg = repo.config().ok()?;
let raw = cfg.get_string(&format!("branch.{}.{}", branch, leaf)).ok()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub fn count_commits_ahead(workdir: &Path, base: &str, head: &str) -> u32 {
let output = Command::new("git")
.arg("-C")
.arg(workdir)
.args(git_rev_list_count_argv(base, head))
.output();
let Ok(out) = output else { return 0 };
if !out.status.success() {
return 0;
}
String::from_utf8_lossy(&out.stdout).trim().parse::<u32>().unwrap_or(0)
}
pub fn locate_binary(expanded: &ExpandedCommand) -> Option<PathBuf> {
let bin = expanded.binary()?;
which::which(bin).ok()
}
pub fn missing_binary_for(launcher: &ResolvedLauncher) -> Option<String> {
let cleaned = launcher
.command
.replace("{base}", "BASE")
.replace("{head}", "HEAD")
.replace("{path}", "PATH")
.replace("{diff}", "/tmp/diff");
let tokens = shell_words::split(&cleaned).ok()?;
let bin = tokens.into_iter().find(|t| !t.contains('='))?;
if which::which(&bin).is_ok() {
None
} else {
Some(bin)
}
}