story-kit: merge 119_story_mergemaster_should_resolve_merge_conflicts_instead_of_leaving_conflict_markers_on_master
This commit is contained in:
@@ -219,14 +219,18 @@ Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
||||
|
||||
## Your Workflow
|
||||
1. Call merge_agent_work(story_id='{{story_id}}') via the MCP tool to trigger the full merge pipeline
|
||||
2. Review the result: check success, had_conflicts, gates_passed, and gate_output
|
||||
2. Review the result: check success, had_conflicts, conflicts_resolved, gates_passed, and gate_output
|
||||
3. If merge succeeded and gates passed: report success to the human
|
||||
4. If conflicts were found: report the conflict details so the human can resolve them
|
||||
5. If gates failed after merge: report the failing output so a coder can fix it
|
||||
4. If conflicts were auto-resolved (conflicts_resolved=true) and gates passed: report success, noting which conflicts were resolved
|
||||
5. If conflicts could not be auto-resolved: report the conflict details clearly so the human can resolve them. Master is untouched.
|
||||
6. If gates failed after merge: report the failing output so a coder can fix it
|
||||
|
||||
## How Conflict Resolution Works
|
||||
The merge pipeline uses a temporary merge-queue branch and worktree to isolate merges from master. Simple additive conflicts (both branches adding code at the same location) are resolved automatically by keeping both additions. Complex conflicts (modifying the same lines differently) are reported without touching master.
|
||||
|
||||
## Rules
|
||||
- Do NOT implement code yourself
|
||||
- Do NOT resolve complex conflicts yourself - report them clearly
|
||||
- Report conflict resolution outcomes clearly
|
||||
- Your job is to trigger the merge pipeline and report results
|
||||
- The server automatically runs acceptance gates when your process exits"""
|
||||
system_prompt = "You are the mergemaster agent. Your sole responsibility is to trigger the merge_agent_work MCP tool and report the results. Do not write code. Do not resolve conflicts manually. Report success or failure clearly so the human can act."
|
||||
system_prompt = "You are the mergemaster agent. Your sole responsibility is to trigger the merge_agent_work MCP tool and report the results. Do not write code. The merge pipeline automatically resolves simple additive conflicts. Report success or failure clearly so the human can act."
|
||||
|
||||
@@ -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");
|
||||
|
||||
// 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}"
|
||||
);
|
||||
|
||||
// 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));
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1399,10 +1399,12 @@ async fn tool_merge_agent_work(args: &Value, ctx: &AppContext) -> Result<String,
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
let report = ctx.agents.merge_agent_work(&project_root, story_id).await?;
|
||||
|
||||
let status_msg = if report.success && report.gates_passed {
|
||||
let status_msg = if report.success && report.gates_passed && report.conflicts_resolved {
|
||||
"Merge complete: conflicts were auto-resolved and all quality gates passed. Story archived and worktree cleaned up."
|
||||
} else if report.success && report.gates_passed {
|
||||
"Merge complete: all quality gates passed. Story archived and worktree cleaned up."
|
||||
} else if report.had_conflicts {
|
||||
"Merge failed: conflicts detected. Merge was aborted. Resolve conflicts manually and retry."
|
||||
} else if report.had_conflicts && !report.conflicts_resolved {
|
||||
"Merge failed: conflicts detected that could not be auto-resolved. Merge was aborted — master is untouched. Report the conflict details so the human can resolve them."
|
||||
} else if report.success && !report.gates_passed {
|
||||
"Merge committed but quality gates failed. Review gate_output and fix issues before re-running."
|
||||
} else {
|
||||
@@ -1414,6 +1416,7 @@ async fn tool_merge_agent_work(args: &Value, ctx: &AppContext) -> Result<String,
|
||||
"agent_name": agent_name,
|
||||
"success": report.success,
|
||||
"had_conflicts": report.had_conflicts,
|
||||
"conflicts_resolved": report.conflicts_resolved,
|
||||
"conflict_details": report.conflict_details,
|
||||
"gates_passed": report.gates_passed,
|
||||
"gate_output": report.gate_output,
|
||||
|
||||
Reference in New Issue
Block a user