use anyhow::{bail, Context, Result};
use std::process::Command;
pub fn get_current_branch() -> Result<String> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.context("Failed to execute git rev-parse")?;
if !output.status.success() {
bail!("Not in a git repository or HEAD is detached");
}
let branch = String::from_utf8(output.stdout)
.context("Invalid UTF-8 in branch name")?
.trim()
.to_string();
Ok(branch)
}
pub fn detect_base_branch() -> Result<String> {
if let Ok(remote_head) = get_remote_default_branch() {
return Ok(remote_head);
}
for candidate in &["main", "master", "develop"] {
if branch_exists(candidate)? {
return Ok(candidate.to_string());
}
}
bail!("Could not detect base branch. Please specify with --base")
}
fn get_remote_default_branch() -> Result<String> {
let output = Command::new("git")
.args(["symbolic-ref", "refs/remotes/origin/HEAD"])
.output()
.context("Failed to get remote HEAD")?;
if !output.status.success() {
bail!("Remote HEAD not set");
}
let full_ref = String::from_utf8(output.stdout)?.trim().to_string();
let branch = full_ref
.strip_prefix("refs/remotes/origin/")
.context("Unexpected ref format")?
.to_string();
Ok(branch)
}
fn branch_exists(branch: &str) -> Result<bool> {
let output = Command::new("git")
.args(["rev-parse", "--verify", &format!("refs/heads/{}", branch)])
.output()
.context("Failed to verify branch")?;
Ok(output.status.success())
}
pub fn fetch_remote_branch(branch: &str) -> Result<()> {
let output = Command::new("git")
.args(["fetch", "origin", branch])
.output()
.context("Failed to fetch remote branch")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to fetch origin/{}: {}", branch, stderr);
}
Ok(())
}
pub fn check_merge_tree(current_branch: &str, base_branch: &str) -> Result<MergeResult> {
let output = Command::new("git")
.args(["merge-tree", "--write-tree", base_branch, current_branch])
.output()
.context("Failed to execute git merge-tree")?;
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8(output.stdout).context("Invalid UTF-8 in merge-tree output")?;
if stderr.contains("unknown option")
|| stderr.contains("unrecognized argument")
|| stderr.contains("--write-tree")
{
bail!("Your Git version does not support modern merge-tree.\nPlease upgrade to Git >= 2.38.0\n\nCurrent error: {}", stderr);
}
if !output.status.success() && !stderr.is_empty() {
bail!("git merge-tree failed: {}", stderr);
}
parse_merge_tree_output(&stdout)
}
#[derive(Debug, Clone)]
pub struct MergeResult {
pub has_conflicts: bool,
pub conflicted_files: Vec<String>,
#[allow(dead_code)]
pub conflict_diffs: Vec<ConflictDiff>,
}
#[derive(Debug, Clone)]
pub struct ConflictDiff {
pub filename: String,
pub hunks: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct BranchStats {
pub ahead: usize,
pub behind: usize,
pub files_changed: usize,
pub insertions: usize,
pub deletions: usize,
pub merge_base: String,
pub merge_base_subject: String,
}
fn parse_merge_tree_output(output: &str) -> Result<MergeResult> {
if output.trim().is_empty() {
bail!("Empty output from git merge-tree");
}
let lines: Vec<&str> = output.lines().collect();
let has_conflicts = lines.len() > 1 && output.contains("CONFLICT");
let mut conflicted_files = Vec::new();
if has_conflicts {
for line in &lines[1..] {
if let Some(conflict_marker) = line.find("Merge conflict in ") {
let filename = &line[conflict_marker + 18..].trim();
conflicted_files.push(filename.to_string());
} else if let Some(conflict_marker) = line.find("CONFLICT") {
let rest = &line[conflict_marker..];
if let Some(in_pos) = rest.find(" in ") {
let filename = rest[in_pos + 4..].trim();
if !filename.is_empty() && !conflicted_files.contains(&filename.to_string()) {
conflicted_files.push(filename.to_string());
}
}
}
}
if conflicted_files.is_empty() {
conflicted_files
.push("(unable to parse specific files - check git output)".to_string());
}
}
Ok(MergeResult {
has_conflicts,
conflicted_files,
conflict_diffs: Vec::new(), })
}
pub fn get_conflict_diffs(
current_branch: &str,
base_branch: &str,
files: &[String],
) -> Vec<ConflictDiff> {
let mut diffs = Vec::new();
for file in files {
let output = Command::new("git")
.args(["diff", base_branch, current_branch, "--", file])
.output();
match output {
Ok(out) if out.status.success() => {
let diff_text = String::from_utf8_lossy(&out.stdout).to_string();
let hunks = extract_hunks(&diff_text);
if !hunks.is_empty() {
diffs.push(ConflictDiff {
filename: file.clone(),
hunks,
});
}
}
_ => {
}
}
}
diffs
}
fn extract_hunks(diff: &str) -> Vec<String> {
let mut hunks = Vec::new();
let mut current_hunk = String::new();
let mut in_hunk = false;
for line in diff.lines() {
if line.starts_with("@@") {
if in_hunk && !current_hunk.is_empty() {
hunks.push(current_hunk.clone());
}
current_hunk = String::new();
current_hunk.push_str(line);
current_hunk.push('\n');
in_hunk = true;
} else if in_hunk {
if line.starts_with("diff --git") || line.starts_with("index ") {
if !current_hunk.is_empty() {
hunks.push(current_hunk.clone());
}
current_hunk = String::new();
in_hunk = false;
} else {
current_hunk.push_str(line);
current_hunk.push('\n');
}
}
}
if in_hunk && !current_hunk.is_empty() {
hunks.push(current_hunk);
}
hunks
}
pub fn get_branch_stats(current_branch: &str, base_branch: &str) -> Result<BranchStats> {
let merge_base_output = Command::new("git")
.args(["merge-base", current_branch, base_branch])
.output()
.context("Failed to get merge base")?;
let merge_base = String::from_utf8(merge_base_output.stdout)
.context("Invalid UTF-8 in merge base")?
.trim()
.to_string();
let subject_output = Command::new("git")
.args(["log", "-1", "--format=%s", &merge_base])
.output();
let merge_base_subject = subject_output
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_default();
let rev_list_output = Command::new("git")
.args([
"rev-list",
"--left-right",
"--count",
&format!("{}...{}", base_branch, current_branch),
])
.output()
.context("Failed to get ahead/behind counts")?;
let rev_list =
String::from_utf8(rev_list_output.stdout).context("Invalid UTF-8 in rev-list")?;
let parts: Vec<&str> = rev_list.split_whitespace().collect();
let behind = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
let ahead = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
let diff_output = Command::new("git")
.args(["diff", "--shortstat", base_branch, current_branch])
.output()
.context("Failed to get diff stats")?;
let diff_stat = String::from_utf8(diff_output.stdout).context("Invalid UTF-8 in diff stat")?;
let files_changed = extract_number(&diff_stat, "file");
let insertions = extract_number(&diff_stat, "insertion");
let deletions = extract_number(&diff_stat, "deletion");
Ok(BranchStats {
ahead,
behind,
files_changed,
insertions,
deletions,
merge_base: merge_base.chars().take(7).collect(),
merge_base_subject,
})
}
fn extract_number(text: &str, keyword: &str) -> usize {
text.split(',')
.find(|part| part.contains(keyword))
.and_then(|part| {
part.split_whitespace()
.next()
.and_then(|num| num.parse().ok())
})
.unwrap_or(0)
}