story-kit: merge 119_story_mergemaster_should_resolve_merge_conflicts_instead_of_leaving_conflict_markers_on_master

This commit is contained in:
Dave
2026-02-23 23:22:24 +00:00
parent 52e1db789a
commit b928eace9c
3 changed files with 741 additions and 39 deletions

View File

@@ -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");
}
}