352 lines
11 KiB
Rust
352 lines
11 KiB
Rust
|
|
//! Merge conflict resolution helpers.
|
||
|
|
|
||
|
|
use std::path::Path;
|
||
|
|
use std::process::Command;
|
||
|
|
|
||
|
|
pub(super) fn try_resolve_conflicts(worktree: &Path) -> Result<(bool, String), String> {
|
||
|
|
let mut log = String::new();
|
||
|
|
|
||
|
|
// List conflicted files.
|
||
|
|
let ls = Command::new("git")
|
||
|
|
.args(["diff", "--name-only", "--diff-filter=U"])
|
||
|
|
.current_dir(worktree)
|
||
|
|
.output()
|
||
|
|
.map_err(|e| format!("Failed to list conflicted files: {e}"))?;
|
||
|
|
|
||
|
|
let file_list = String::from_utf8_lossy(&ls.stdout);
|
||
|
|
let conflicted_files: Vec<&str> = file_list.lines().filter(|l| !l.is_empty()).collect();
|
||
|
|
|
||
|
|
if conflicted_files.is_empty() {
|
||
|
|
log.push_str("No conflicted files found (conflict may be index-only).\n");
|
||
|
|
return Ok((false, log));
|
||
|
|
}
|
||
|
|
|
||
|
|
log.push_str(&format!("Conflicted files ({}):\n", conflicted_files.len()));
|
||
|
|
for f in &conflicted_files {
|
||
|
|
log.push_str(&format!(" - {f}\n"));
|
||
|
|
}
|
||
|
|
|
||
|
|
// First pass: check that all files can be resolved before touching any.
|
||
|
|
let mut resolutions: Vec<(&str, String)> = Vec::new();
|
||
|
|
for file in &conflicted_files {
|
||
|
|
let file_path = worktree.join(file);
|
||
|
|
let content = std::fs::read_to_string(&file_path)
|
||
|
|
.map_err(|e| format!("Failed to read conflicted file '{file}': {e}"))?;
|
||
|
|
|
||
|
|
match resolve_simple_conflicts(&content) {
|
||
|
|
Some(resolved) => {
|
||
|
|
log.push_str(&format!(" [auto-resolve] {file}\n"));
|
||
|
|
resolutions.push((file, resolved));
|
||
|
|
}
|
||
|
|
None => {
|
||
|
|
log.push_str(&format!(" [COMPLEX — cannot auto-resolve] {file}\n"));
|
||
|
|
return Ok((false, log));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Second pass: write resolved content and stage.
|
||
|
|
for (file, resolved) in &resolutions {
|
||
|
|
let file_path = worktree.join(file);
|
||
|
|
std::fs::write(&file_path, resolved)
|
||
|
|
.map_err(|e| format!("Failed to write resolved file '{file}': {e}"))?;
|
||
|
|
|
||
|
|
let add = Command::new("git")
|
||
|
|
.args(["add", file])
|
||
|
|
.current_dir(worktree)
|
||
|
|
.output()
|
||
|
|
.map_err(|e| format!("Failed to stage resolved file '{file}': {e}"))?;
|
||
|
|
if !add.status.success() {
|
||
|
|
return Err(format!(
|
||
|
|
"git add failed for '{file}': {}",
|
||
|
|
String::from_utf8_lossy(&add.stderr)
|
||
|
|
));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok((true, log))
|
||
|
|
}
|
||
|
|
|
||
|
|
fn resolve_simple_conflicts(content: &str) -> Option<String> {
|
||
|
|
// Quick check: if there are no conflict markers at all, nothing to do.
|
||
|
|
if !content.contains("<<<<<<<") {
|
||
|
|
return Some(content.to_string());
|
||
|
|
}
|
||
|
|
|
||
|
|
let mut result = String::new();
|
||
|
|
let mut lines = content.lines().peekable();
|
||
|
|
|
||
|
|
while let Some(line) = lines.next() {
|
||
|
|
if line.starts_with("<<<<<<<") {
|
||
|
|
// Collect the "ours" side (between <<<<<<< and =======).
|
||
|
|
let mut ours = Vec::new();
|
||
|
|
let mut found_separator = false;
|
||
|
|
for next_line in lines.by_ref() {
|
||
|
|
if next_line.starts_with("=======") {
|
||
|
|
found_separator = true;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
ours.push(next_line);
|
||
|
|
}
|
||
|
|
if !found_separator {
|
||
|
|
return None; // Malformed conflict block.
|
||
|
|
}
|
||
|
|
|
||
|
|
// Collect the "theirs" side (between ======= and >>>>>>>).
|
||
|
|
let mut theirs = Vec::new();
|
||
|
|
let mut found_end = false;
|
||
|
|
for next_line in lines.by_ref() {
|
||
|
|
if next_line.starts_with(">>>>>>>") {
|
||
|
|
found_end = true;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
theirs.push(next_line);
|
||
|
|
}
|
||
|
|
if !found_end {
|
||
|
|
return None; // Malformed conflict block.
|
||
|
|
}
|
||
|
|
|
||
|
|
// Both sides must be non-empty additions to be considered simple.
|
||
|
|
// If either side is empty, it means one side deleted something — complex.
|
||
|
|
if ours.is_empty() && theirs.is_empty() {
|
||
|
|
// Both empty — nothing to add, skip.
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Accept both: ours first, then theirs.
|
||
|
|
for l in &ours {
|
||
|
|
result.push_str(l);
|
||
|
|
result.push('\n');
|
||
|
|
}
|
||
|
|
for l in &theirs {
|
||
|
|
result.push_str(l);
|
||
|
|
result.push('\n');
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
result.push_str(line);
|
||
|
|
result.push('\n');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Preserve trailing newline consistency: if original ended without
|
||
|
|
// newline, strip the trailing one we added.
|
||
|
|
if !content.ends_with('\n') && result.ends_with('\n') {
|
||
|
|
result.pop();
|
||
|
|
}
|
||
|
|
|
||
|
|
Some(result)
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn resolve_simple_conflicts_no_markers() {
|
||
|
|
let input = "line 1\nline 2\nline 3\n";
|
||
|
|
let result = resolve_simple_conflicts(input);
|
||
|
|
assert_eq!(result, Some(input.to_string()));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn resolve_simple_conflicts_additive() {
|
||
|
|
let input = "\
|
||
|
|
before
|
||
|
|
<<<<<<< HEAD
|
||
|
|
ours line 1
|
||
|
|
ours line 2
|
||
|
|
=======
|
||
|
|
theirs line 1
|
||
|
|
theirs line 2
|
||
|
|
>>>>>>> feature
|
||
|
|
after
|
||
|
|
";
|
||
|
|
let result = resolve_simple_conflicts(input).unwrap();
|
||
|
|
assert!(
|
||
|
|
!result.contains("<<<<<<<"),
|
||
|
|
"should not contain conflict markers"
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
!result.contains(">>>>>>>"),
|
||
|
|
"should not contain conflict markers"
|
||
|
|
);
|
||
|
|
assert!(result.contains("ours line 1"));
|
||
|
|
assert!(result.contains("ours line 2"));
|
||
|
|
assert!(result.contains("theirs line 1"));
|
||
|
|
assert!(result.contains("theirs line 2"));
|
||
|
|
assert!(result.contains("before"));
|
||
|
|
assert!(result.contains("after"));
|
||
|
|
// Ours comes before theirs
|
||
|
|
let ours_pos = result.find("ours line 1").unwrap();
|
||
|
|
let theirs_pos = result.find("theirs line 1").unwrap();
|
||
|
|
assert!(ours_pos < theirs_pos, "ours should come before theirs");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn resolve_simple_conflicts_multiple_blocks() {
|
||
|
|
let input = "\
|
||
|
|
header
|
||
|
|
<<<<<<< HEAD
|
||
|
|
ours block 1
|
||
|
|
=======
|
||
|
|
theirs block 1
|
||
|
|
>>>>>>> feature
|
||
|
|
middle
|
||
|
|
<<<<<<< HEAD
|
||
|
|
ours block 2
|
||
|
|
=======
|
||
|
|
theirs block 2
|
||
|
|
>>>>>>> feature
|
||
|
|
footer
|
||
|
|
";
|
||
|
|
let result = resolve_simple_conflicts(input).unwrap();
|
||
|
|
assert!(!result.contains("<<<<<<<"));
|
||
|
|
assert!(result.contains("ours block 1"));
|
||
|
|
assert!(result.contains("theirs block 1"));
|
||
|
|
assert!(result.contains("ours block 2"));
|
||
|
|
assert!(result.contains("theirs block 2"));
|
||
|
|
assert!(result.contains("header"));
|
||
|
|
assert!(result.contains("middle"));
|
||
|
|
assert!(result.contains("footer"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn resolve_simple_conflicts_malformed_no_separator() {
|
||
|
|
let input = "\
|
||
|
|
<<<<<<< HEAD
|
||
|
|
ours
|
||
|
|
>>>>>>> feature
|
||
|
|
";
|
||
|
|
let result = resolve_simple_conflicts(input);
|
||
|
|
assert!(
|
||
|
|
result.is_none(),
|
||
|
|
"malformed conflict (no separator) should return None"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn resolve_simple_conflicts_malformed_no_end() {
|
||
|
|
let input = "\
|
||
|
|
<<<<<<< HEAD
|
||
|
|
ours
|
||
|
|
=======
|
||
|
|
theirs
|
||
|
|
";
|
||
|
|
let result = resolve_simple_conflicts(input);
|
||
|
|
assert!(
|
||
|
|
result.is_none(),
|
||
|
|
"malformed conflict (no end marker) should return None"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn resolve_simple_conflicts_preserves_no_trailing_newline() {
|
||
|
|
let input = "before\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\nafter";
|
||
|
|
let result = resolve_simple_conflicts(input).unwrap();
|
||
|
|
assert!(
|
||
|
|
!result.ends_with('\n'),
|
||
|
|
"should not add trailing newline if original lacks one"
|
||
|
|
);
|
||
|
|
assert!(result.ends_with("after"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn resolve_simple_conflicts_real_markers_additive_both_sides() {
|
||
|
|
// The most common real-world case: both branches add different content
|
||
|
|
// (e.g. different functions) to the same region of a file.
|
||
|
|
let input = "// shared code\n\
|
||
|
|
<<<<<<< HEAD\n\
|
||
|
|
fn master_fn() { println!(\"from master\"); }\n\
|
||
|
|
=======\n\
|
||
|
|
fn feature_fn() { println!(\"from feature\"); }\n\
|
||
|
|
>>>>>>> feature/story-42\n\
|
||
|
|
// end\n";
|
||
|
|
let result = resolve_simple_conflicts(input).unwrap();
|
||
|
|
assert!(!result.contains("<<<<<<<"), "no conflict markers in output");
|
||
|
|
assert!(!result.contains(">>>>>>>"), "no conflict markers in output");
|
||
|
|
assert!(!result.contains("======="), "no separator in output");
|
||
|
|
assert!(
|
||
|
|
result.contains("fn master_fn()"),
|
||
|
|
"master (ours) side must be preserved"
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
result.contains("fn feature_fn()"),
|
||
|
|
"feature (theirs) side must be preserved"
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
result.contains("// shared code"),
|
||
|
|
"context before conflict preserved"
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
result.contains("// end"),
|
||
|
|
"context after conflict preserved"
|
||
|
|
);
|
||
|
|
// ours (master) must appear before theirs (feature)
|
||
|
|
assert!(
|
||
|
|
result.find("master_fn").unwrap() < result.find("feature_fn").unwrap(),
|
||
|
|
"master side must appear before feature side"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn resolve_simple_conflicts_real_markers_multiple_conflict_blocks() {
|
||
|
|
// Two separate conflict blocks in the same file — as happens when two
|
||
|
|
// feature branches both add imports AND test suites to the same file.
|
||
|
|
let input = "// imports\n\
|
||
|
|
<<<<<<< HEAD\n\
|
||
|
|
import { A } from './a';\n\
|
||
|
|
=======\n\
|
||
|
|
import { B } from './b';\n\
|
||
|
|
>>>>>>> feature/story-43\n\
|
||
|
|
// implementation\n\
|
||
|
|
<<<<<<< HEAD\n\
|
||
|
|
export function masterImpl() {}\n\
|
||
|
|
=======\n\
|
||
|
|
export function featureImpl() {}\n\
|
||
|
|
>>>>>>> feature/story-43\n";
|
||
|
|
let result = resolve_simple_conflicts(input).unwrap();
|
||
|
|
assert!(!result.contains("<<<<<<<"), "no conflict markers in output");
|
||
|
|
assert!(
|
||
|
|
result.contains("import { A }"),
|
||
|
|
"first block ours preserved"
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
result.contains("import { B }"),
|
||
|
|
"first block theirs preserved"
|
||
|
|
);
|
||
|
|
assert!(result.contains("masterImpl"), "second block ours preserved");
|
||
|
|
assert!(
|
||
|
|
result.contains("featureImpl"),
|
||
|
|
"second block theirs preserved"
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
result.contains("// imports"),
|
||
|
|
"surrounding context preserved"
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
result.contains("// implementation"),
|
||
|
|
"surrounding context preserved"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn resolve_simple_conflicts_real_markers_one_side_empty() {
|
||
|
|
// Ours (master) has no content in the conflicted region; theirs (feature)
|
||
|
|
// adds new content. Resolution: keep theirs.
|
||
|
|
let input = "before\n\
|
||
|
|
<<<<<<< HEAD\n\
|
||
|
|
=======\n\
|
||
|
|
feature_addition\n\
|
||
|
|
>>>>>>> feature/story-44\n\
|
||
|
|
after\n";
|
||
|
|
let result = resolve_simple_conflicts(input).unwrap();
|
||
|
|
assert!(!result.contains("<<<<<<<"), "no conflict markers");
|
||
|
|
assert!(
|
||
|
|
result.contains("feature_addition"),
|
||
|
|
"non-empty side preserved"
|
||
|
|
);
|
||
|
|
assert!(result.contains("before"), "context preserved");
|
||
|
|
assert!(result.contains("after"), "context preserved");
|
||
|
|
}
|
||
|
|
}
|