story-kit: merge 238_story_mergemaster_handles_merge_conflicts_by_resolving_them_automatically
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user