use std::path::{Path, PathBuf};
use std::process::Command;
use super::PipelineError;
fn git_bin() -> String {
std::env::var("GIT_BIN").unwrap_or_else(|_| "git".to_string())
}
fn git_at(dir: &Path) -> Command {
let mut cmd = Command::new(git_bin());
cmd.arg("-C")
.arg(dir)
.args([
"-c",
"core.quotePath=false",
"-c",
"commit.gpgsign=false",
"-c",
"user.name=orchestratectl pipeline",
"-c",
"user.email=pipeline@orchestratectl.local",
])
.env("LC_ALL", "C");
cmd
}
pub fn git(dir: &Path, args: &[&str]) -> Result<String, PipelineError> {
let out = git_at(dir).args(args).output().map_err(|e| {
PipelineError::Git(format!(
"could not run git {} in {}: {e}",
args.join(" "),
dir.display()
))
})?;
if !out.status.success() {
return Err(PipelineError::Git(format!(
"git {} failed in {}: {}",
args.join(" "),
dir.display(),
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
pub fn toplevel(dir: &Path) -> Result<PathBuf, PipelineError> {
Ok(PathBuf::from(git(dir, &["rev-parse", "--show-toplevel"])?))
}
pub fn resolve_commit(dir: &Path, rev: &str) -> Result<String, PipelineError> {
git(
dir,
&["rev-parse", "--verify", &format!("{rev}^{{commit}}")],
)
.map_err(|_| {
PipelineError::Git(format!(
"`{rev}` does not resolve to a commit in {}",
dir.display()
))
})
}
pub fn branch_exists(dir: &Path, branch: &str) -> bool {
git_at(dir)
.args([
"show-ref",
"--verify",
"--quiet",
&format!("refs/heads/{branch}"),
])
.status()
.is_ok_and(|s| s.success())
}
pub fn create_branch(dir: &Path, name: &str, start: &str) -> Result<(), PipelineError> {
git(dir, &["branch", name, start])?;
Ok(())
}
pub fn delete_branch(dir: &Path, name: &str, force: bool) -> Result<(), PipelineError> {
let flag = if force { "-D" } else { "-d" };
git(dir, &["branch", flag, name])?;
Ok(())
}
pub fn worktree_add(dir: &Path, path: &Path, branch: &str) -> Result<(), PipelineError> {
git(
dir,
&["worktree", "add", &path.display().to_string(), branch],
)?;
Ok(())
}
pub fn worktree_add_new_branch(
dir: &Path,
path: &Path,
new_branch: &str,
start: &str,
) -> Result<(), PipelineError> {
git(
dir,
&[
"worktree",
"add",
"-b",
new_branch,
&path.display().to_string(),
start,
],
)?;
Ok(())
}
pub fn worktree_remove(dir: &Path, path: &Path) -> Result<(), PipelineError> {
git(
dir,
&["worktree", "remove", "--force", &path.display().to_string()],
)?;
Ok(())
}
pub fn head(worktree: &Path) -> Result<String, PipelineError> {
resolve_commit(worktree, "HEAD")
}
pub fn is_clean(worktree: &Path) -> Result<bool, PipelineError> {
Ok(git(worktree, &["status", "--porcelain"])?.is_empty())
}
pub fn is_ancestor(dir: &Path, ancestor: &str, descendant: &str) -> Result<bool, PipelineError> {
let out = git_at(dir)
.args(["merge-base", "--is-ancestor", ancestor, descendant])
.output()
.map_err(|e| {
PipelineError::Git(format!(
"could not run git merge-base in {}: {e}",
dir.display()
))
})?;
match out.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
_ => Err(PipelineError::Git(format!(
"git merge-base --is-ancestor failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
))),
}
}
pub fn range_has_merge(dir: &Path, base: &str, tip: &str) -> Result<bool, PipelineError> {
let out = git(
dir,
&["rev-list", "--merges", "--count", &format!("{base}..{tip}")],
)?;
let n = out.trim().parse::<usize>().map_err(|e| {
PipelineError::Git(format!(
"could not parse rev-list --merges count {out:?}: {e}"
))
})?;
Ok(n > 0)
}
pub fn restore_to(worktree: &Path, rev: &str) -> Result<(), PipelineError> {
git(worktree, &["reset", "--hard", rev])?;
git(worktree, &["clean", "-fdq"])?;
Ok(())
}
pub fn diff(worktree: &Path, base: &str, tip: &str) -> Result<String, PipelineError> {
let out = git(worktree, &["diff", base, tip])?;
if out.len() > DIFF_CAP_BYTES {
let mut end = DIFF_CAP_BYTES.min(out.len());
while end > 0 && !out.is_char_boundary(end) {
end -= 1;
}
if let Some(nl) = out[..end].rfind('\n') {
end = nl;
}
Ok(format!(
"{}\n… [diff truncated at ~{DIFF_CAP_BYTES} bytes]",
&out[..end]
))
} else {
Ok(out)
}
}
const DIFF_CAP_BYTES: usize = 16 * 1024;
pub fn cherry_pick(worktree: &Path, base: &str, tip: &str) -> Result<MergeOutcome, PipelineError> {
let out = git_at(worktree)
.args(["cherry-pick", &format!("{base}..{tip}")])
.output()
.map_err(|e| {
PipelineError::Git(format!(
"could not run git cherry-pick in {}: {e}",
worktree.display()
))
})?;
if out.status.success() {
return Ok(MergeOutcome::Merged {
commit: head(worktree)?,
});
}
let details = format!(
"{} {}",
String::from_utf8_lossy(&out.stdout).trim(),
String::from_utf8_lossy(&out.stderr).trim()
)
.trim()
.to_string();
let in_pick = git_at(worktree)
.args(["rev-parse", "-q", "--verify", "CHERRY_PICK_HEAD"])
.output()
.is_ok_and(|o| o.status.success());
if in_pick {
let _ = git(worktree, &["cherry-pick", "--abort"]);
Ok(MergeOutcome::Conflict { details })
} else {
Err(PipelineError::Git(format!(
"git cherry-pick failed in {} (not a content conflict): {details}",
worktree.display()
)))
}
}
pub fn update_ref(dir: &Path, ref_name: &str, oid: &str) -> Result<(), PipelineError> {
git(dir, &["update-ref", ref_name, oid])?;
Ok(())
}
pub fn refs_under(dir: &Path, prefix: &str) -> Result<Vec<String>, PipelineError> {
let out = git(dir, &["for-each-ref", "--format=%(refname)", prefix])?;
Ok(out
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect())
}
pub fn delete_ref(dir: &Path, ref_name: &str) -> Result<(), PipelineError> {
git(dir, &["update-ref", "-d", ref_name])?;
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MergeOutcome {
Merged { commit: String },
Conflict { details: String },
}
pub fn merge_no_ff(
worktree: &Path,
branch: &str,
message: &str,
) -> Result<MergeOutcome, PipelineError> {
let out = git_at(worktree)
.args(["merge", "--no-ff", "--no-edit", "-m", message, branch])
.output()
.map_err(|e| {
PipelineError::Git(format!(
"could not run git merge in {}: {e}",
worktree.display()
))
})?;
if out.status.success() {
return Ok(MergeOutcome::Merged {
commit: head(worktree)?,
});
}
let details = format!(
"{} {}",
String::from_utf8_lossy(&out.stdout).trim(),
String::from_utf8_lossy(&out.stderr).trim()
)
.trim()
.to_string();
let in_merge = git_at(worktree)
.args(["rev-parse", "-q", "--verify", "MERGE_HEAD"])
.output()
.is_ok_and(|o| o.status.success());
if in_merge {
let _ = git(worktree, &["merge", "--abort"]);
Ok(MergeOutcome::Conflict { details })
} else {
Err(PipelineError::Git(format!(
"git merge failed in {} (not a content conflict): {details}",
worktree.display()
)))
}
}
pub fn commits_ahead_of(dir: &Path, base: &str, branch: &str) -> Result<usize, PipelineError> {
let out = git(dir, &["rev-list", "--count", &format!("{base}..{branch}")])?;
out.trim()
.parse::<usize>()
.map_err(|e| PipelineError::Git(format!("could not parse rev-list count {out:?}: {e}")))
}
pub fn worktree_for_branch(dir: &Path, branch: &str) -> Result<Option<PathBuf>, PipelineError> {
let listing = git(dir, &["worktree", "list", "--porcelain"])?;
let want = format!("refs/heads/{branch}");
let mut current_path: Option<PathBuf> = None;
for line in listing.lines() {
if let Some(p) = line.strip_prefix("worktree ") {
current_path = Some(PathBuf::from(p));
} else if let Some(b) = line.strip_prefix("branch ") {
if b == want {
return Ok(current_path);
}
}
}
Ok(None)
}