story-kit: merge 238_story_mergemaster_handles_merge_conflicts_by_resolving_them_automatically
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"story-kit": {
|
"story-kit": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "http://localhost:3001/mcp"
|
"url": "http://localhost:3010/mcp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5861,6 +5861,80 @@ theirs
|
|||||||
assert!(result.ends_with("after"));
|
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 ──────────────────────────
|
// ── merge-queue squash-merge integration tests ──────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[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
|
/// Bug 226: feature_branch_has_unmerged_changes returns true when the
|
||||||
/// feature branch has commits not on master.
|
/// feature branch has commits not on master.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user