story-kit: merge 238_story_mergemaster_handles_merge_conflicts_by_resolving_them_automatically

This commit is contained in:
Dave
2026-02-28 10:12:14 +00:00
parent 670a6a6808
commit c166fe24f5
2 changed files with 292 additions and 1 deletions

View File

@@ -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]