use super::basic::{is_working_directory_clean, run_git};
use crate::vcs::{VcsError, VcsResult};
use std::path::Path;
use std::process::Stdio;
use tokio::process::Command;
use tracing::debug;
#[allow(dead_code)]
pub async fn check_merge_conflicts<P: AsRef<Path>>(
cwd: P,
branch_name: &str,
) -> VcsResult<Option<Vec<String>>> {
let cwd = cwd.as_ref();
let head_commit = run_git(&["rev-parse", "HEAD"], cwd).await?;
let head_commit = head_commit.trim();
let branch_commit = run_git(&["rev-parse", branch_name], cwd).await?;
let branch_commit = branch_commit.trim();
let merge_base = run_git(&["merge-base", head_commit, branch_commit], cwd).await?;
let merge_base = merge_base.trim();
let output = Command::new("git")
.args([
"merge-tree",
"--write-tree",
"--merge-base",
merge_base,
head_commit,
branch_commit,
])
.current_dir(cwd)
.output()
.await
.map_err(|e| VcsError::git_command(e.to_string()))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let exit_code = output.status.code().unwrap_or(-1);
if exit_code == 1 {
let conflict_files = parse_conflict_files_from_stdout(&stdout);
let conflict_files = if conflict_files.is_empty() {
parse_conflict_files_from_stderr(&stderr)
} else {
conflict_files
};
debug!(
"Detected {} conflicts in worktree at {} (exit_code: {}, files: {:?})",
conflict_files.len(),
cwd.display(),
exit_code,
conflict_files
);
if conflict_files.is_empty() {
debug!(
"Exit code 1 but no conflict files parsed. stdout: {}, stderr: {}",
stdout.trim(),
stderr.trim()
);
Ok(Some(vec!["<unknown>".to_string()]))
} else {
Ok(Some(conflict_files))
}
} else if exit_code == 0 {
debug!(
"No conflicts detected for {} in {}",
branch_name,
cwd.display()
);
Ok(None)
} else {
debug!(
"Merge tree command failed: exit_code={}, stdout={}, stderr={}",
exit_code,
stdout.trim(),
stderr.trim()
);
Err(VcsError::git_command(format!(
"Merge tree simulation failed (exit {}): {}",
exit_code, stderr
)))
}
}
fn parse_conflict_files_from_stdout(stdout: &str) -> Vec<String> {
let mut files = Vec::new();
let mut lines = stdout.lines();
if lines.next().is_none() {
return files;
}
for line in lines {
let line = line.trim();
if line.is_empty() {
break;
}
let parts: Vec<&str> = line.splitn(4, ' ').collect();
if parts.len() == 4 {
if let Ok(stage) = parts[2].parse::<u8>() {
if (1..=3).contains(&stage) {
let filename = parts[3].trim();
if !files.contains(&filename.to_string()) {
files.push(filename.to_string());
}
}
}
}
}
files
}
fn parse_conflict_files_from_stderr(stderr: &str) -> Vec<String> {
let mut files = Vec::new();
for line in stderr.lines() {
if line.contains("CONFLICT") {
if let Some(idx) = line.find(" in ") {
let file = line[idx + 4..].trim();
files.push(file.to_string());
} else if line.contains("deleted in") || line.contains("added in") {
if let Some(start) = line.find("): ") {
let rest = &line[start + 3..];
if let Some(end) = rest.find(" deleted") {
files.push(rest[..end].trim().to_string());
} else if let Some(end) = rest.find(" added") {
files.push(rest[..end].trim().to_string());
}
}
} else if line.contains("Rename") {
if let Some(start) = line.find("Rename ") {
let rest = &line[start + 7..];
if let Some(end) = rest.find("->") {
let file1 = rest[..end].trim();
files.push(file1.to_string());
let after_arrow = &rest[end + 2..];
if let Some(space_idx) = after_arrow.find(' ') {
let file2 = after_arrow[..space_idx].trim();
files.push(file2.to_string());
}
}
}
}
}
}
files
}
pub async fn merge_branch<P: AsRef<Path>>(cwd: P, branch_name: &str) -> VcsResult<()> {
let cwd = cwd.as_ref();
if !is_working_directory_clean(cwd).await? {
return Err(VcsError::git_command(
"Working directory is not clean. Commit or stash changes before merging.".to_string(),
));
}
let output = Command::new("git")
.args(["merge", "--no-ff", "--no-edit", branch_name])
.current_dir(cwd)
.output()
.await
.map_err(|e| VcsError::git_command(e.to_string()))?;
if output.status.success() {
debug!("Merged branch {} successfully", branch_name);
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("CONFLICT") {
let _ = run_git(&["merge", "--abort"], cwd).await;
Err(VcsError::git_command(format!(
"Merge conflict detected. Merge aborted. Files: {}",
parse_conflict_files_from_stderr(&stderr).join(", ")
)))
} else {
Err(VcsError::git_command(format!("Merge failed: {}", stderr)))
}
}
}
pub async fn merge<P: AsRef<Path>>(cwd: P, branch_name: &str) -> VcsResult<()> {
debug!(
module = module_path!(),
"Executing git command: git merge {} --no-edit (cwd: {:?})",
branch_name,
cwd.as_ref()
);
let output = Command::new("git")
.args(["merge", branch_name, "--no-edit"])
.current_dir(cwd.as_ref())
.stdin(Stdio::null())
.output()
.await
.map_err(|e| VcsError::git_command(format!("Failed to execute git merge: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined = format!("{}\n{}", stdout, stderr);
if combined.contains("CONFLICT") || combined.contains("Automatic merge failed") {
return Err(VcsError::git_conflict(combined.to_string()));
}
return Err(VcsError::git_command(format!(
"git merge {} failed: {}",
branch_name, combined
)));
}
Ok(())
}
#[allow(dead_code)]
pub async fn merge_abort<P: AsRef<Path>>(cwd: P) -> VcsResult<()> {
run_git(&["merge", "--abort"], cwd).await?;
Ok(())
}
pub async fn is_merge_in_progress<P: AsRef<Path>>(cwd: P) -> VcsResult<bool> {
let output = Command::new("git")
.args(["rev-parse", "-q", "--verify", "MERGE_HEAD"])
.current_dir(cwd.as_ref())
.stdin(Stdio::null())
.output()
.await
.map_err(|e| VcsError::git_command(format!("Failed to check merge state: {}", e)))?;
Ok(output.status.success())
}
pub async fn missing_merge_commits_since<P: AsRef<Path>>(
cwd: P,
base_revision: &str,
change_ids: &[String],
) -> VcsResult<Vec<String>> {
if change_ids.is_empty() {
return Ok(Vec::new());
}
let output = run_git(
&[
"log",
"--merges",
"--format=%s",
&format!("{}..HEAD", base_revision),
],
cwd,
)
.await?;
let merge_messages: Vec<&str> = output.lines().collect();
let mut missing = Vec::new();
for change_id in change_ids {
let expected = format!("Merge change: {}", change_id);
if !merge_messages.iter().any(|line| line.trim() == expected) {
missing.push(change_id.clone());
}
}
Ok(missing)
}
pub async fn merge_commit_hash_by_subject_since<P: AsRef<Path>>(
cwd: P,
base_revision: &str,
expected_subject: &str,
) -> VcsResult<Option<String>> {
let output = run_git(
&[
"log",
"--merges",
"--format=%H\t%s",
&format!("{}..HEAD", base_revision),
],
cwd,
)
.await?;
for line in output.lines().map(str::trim).filter(|s| !s.is_empty()) {
let mut parts = line.splitn(2, '\t');
let Some(hash) = parts.next() else {
continue;
};
let subject = parts.next().unwrap_or("");
if subject == expected_subject {
return Ok(Some(hash.to_string()));
}
}
Ok(None)
}
pub async fn first_parent_of<P: AsRef<Path>>(cwd: P, commit: &str) -> VcsResult<String> {
run_git(&["rev-parse", &format!("{}^1", commit)], cwd).await
}
pub async fn is_ancestor<P: AsRef<Path>>(
cwd: P,
ancestor: &str,
descendant: &str,
) -> VcsResult<bool> {
let output = Command::new("git")
.args(["merge-base", "--is-ancestor", ancestor, descendant])
.current_dir(cwd.as_ref())
.stdin(Stdio::null())
.output()
.await
.map_err(|e| VcsError::git_command(format!("Failed to execute git merge-base: {}", e)))?;
Ok(output.status.success())
}
pub async fn presync_merge_subject_mismatches_since<P: AsRef<Path>>(
cwd: P,
base_revision: &str,
change_id: &str,
) -> VcsResult<Vec<String>> {
let expected = format!("Pre-sync base into {}", change_id);
let output = run_git(
&[
"log",
"--merges",
"--format=%s",
&format!("{}..HEAD", base_revision),
],
cwd,
)
.await?;
let mut mismatches = Vec::new();
for subject in output.lines().map(str::trim).filter(|s| !s.is_empty()) {
if subject.starts_with("Pre-sync base into") && subject != expected {
mismatches.push(subject.to_string());
}
}
Ok(mismatches)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_presync_merge_subject_mismatches_since() {
let temp_dir = TempDir::new().unwrap();
let init = Command::new("git")
.args(["init", "-b", "main"])
.current_dir(temp_dir.path())
.output()
.await;
if init.is_err() {
return;
}
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.await;
std::fs::write(temp_dir.path().join("README.md"), "base\n").unwrap();
let _ = Command::new("git")
.args(["add", "-A"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "Base"])
.current_dir(temp_dir.path())
.output()
.await;
let base_revision = run_git(&["rev-parse", "HEAD"], temp_dir.path())
.await
.unwrap();
let _ = Command::new("git")
.args(["checkout", "-b", "ws-change-a"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["checkout", "main"])
.current_dir(temp_dir.path())
.output()
.await;
std::fs::write(temp_dir.path().join("main.txt"), "main\n").unwrap();
let _ = Command::new("git")
.args(["add", "-A"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "Main advance"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["checkout", "ws-change-a"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["merge", "--no-ff", "-m", "Pre-sync base into WRONG", "main"])
.current_dir(temp_dir.path())
.output()
.await;
let mismatches = presync_merge_subject_mismatches_since(
temp_dir.path(),
base_revision.trim(),
"change-a",
)
.await
.unwrap();
assert!(
mismatches.iter().any(|s| s == "Pre-sync base into WRONG"),
"Expected mismatch to include wrong pre-sync subject"
);
}
async fn init_test_repo(dir: &Path) {
let _ = Command::new("git")
.args(["init", "-b", "main"])
.current_dir(dir)
.output()
.await
.unwrap();
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(dir)
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(dir)
.output()
.await;
std::fs::write(dir.join("README.md"), "initial\n").unwrap();
let _ = Command::new("git")
.args(["add", "-A"])
.current_dir(dir)
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(dir)
.output()
.await;
}
#[tokio::test]
async fn test_missing_merge_commits_reports_missing_for_fast_forward() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
init_test_repo(dir).await;
let base_rev = run_git(&["rev-parse", "HEAD"], dir).await.unwrap();
let _ = Command::new("git")
.args(["checkout", "-b", "ws-change-a"])
.current_dir(dir)
.output()
.await;
std::fs::write(dir.join("feature.txt"), "feature\n").unwrap();
let _ = Command::new("git")
.args(["add", "-A"])
.current_dir(dir)
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "Feature commit"])
.current_dir(dir)
.output()
.await;
let _ = Command::new("git")
.args(["checkout", "main"])
.current_dir(dir)
.output()
.await;
let _ = Command::new("git")
.args(["merge", "ws-change-a"]) .current_dir(dir)
.output()
.await;
let missing = missing_merge_commits_since(dir, base_rev.trim(), &["change-a".to_string()])
.await
.unwrap();
assert_eq!(
missing,
vec!["change-a".to_string()],
"Fast-forward merged change should appear as missing (no merge commit)"
);
}
#[tokio::test]
async fn test_is_ancestor_detects_fast_forward_integration() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
init_test_repo(dir).await;
let _ = Command::new("git")
.args(["checkout", "-b", "ws-change-a"])
.current_dir(dir)
.output()
.await;
std::fs::write(dir.join("feature.txt"), "feature\n").unwrap();
let _ = Command::new("git")
.args(["add", "-A"])
.current_dir(dir)
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "Feature commit"])
.current_dir(dir)
.output()
.await;
let _ = Command::new("git")
.args(["checkout", "main"])
.current_dir(dir)
.output()
.await;
let _ = Command::new("git")
.args(["merge", "ws-change-a"])
.current_dir(dir)
.output()
.await;
let integrated = is_ancestor(dir, "ws-change-a", "HEAD").await.unwrap();
assert!(integrated, "Fast-forward branch should be ancestor of HEAD");
let _ = Command::new("git")
.args(["checkout", "-b", "ws-unmerged"])
.current_dir(dir)
.output()
.await;
std::fs::write(dir.join("unmerged.txt"), "unmerged\n").unwrap();
let _ = Command::new("git")
.args(["add", "-A"])
.current_dir(dir)
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "Unmerged commit"])
.current_dir(dir)
.output()
.await;
let _ = Command::new("git")
.args(["checkout", "main"])
.current_dir(dir)
.output()
.await;
let not_integrated = is_ancestor(dir, "ws-unmerged", "HEAD").await.unwrap();
assert!(
!not_integrated,
"Unmerged branch should NOT be ancestor of HEAD"
);
}
#[tokio::test]
async fn test_fast_forward_merge_passes_verification_with_ancestor_check() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
init_test_repo(dir).await;
let base_rev = run_git(&["rev-parse", "HEAD"], dir).await.unwrap();
let _ = Command::new("git")
.args(["checkout", "-b", "ws-change-ff"])
.current_dir(dir)
.output()
.await;
std::fs::write(dir.join("feature-ff.txt"), "fast-forward content\n").unwrap();
let _ = Command::new("git")
.args(["add", "-A"])
.current_dir(dir)
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "Archive: change-ff"])
.current_dir(dir)
.output()
.await;
let _ = Command::new("git")
.args(["checkout", "main"])
.current_dir(dir)
.output()
.await;
let _ = Command::new("git")
.args(["merge", "ws-change-ff"]) .current_dir(dir)
.output()
.await;
let change_ids = vec!["change-ff".to_string()];
let revisions = ["ws-change-ff".to_string()];
let missing = missing_merge_commits_since(dir, base_rev.trim(), &change_ids)
.await
.unwrap();
assert_eq!(
missing,
vec!["change-ff".to_string()],
"Fast-forward merge should be reported as missing by merge commit check"
);
let mut truly_missing: Vec<String> = Vec::new();
for missing_id in &missing {
let revision = revisions
.iter()
.zip(change_ids.iter())
.find(|(_, cid)| *cid == missing_id)
.map(|(rev, _)| rev.as_str());
if let Some(rev) = revision {
let is_integrated = is_ancestor(dir, rev, "HEAD").await.unwrap_or(false);
if !is_integrated {
truly_missing.push(missing_id.clone());
}
} else {
truly_missing.push(missing_id.clone());
}
}
assert!(
truly_missing.is_empty(),
"Fast-forward integrated change should NOT appear as truly missing, \
but got: {:?}",
truly_missing
);
}
#[tokio::test]
async fn test_unintegrated_change_still_reported_missing_after_ff_filter() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
init_test_repo(dir).await;
let base_rev = run_git(&["rev-parse", "HEAD"], dir).await.unwrap();
let _ = Command::new("git")
.args(["checkout", "-b", "ws-change-unmerged"])
.current_dir(dir)
.output()
.await;
std::fs::write(dir.join("unmerged.txt"), "unmerged\n").unwrap();
let _ = Command::new("git")
.args(["add", "-A"])
.current_dir(dir)
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "Unmerged change"])
.current_dir(dir)
.output()
.await;
let _ = Command::new("git")
.args(["checkout", "main"])
.current_dir(dir)
.output()
.await;
let change_ids = vec!["change-unmerged".to_string()];
let revisions = ["ws-change-unmerged".to_string()];
let missing = missing_merge_commits_since(dir, base_rev.trim(), &change_ids)
.await
.unwrap();
let mut truly_missing: Vec<String> = Vec::new();
for missing_id in &missing {
let revision = revisions
.iter()
.zip(change_ids.iter())
.find(|(_, cid)| *cid == missing_id)
.map(|(rev, _)| rev.as_str());
if let Some(rev) = revision {
let is_integrated = is_ancestor(dir, rev, "HEAD").await.unwrap_or(false);
if !is_integrated {
truly_missing.push(missing_id.clone());
}
} else {
truly_missing.push(missing_id.clone());
}
}
assert_eq!(
truly_missing,
vec!["change-unmerged".to_string()],
"Unintegrated change must still be reported as truly missing"
);
}
}