From c166fe24f5d32b5b3dc8036ffdc14e28596ae273 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 28 Feb 2026 10:12:14 +0000 Subject: [PATCH] story-kit: merge 238_story_mergemaster_handles_merge_conflicts_by_resolving_them_automatically --- .mcp.json | 2 +- server/src/agents.rs | 291 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+), 1 deletion(-) diff --git a/.mcp.json b/.mcp.json index a36a88a..22f1f26 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "story-kit": { "type": "http", - "url": "http://localhost:3001/mcp" + "url": "http://localhost:3010/mcp" } } } diff --git a/server/src/agents.rs b/server/src/agents.rs index 6f1b77f..dd135c0 100644 --- a/server/src/agents.rs +++ b/server/src/agents.rs @@ -5861,6 +5861,80 @@ theirs assert!(result.ends_with("after")); } + // ── Additional resolve_simple_conflicts tests (real conflict markers) ──── + // + // AC1: The mergemaster reads both sides of the conflict and produces a + // resolved file that preserves changes from both branches. + + #[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"); + } + // ── merge-queue squash-merge integration tests ────────────────────────── #[tokio::test] @@ -6348,6 +6422,223 @@ theirs ); } + // ── AC4: additive multi-branch conflict auto-resolution ──────────────── + // + // Verifies that when two feature branches both add different code to the + // same region of a file (the most common conflict pattern in this project), + // the mergemaster auto-resolves the conflict and preserves both additions. + #[tokio::test] + async fn squash_merge_additive_conflict_both_additions_preserved() { + use std::fs; + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let repo = tmp.path(); + init_git_repo(repo); + + // Initial file with a shared base. + fs::write(repo.join("module.rs"), "// module\npub fn existing() {}\n").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "initial module"]) + .current_dir(repo) + .output() + .unwrap(); + + // Feature branch: appends feature_fn to the file. + Command::new("git") + .args(["checkout", "-b", "feature/story-238_additive"]) + .current_dir(repo) + .output() + .unwrap(); + fs::write( + repo.join("module.rs"), + "// module\npub fn existing() {}\npub fn feature_fn() {}\n", + ) + .unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "add feature_fn"]) + .current_dir(repo) + .output() + .unwrap(); + + // Simulate another branch already merged into master: appends master_fn. + Command::new("git") + .args(["checkout", "master"]) + .current_dir(repo) + .output() + .unwrap(); + fs::write( + repo.join("module.rs"), + "// module\npub fn existing() {}\npub fn master_fn() {}\n", + ) + .unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "add master_fn (another branch merged)"]) + .current_dir(repo) + .output() + .unwrap(); + + // Squash-merge the feature branch — conflicts because both appended to the same location. + let result = + run_squash_merge(repo, "feature/story-238_additive", "238_additive").unwrap(); + + // Conflict must be detected and auto-resolved. + assert!(result.had_conflicts, "additive conflict should be detected"); + assert!( + result.conflicts_resolved, + "additive conflict must be auto-resolved; output:\n{}", + result.output + ); + + // Master must contain both additions without conflict markers. + let content = fs::read_to_string(repo.join("module.rs")).unwrap(); + assert!(!content.contains("<<<<<<<"), "master must not contain conflict markers"); + assert!(!content.contains(">>>>>>>"), "master must not contain conflict markers"); + assert!( + content.contains("feature_fn"), + "feature branch addition must be preserved on master" + ); + assert!( + content.contains("master_fn"), + "master branch addition must be preserved on master" + ); + assert!(content.contains("existing"), "original function must be preserved"); + + // Cleanup: no leftover merge-queue branch or workspace. + let branches = Command::new("git") + .args(["branch", "--list", "merge-queue/*"]) + .current_dir(repo) + .output() + .unwrap(); + assert!( + String::from_utf8_lossy(&branches.stdout).trim().is_empty(), + "merge-queue branch must be cleaned up" + ); + assert!( + !repo.join(".story_kit/merge_workspace").exists(), + "merge workspace must be cleaned up" + ); + } + + // ── AC3: quality gates fail after conflict resolution ───────────────── + // + // Verifies that when conflicts are auto-resolved but the resulting code + // fails quality gates, the merge is reported as failed (not merged to master). + #[tokio::test] + async fn squash_merge_conflict_resolved_but_gates_fail_reported_as_failure() { + use std::fs; + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let repo = tmp.path(); + init_git_repo(repo); + + // Add a script/test that always fails (quality gate). This must be on + // master before the feature branch forks so it doesn't cause its own conflict. + let script_dir = repo.join("script"); + fs::create_dir_all(&script_dir).unwrap(); + fs::write(script_dir.join("test"), "#!/bin/sh\nexit 1\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + script_dir.join("test"), + std::fs::Permissions::from_mode(0o755), + ) + .unwrap(); + } + fs::write(repo.join("code.txt"), "// base\n").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "initial with failing script/test"]) + .current_dir(repo) + .output() + .unwrap(); + + // Feature branch: appends feature content (creates future conflict point). + Command::new("git") + .args(["checkout", "-b", "feature/story-238_gates_fail"]) + .current_dir(repo) + .output() + .unwrap(); + fs::write(repo.join("code.txt"), "// base\nfeature_addition\n").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "feature addition"]) + .current_dir(repo) + .output() + .unwrap(); + + // Master: append different content at same location (creates conflict). + Command::new("git") + .args(["checkout", "master"]) + .current_dir(repo) + .output() + .unwrap(); + fs::write(repo.join("code.txt"), "// base\nmaster_addition\n").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "master addition"]) + .current_dir(repo) + .output() + .unwrap(); + + // Squash-merge: conflict detected → auto-resolved → quality gates run → fail. + let result = + run_squash_merge(repo, "feature/story-238_gates_fail", "238_gates_fail").unwrap(); + + assert!(result.had_conflicts, "conflict must be detected"); + assert!(result.conflicts_resolved, "additive conflict must be auto-resolved"); + assert!(!result.gates_passed, "quality gates must fail (script/test exits 1)"); + assert!(!result.success, "merge must be reported as failed when gates fail"); + assert!( + !result.output.is_empty(), + "output must contain gate failure details" + ); + + // Master must NOT have been updated (cherry-pick was blocked by gate failure). + let content = fs::read_to_string(repo.join("code.txt")).unwrap(); + assert!(!content.contains("<<<<<<<"), "master must not contain conflict markers"); + // master_addition was the last commit on master; feature_addition must NOT be there. + assert!( + !content.contains("feature_addition"), + "feature code must not land on master when gates fail" + ); + + // Cleanup must still happen. + assert!( + !repo.join(".story_kit/merge_workspace").exists(), + "merge workspace must be cleaned up even on gate failure" + ); + } + /// Bug 226: feature_branch_has_unmerged_changes returns true when the /// feature branch has commits not on master. #[test]