use crate::error::{BallError, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
fn run_git_in(dir: &Path, args: &[&str]) -> Result<std::process::Output> {
let out = Command::new("git")
.current_dir(dir)
.args(args)
.output()
.map_err(|e| BallError::Git(format!("failed to spawn git: {}", e)))?;
Ok(out)
}
fn run_git_ok(dir: &Path, args: &[&str]) -> Result<String> {
let out = run_git_in(dir, args)?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
return Err(BallError::Git(format!(
"git {}: {}",
args.join(" "),
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
pub fn git_root(from: &Path) -> Result<PathBuf> {
let out = run_git_in(from, &["rev-parse", "--show-toplevel"])?;
if !out.status.success() {
return Err(BallError::NotARepo);
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
Ok(PathBuf::from(s))
}
pub fn git_common_dir(from: &Path) -> Result<PathBuf> {
let out = run_git_ok(from, &["rev-parse", "--git-common-dir"])?;
let p = PathBuf::from(out.trim());
if p.is_absolute() {
Ok(p)
} else {
Ok(from.join(p))
}
}
pub fn git_add(dir: &Path, paths: &[&Path]) -> Result<()> {
let mut args = vec!["add", "--"];
let strs: Vec<String> = paths.iter().map(|p| p.to_string_lossy().to_string()).collect();
for s in &strs {
args.push(s.as_str());
}
run_git_ok(dir, &args)?;
Ok(())
}
pub fn git_rm(dir: &Path, paths: &[&Path]) -> Result<()> {
let mut args = vec!["rm", "--"];
let strs: Vec<String> = paths.iter().map(|p| p.to_string_lossy().to_string()).collect();
for s in &strs {
args.push(s.as_str());
}
run_git_ok(dir, &args)?;
Ok(())
}
pub fn git_add_all(dir: &Path) -> Result<()> {
run_git_ok(dir, &["add", "-A"])?;
Ok(())
}
pub fn git_commit(dir: &Path, message: &str) -> Result<()> {
if !has_staged_changes(dir)? && !is_merging(dir) {
return Ok(());
}
run_git_ok(dir, &["commit", "-m", message, "--no-verify"])?;
Ok(())
}
pub fn is_merging(dir: &Path) -> bool {
git_common_dir(dir)
.map(|c| c.join("MERGE_HEAD").exists())
.unwrap_or(false)
}
pub fn has_staged_changes(dir: &Path) -> Result<bool> {
let out = run_git_in(dir, &["diff", "--cached", "--quiet"])?;
Ok(!out.status.success())
}
pub fn has_uncommitted_changes(dir: &Path) -> Result<bool> {
let out = run_git_ok(dir, &["status", "--porcelain"])?;
Ok(!out.trim().is_empty())
}
pub fn git_fetch(dir: &Path, remote: &str) -> Result<bool> {
let out = run_git_in(dir, &["fetch", remote])?;
Ok(out.status.success())
}
#[derive(Debug, PartialEq, Eq)]
pub enum MergeResult {
Clean,
UpToDate,
Conflict,
}
pub fn git_merge(dir: &Path, branch: &str, message: Option<&str>) -> Result<MergeResult> {
git_merge_inner(dir, branch, message, false)
}
pub fn git_merge_no_ff(dir: &Path, branch: &str, message: Option<&str>) -> Result<MergeResult> {
git_merge_inner(dir, branch, message, true)
}
pub fn git_merge_squash(dir: &Path, branch: &str) -> Result<MergeResult> {
let args = vec!["merge", "--squash", branch];
let out = run_git_in(dir, &args)?;
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
if out.status.success() {
if stdout.contains("Already up to date") || stdout.contains("Already up-to-date") {
Ok(MergeResult::UpToDate)
} else {
Ok(MergeResult::Clean)
}
} else {
let combined = format!("{}{}", stdout, stderr);
if combined.contains("CONFLICT") || combined.contains("conflict") {
Ok(MergeResult::Conflict)
} else {
Err(BallError::Git(format!(
"merge --squash failed: {}",
combined.trim()
)))
}
}
}
fn git_merge_inner(dir: &Path, branch: &str, message: Option<&str>, no_ff: bool) -> Result<MergeResult> {
let mut args = vec!["merge", "--no-edit"];
if no_ff {
args.push("--no-ff");
}
if let Some(m) = message {
args.push("-m");
args.push(m);
}
args.push(branch);
let out = run_git_in(dir, &args)?;
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
if out.status.success() {
if stdout.contains("Already up to date") || stdout.contains("Already up-to-date") {
Ok(MergeResult::UpToDate)
} else {
Ok(MergeResult::Clean)
}
} else {
let combined = format!("{}{}", stdout, stderr);
if combined.contains("CONFLICT") || combined.contains("conflict") {
Ok(MergeResult::Conflict)
} else {
Err(BallError::Git(format!(
"merge failed: {}",
combined.trim()
)))
}
}
}
pub fn git_push(dir: &Path, remote: &str, branch: &str) -> Result<()> {
run_git_ok(dir, &["push", remote, branch])?;
Ok(())
}
pub fn git_worktree_add(dir: &Path, path: &Path, branch: &str) -> Result<()> {
let path_str = path.to_string_lossy().to_string();
run_git_ok(dir, &["worktree", "add", &path_str, "-b", branch])?;
Ok(())
}
pub fn git_worktree_remove(dir: &Path, path: &Path, force: bool) -> Result<()> {
let path_str = path.to_string_lossy().to_string();
let mut args = vec!["worktree", "remove"];
if force {
args.push("--force");
}
args.push(&path_str);
run_git_ok(dir, &args)?;
Ok(())
}
pub fn git_branch_delete(dir: &Path, branch: &str, force: bool) -> Result<()> {
let flag = if force { "-D" } else { "-d" };
run_git_ok(dir, &["branch", flag, branch])?;
Ok(())
}
pub fn git_has_remote(dir: &Path, remote: &str) -> bool {
run_git_ok(dir, &["remote", "get-url", remote]).is_ok()
}
pub fn git_current_branch(dir: &Path) -> Result<String> {
let out = run_git_ok(dir, &["rev-parse", "--abbrev-ref", "HEAD"])?;
Ok(out.trim().to_string())
}
pub fn git_has_any_commits(dir: &Path) -> bool {
run_git_ok(dir, &["rev-parse", "HEAD"]).is_ok()
}
pub fn git_init_commit(dir: &Path) -> Result<()> {
if git_has_any_commits(dir) {
return Ok(());
}
run_git_ok(dir, &["commit", "--allow-empty", "-m", "initial commit", "--no-verify"])?;
Ok(())
}
pub fn git_list_conflicted_files(dir: &Path) -> Result<Vec<PathBuf>> {
let out = run_git_ok(dir, &["diff", "--name-only", "--diff-filter=U"])?;
let files = out
.lines()
.filter(|l| !l.is_empty())
.map(|l| dir.join(l))
.collect();
Ok(files)
}
pub(crate) fn git_ensure_user(dir: &Path) -> Result<()> {
let email = run_git_ok(dir, &["config", "user.email"]).unwrap_or_default();
if email.trim().is_empty() {
run_git_ok(dir, &["config", "user.email", "balls@example.local"])?;
}
let name = run_git_ok(dir, &["config", "user.name"]).unwrap_or_default();
if name.trim().is_empty() {
run_git_ok(dir, &["config", "user.name", "balls"])?;
}
Ok(())
}