story-kit: merge 119_story_mergemaster_should_resolve_merge_conflicts_instead_of_leaving_conflict_markers_on_master
This commit is contained in:
@@ -1001,19 +1001,20 @@ impl AgentPool {
|
||||
let br = branch.clone();
|
||||
|
||||
// Run blocking operations (git + cargo) off the async runtime.
|
||||
let (merge_success, had_conflicts, conflict_details, merge_output) =
|
||||
let merge_result =
|
||||
tokio::task::spawn_blocking(move || run_squash_merge(&root, &br, &sid))
|
||||
.await
|
||||
.map_err(|e| format!("Merge task panicked: {e}"))??;
|
||||
|
||||
if !merge_success {
|
||||
if !merge_result.success {
|
||||
return Ok(MergeReport {
|
||||
story_id: story_id.to_string(),
|
||||
success: false,
|
||||
had_conflicts,
|
||||
conflict_details,
|
||||
had_conflicts: merge_result.had_conflicts,
|
||||
conflicts_resolved: merge_result.conflicts_resolved,
|
||||
conflict_details: merge_result.conflict_details,
|
||||
gates_passed: false,
|
||||
gate_output: merge_output,
|
||||
gate_output: merge_result.output,
|
||||
worktree_cleaned_up: false,
|
||||
story_archived: false,
|
||||
});
|
||||
@@ -1030,8 +1031,9 @@ impl AgentPool {
|
||||
return Ok(MergeReport {
|
||||
story_id: story_id.to_string(),
|
||||
success: true,
|
||||
had_conflicts: false,
|
||||
conflict_details: None,
|
||||
had_conflicts: merge_result.had_conflicts,
|
||||
conflicts_resolved: merge_result.conflicts_resolved,
|
||||
conflict_details: merge_result.conflict_details.clone(),
|
||||
gates_passed: false,
|
||||
gate_output,
|
||||
worktree_cleaned_up: false,
|
||||
@@ -1056,8 +1058,9 @@ impl AgentPool {
|
||||
Ok(MergeReport {
|
||||
story_id: story_id.to_string(),
|
||||
success: true,
|
||||
had_conflicts: false,
|
||||
conflict_details: None,
|
||||
had_conflicts: merge_result.had_conflicts,
|
||||
conflicts_resolved: merge_result.conflicts_resolved,
|
||||
conflict_details: merge_result.conflict_details,
|
||||
gates_passed: true,
|
||||
gate_output,
|
||||
worktree_cleaned_up,
|
||||
@@ -1716,6 +1719,8 @@ pub struct MergeReport {
|
||||
pub story_id: String,
|
||||
pub success: bool,
|
||||
pub had_conflicts: bool,
|
||||
/// `true` when conflicts were detected but automatically resolved.
|
||||
pub conflicts_resolved: bool,
|
||||
pub conflict_details: Option<String>,
|
||||
pub gates_passed: bool,
|
||||
pub gate_output: String,
|
||||
@@ -2130,21 +2135,77 @@ fn run_coverage_gate(path: &Path) -> Result<(bool, String), String> {
|
||||
|
||||
// ── Mergemaster helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/// Squash-merge a feature branch into the current branch in the project root.
|
||||
/// Result of a squash-merge operation.
|
||||
struct SquashMergeResult {
|
||||
success: bool,
|
||||
had_conflicts: bool,
|
||||
/// `true` when conflicts were detected but automatically resolved.
|
||||
conflicts_resolved: bool,
|
||||
conflict_details: Option<String>,
|
||||
output: String,
|
||||
}
|
||||
|
||||
/// Squash-merge a feature branch into the current branch using a temporary
|
||||
/// merge-queue worktree. This avoids the race condition where the filesystem
|
||||
/// watcher auto-commits conflict markers to master.
|
||||
///
|
||||
/// Returns `(success, had_conflicts, conflict_details, output)`.
|
||||
/// **Flow:**
|
||||
/// 1. Create a temporary `merge-queue/{story_id}` branch at current HEAD.
|
||||
/// 2. Create a temporary worktree for that branch.
|
||||
/// 3. Run `git merge --squash` in the temporary worktree (not the main worktree).
|
||||
/// 4. If conflicts arise, attempt automatic resolution for simple additive cases.
|
||||
/// 5. If clean (or resolved), commit in the temp worktree.
|
||||
/// 6. Fast-forward master to the merge-queue commit.
|
||||
/// 7. Clean up the temporary worktree and branch.
|
||||
fn run_squash_merge(
|
||||
project_root: &Path,
|
||||
branch: &str,
|
||||
story_id: &str,
|
||||
) -> Result<(bool, bool, Option<String>, String), String> {
|
||||
) -> Result<SquashMergeResult, String> {
|
||||
let mut all_output = String::new();
|
||||
let merge_branch = format!("merge-queue/{story_id}");
|
||||
let merge_wt_path = project_root
|
||||
.join(".story_kit")
|
||||
.join("merge_workspace");
|
||||
|
||||
// ── git merge --squash ────────────────────────────────────────
|
||||
// Ensure we start clean: remove any leftover merge workspace.
|
||||
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||
|
||||
// ── Create merge-queue branch at current HEAD ─────────────────
|
||||
all_output.push_str(&format!(
|
||||
"=== Creating merge-queue branch '{merge_branch}' ===\n"
|
||||
));
|
||||
let create_branch = Command::new("git")
|
||||
.args(["branch", &merge_branch])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to create merge-queue branch: {e}"))?;
|
||||
if !create_branch.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&create_branch.stderr);
|
||||
all_output.push_str(&format!("Branch creation failed: {stderr}\n"));
|
||||
return Err(format!("Failed to create merge-queue branch: {stderr}"));
|
||||
}
|
||||
|
||||
// ── Create temporary worktree ─────────────────────────────────
|
||||
all_output.push_str("=== Creating temporary merge worktree ===\n");
|
||||
let wt_str = merge_wt_path.to_string_lossy().to_string();
|
||||
let create_wt = Command::new("git")
|
||||
.args(["worktree", "add", &wt_str, &merge_branch])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to create merge worktree: {e}"))?;
|
||||
if !create_wt.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&create_wt.stderr);
|
||||
all_output.push_str(&format!("Worktree creation failed: {stderr}\n"));
|
||||
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||
return Err(format!("Failed to create merge worktree: {stderr}"));
|
||||
}
|
||||
|
||||
// ── Squash-merge in the temporary worktree ────────────────────
|
||||
all_output.push_str(&format!("=== git merge --squash {branch} ===\n"));
|
||||
let merge = Command::new("git")
|
||||
.args(["merge", "--squash", branch])
|
||||
.current_dir(project_root)
|
||||
.current_dir(&merge_wt_path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git merge: {e}"))?;
|
||||
|
||||
@@ -2154,28 +2215,70 @@ fn run_squash_merge(
|
||||
all_output.push_str(&merge_stderr);
|
||||
all_output.push('\n');
|
||||
|
||||
let mut had_conflicts = false;
|
||||
let mut conflicts_resolved = false;
|
||||
let mut conflict_details: Option<String> = None;
|
||||
|
||||
if !merge.status.success() {
|
||||
// Conflicts detected — abort the merge and report.
|
||||
let conflict_details = format!(
|
||||
"Merge conflicts in branch '{branch}':\n{merge_stdout}{merge_stderr}"
|
||||
);
|
||||
had_conflicts = true;
|
||||
all_output.push_str("=== Conflicts detected, attempting auto-resolution ===\n");
|
||||
|
||||
// Abort the merge to restore clean state.
|
||||
let _ = Command::new("git")
|
||||
.args(["merge", "--abort"])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
|
||||
all_output.push_str("=== Merge aborted due to conflicts ===\n");
|
||||
return Ok((false, true, Some(conflict_details), all_output));
|
||||
// Try to automatically resolve simple conflicts.
|
||||
match try_resolve_conflicts(&merge_wt_path) {
|
||||
Ok((resolved, resolution_log)) => {
|
||||
all_output.push_str(&resolution_log);
|
||||
if resolved {
|
||||
conflicts_resolved = true;
|
||||
all_output
|
||||
.push_str("=== All conflicts resolved automatically ===\n");
|
||||
} else {
|
||||
// Could not resolve — abort, clean up, and report.
|
||||
let details = format!(
|
||||
"Merge conflicts in branch '{branch}':\n{merge_stdout}{merge_stderr}\n{resolution_log}"
|
||||
);
|
||||
conflict_details = Some(details);
|
||||
all_output
|
||||
.push_str("=== Unresolvable conflicts, aborting merge ===\n");
|
||||
cleanup_merge_workspace(
|
||||
project_root,
|
||||
&merge_wt_path,
|
||||
&merge_branch,
|
||||
);
|
||||
return Ok(SquashMergeResult {
|
||||
success: false,
|
||||
had_conflicts: true,
|
||||
conflicts_resolved: false,
|
||||
conflict_details,
|
||||
output: all_output,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
all_output.push_str(&format!("Auto-resolution error: {e}\n"));
|
||||
cleanup_merge_workspace(
|
||||
project_root,
|
||||
&merge_wt_path,
|
||||
&merge_branch,
|
||||
);
|
||||
return Ok(SquashMergeResult {
|
||||
success: false,
|
||||
had_conflicts: true,
|
||||
conflicts_resolved: false,
|
||||
conflict_details: Some(format!(
|
||||
"Merge conflicts in branch '{branch}' (auto-resolution failed: {e}):\n{merge_stdout}{merge_stderr}"
|
||||
)),
|
||||
output: all_output,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── git commit ─────────────────────────────────────────────
|
||||
// ── Commit in the temporary worktree ──────────────────────────
|
||||
all_output.push_str("=== git commit ===\n");
|
||||
let commit_msg = format!("story-kit: merge {story_id}");
|
||||
let commit = Command::new("git")
|
||||
.args(["commit", "-m", &commit_msg])
|
||||
.current_dir(project_root)
|
||||
.current_dir(&merge_wt_path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git commit: {e}"))?;
|
||||
|
||||
@@ -2190,12 +2293,238 @@ fn run_squash_merge(
|
||||
if commit_stderr.contains("nothing to commit")
|
||||
|| commit_stdout.contains("nothing to commit")
|
||||
{
|
||||
return Ok((true, false, None, all_output));
|
||||
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||
return Ok(SquashMergeResult {
|
||||
success: true,
|
||||
had_conflicts,
|
||||
conflicts_resolved,
|
||||
conflict_details,
|
||||
output: all_output,
|
||||
});
|
||||
}
|
||||
return Ok((false, false, None, all_output));
|
||||
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||
return Ok(SquashMergeResult {
|
||||
success: false,
|
||||
had_conflicts,
|
||||
conflicts_resolved,
|
||||
conflict_details,
|
||||
output: all_output,
|
||||
});
|
||||
}
|
||||
|
||||
Ok((true, false, None, all_output))
|
||||
// ── Fast-forward master to the merge-queue commit ─────────────
|
||||
all_output.push_str(&format!(
|
||||
"=== Fast-forwarding master to {merge_branch} ===\n"
|
||||
));
|
||||
let ff = Command::new("git")
|
||||
.args(["merge", "--ff-only", &merge_branch])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to fast-forward master: {e}"))?;
|
||||
|
||||
let ff_stdout = String::from_utf8_lossy(&ff.stdout).to_string();
|
||||
let ff_stderr = String::from_utf8_lossy(&ff.stderr).to_string();
|
||||
all_output.push_str(&ff_stdout);
|
||||
all_output.push_str(&ff_stderr);
|
||||
all_output.push('\n');
|
||||
|
||||
if !ff.status.success() {
|
||||
all_output.push_str("=== Fast-forward failed — master may have diverged ===\n");
|
||||
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||
return Ok(SquashMergeResult {
|
||||
success: false,
|
||||
had_conflicts,
|
||||
conflicts_resolved,
|
||||
conflict_details: Some(format!(
|
||||
"Fast-forward to merge-queue failed (master diverged?):\n{ff_stderr}"
|
||||
)),
|
||||
output: all_output,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Clean up ──────────────────────────────────────────────────
|
||||
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||
all_output.push_str("=== Merge-queue cleanup complete ===\n");
|
||||
|
||||
Ok(SquashMergeResult {
|
||||
success: true,
|
||||
had_conflicts,
|
||||
conflicts_resolved,
|
||||
conflict_details,
|
||||
output: all_output,
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove the temporary merge worktree and branch. Best-effort — errors are
|
||||
/// silently ignored because this is cleanup code.
|
||||
fn cleanup_merge_workspace(
|
||||
project_root: &Path,
|
||||
merge_wt_path: &Path,
|
||||
merge_branch: &str,
|
||||
) {
|
||||
let wt_str = merge_wt_path.to_string_lossy().to_string();
|
||||
let _ = Command::new("git")
|
||||
.args(["worktree", "remove", "--force", &wt_str])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
let _ = Command::new("git")
|
||||
.args(["branch", "-D", merge_branch])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
}
|
||||
|
||||
/// Attempt to automatically resolve merge conflicts in the given worktree.
|
||||
///
|
||||
/// Finds all conflicted files and tries [`resolve_simple_conflicts`] on each.
|
||||
/// If **all** conflicts can be resolved, stages the resolved files and returns
|
||||
/// `Ok((true, log))`. If any file has a complex conflict that cannot be
|
||||
/// auto-resolved, returns `Ok((false, log))` without staging anything.
|
||||
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))
|
||||
}
|
||||
|
||||
/// Try to resolve simple additive merge conflicts in a file's content.
|
||||
///
|
||||
/// A conflict is considered "simple additive" when both sides add new content
|
||||
/// at the same location without modifying existing lines. In that case we keep
|
||||
/// both additions (ours first, then theirs).
|
||||
///
|
||||
/// Returns `Some(resolved)` if all conflict blocks in the file are simple, or
|
||||
/// `None` if any block is too complex to auto-resolve.
|
||||
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)
|
||||
}
|
||||
|
||||
/// Run quality gates in the project root after a successful merge.
|
||||
@@ -3979,4 +4308,370 @@ name = "qa"
|
||||
"running entry must survive: got '{err}'"
|
||||
);
|
||||
}
|
||||
|
||||
// ── resolve_simple_conflicts unit tests ──────────────────────────────────
|
||||
|
||||
#[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/branch
|
||||
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"));
|
||||
}
|
||||
|
||||
// ── merge-queue squash-merge integration tests ──────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn squash_merge_uses_merge_queue_no_conflict_markers_on_master() {
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_git_repo(repo);
|
||||
|
||||
// Create a file that will be conflicted on master.
|
||||
fs::write(repo.join("shared.txt"), "line 1\nline 2\n").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "initial shared file"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Create a feature branch that modifies the file.
|
||||
Command::new("git")
|
||||
.args(["checkout", "-b", "feature/story-conflict_test"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
fs::write(repo.join("shared.txt"), "line 1\nline 2\nfeature addition\n").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "feature: add line"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Switch to master and make a conflicting change.
|
||||
Command::new("git")
|
||||
.args(["checkout", "master"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
fs::write(repo.join("shared.txt"), "line 1\nline 2\nmaster addition\n").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "master: add line"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Run the squash merge.
|
||||
let result = run_squash_merge(repo, "feature/story-conflict_test", "conflict_test")
|
||||
.unwrap();
|
||||
|
||||
// Master should NEVER contain conflict markers, regardless of outcome.
|
||||
let master_content = fs::read_to_string(repo.join("shared.txt")).unwrap();
|
||||
assert!(
|
||||
!master_content.contains("<<<<<<<"),
|
||||
"master must never contain conflict markers, got:\n{master_content}"
|
||||
);
|
||||
assert!(
|
||||
!master_content.contains(">>>>>>>"),
|
||||
"master must never contain conflict markers, got:\n{master_content}"
|
||||
);
|
||||
|
||||
// The merge should have had conflicts.
|
||||
assert!(result.had_conflicts, "should detect conflicts");
|
||||
|
||||
// Conflicts should have been auto-resolved (both are simple additions).
|
||||
if result.conflicts_resolved {
|
||||
assert!(result.success, "auto-resolved merge should succeed");
|
||||
assert!(
|
||||
master_content.contains("master addition"),
|
||||
"master side should be present"
|
||||
);
|
||||
assert!(
|
||||
master_content.contains("feature addition"),
|
||||
"feature side should be present"
|
||||
);
|
||||
}
|
||||
|
||||
// Verify no leftover merge-queue branch.
|
||||
let branches = Command::new("git")
|
||||
.args(["branch", "--list", "merge-queue/*"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
let branch_list = String::from_utf8_lossy(&branches.stdout);
|
||||
assert!(
|
||||
branch_list.trim().is_empty(),
|
||||
"merge-queue branch should be cleaned up, got: {branch_list}"
|
||||
);
|
||||
|
||||
// Verify no leftover merge workspace directory.
|
||||
assert!(
|
||||
!repo.join(".story_kit/merge_workspace").exists(),
|
||||
"merge workspace should be cleaned up"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn squash_merge_clean_merge_succeeds() {
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_git_repo(repo);
|
||||
|
||||
// Create feature branch with a new file.
|
||||
Command::new("git")
|
||||
.args(["checkout", "-b", "feature/story-clean_test"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
fs::write(repo.join("new_file.txt"), "new content").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "add new file"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Switch back to master.
|
||||
Command::new("git")
|
||||
.args(["checkout", "master"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let result = run_squash_merge(repo, "feature/story-clean_test", "clean_test")
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success, "clean merge should succeed");
|
||||
assert!(!result.had_conflicts, "clean merge should have no conflicts");
|
||||
assert!(!result.conflicts_resolved, "no conflicts means nothing to resolve");
|
||||
assert!(
|
||||
repo.join("new_file.txt").exists(),
|
||||
"merged file should exist on master"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn squash_merge_nonexistent_branch_fails() {
|
||||
use tempfile::tempdir;
|
||||
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_git_repo(repo);
|
||||
|
||||
let result = run_squash_merge(repo, "feature/story-nope", "nope")
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.success, "merge of nonexistent branch should fail");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merge_agent_work_conflict_does_not_break_master() {
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_git_repo(repo);
|
||||
|
||||
// Create a file on master.
|
||||
fs::write(repo.join("code.rs"), "fn main() {\n println!(\"hello\");\n}\n").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "initial code"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Feature branch: modify the same line differently.
|
||||
Command::new("git")
|
||||
.args(["checkout", "-b", "feature/story-42_story_foo"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
fs::write(repo.join("code.rs"), "fn main() {\n println!(\"hello\");\n feature_fn();\n}\n").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "feature: add fn call"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Master: add different line at same location.
|
||||
Command::new("git")
|
||||
.args(["checkout", "master"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
fs::write(repo.join("code.rs"), "fn main() {\n println!(\"hello\");\n master_fn();\n}\n").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "master: add fn call"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Create story file in 4_merge.
|
||||
let merge_dir = repo.join(".story_kit/work/4_merge");
|
||||
fs::create_dir_all(&merge_dir).unwrap();
|
||||
fs::write(merge_dir.join("42_story_foo.md"), "---\nname: Test\n---\n").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "add story"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let pool = AgentPool::new(3001);
|
||||
let report = pool.merge_agent_work(repo, "42_story_foo").await.unwrap();
|
||||
|
||||
// Master should NEVER have conflict markers, regardless of merge outcome.
|
||||
let master_code = fs::read_to_string(repo.join("code.rs")).unwrap();
|
||||
assert!(
|
||||
!master_code.contains("<<<<<<<"),
|
||||
"master must never contain conflict markers:\n{master_code}"
|
||||
);
|
||||
assert!(
|
||||
!master_code.contains(">>>>>>>"),
|
||||
"master must never contain conflict markers:\n{master_code}"
|
||||
);
|
||||
|
||||
// The report should accurately reflect what happened.
|
||||
assert!(report.had_conflicts, "should report conflicts");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user