fix: auto-resolve .gsd/ planning artifact conflicts during slice merge

The merge conflict auto-resolution only handled RUNTIME_EXCLUSION_PATHS
(.gsd/activity/, .gsd/runtime/, .gsd/metrics.json, etc). Planning
artifacts like DECISIONS.md, REQUIREMENTS.md, PROJECT.md, and
ROADMAP.md were not covered, causing the merge to fail and auto-mode
to loop when both main and the slice branch modified these files.

Now any conflict limited to .gsd/ files is auto-resolved by taking
the slice branch version (--theirs), since the LLM just finished
updating these artifacts during complete-slice.
This commit is contained in:
Juan Francisco Lebrero 2026-03-13 18:30:38 -03:00
parent b607a1df73
commit 6f50c02a19
2 changed files with 49 additions and 3 deletions

View file

@ -673,10 +673,11 @@ export class GitServiceImpl {
try {
this.git(mergeArgs);
} catch (mergeError) {
// Check if conflicts are limited to runtime files we can auto-resolve (#189)
// Check if conflicts can be auto-resolved (#189, #218)
const conflicted = this.git(["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
if (conflicted) {
const conflictedFiles = conflicted.split("\n").filter(Boolean);
const allGsd = conflictedFiles.every(f => f.startsWith(".gsd/"));
const allRuntime = conflictedFiles.every(f =>
RUNTIME_EXCLUSION_PATHS.some(excl => f.startsWith(excl.replace(/\/$/, ""))),
);
@ -688,12 +689,21 @@ export class GitServiceImpl {
}
this.git(["add", "-A"], { allowFailure: true });
// Don't throw — let the merge proceed
} else if (allGsd) {
// Non-runtime .gsd/ conflicts (DECISIONS.md, REQUIREMENTS.md, ROADMAP.md, etc.):
// The slice branch has the authoritative .gsd/ state since the LLM just finished
// updating these artifacts during complete-slice. Take theirs (the slice branch).
for (const f of conflictedFiles) {
this.git(["checkout", "--theirs", "--", f], { allowFailure: true });
}
this.git(["add", "-A"], { allowFailure: true });
// Don't throw — let the merge proceed
} else {
// Non-runtime conflicts: reset and throw as before
// Non-.gsd/ conflicts: reset and throw as before
this.git(["reset", "--hard", "HEAD"], { allowFailure: true });
const msg = mergeError instanceof Error ? mergeError.message : String(mergeError);
throw new Error(
`${strategy === "merge" ? "Merge" : "Squash-merge"} of "${branch}" into "${mainBranch}" failed with conflicts. ` +
`${strategy === "merge" ? "Merge" : "Squash-merge"} of "${branch}" into "${mainBranch}" failed with conflicts in non-.gsd/ files. ` +
`Working tree has been reset to a clean state. ` +
`Resolve manually: git checkout ${mainBranch} && git merge ${strategy === "merge" ? "--no-ff" : "--squash"} ${branch}\n` +
`Original error: ${msg}`,

View file

@ -953,6 +953,42 @@ async function main(): Promise<void> {
rmSync(repo, { recursive: true, force: true });
}
// ─── mergeSliceToMain: auto-resolve .gsd/ planning artifact conflicts ──
console.log("\n=== mergeSliceToMain: auto-resolve .gsd/ planning conflicts ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
// Create a .gsd/ planning artifact on main (simulates reassess-roadmap)
createFile(repo, ".gsd/DECISIONS.md", "# Decisions\n\n- D001: Original decision\n");
run("git add -A", repo);
run("git commit -m 'add decisions on main'", repo);
// Create slice branch and modify the same .gsd/ file differently
svc.ensureSliceBranch("M001", "S01");
createFile(repo, ".gsd/DECISIONS.md", "# Decisions\n\n- D001: Original decision\n- D002: New decision from slice\n");
createFile(repo, "src/feature.ts", "export const x = 1;");
run("git add -A", repo);
run("git commit -m 'slice work with .gsd/ changes'", repo);
// Back on main, modify the same .gsd/ file to create a conflict
svc.switchToMain();
createFile(repo, ".gsd/DECISIONS.md", "# Decisions\n\n- D001: Updated decision on main\n");
run("git add -A", repo);
run("git commit -m 'update decisions on main'", repo);
// Merge should auto-resolve .gsd/ conflicts by taking theirs (slice branch)
const result = svc.mergeSliceToMain("M001", "S01", "Feature with .gsd/ conflicts");
assertEq(result.deletedBranch, true, ".gsd/ conflict auto-resolved: branch deleted");
// Verify the merge succeeded and src file is present
assert(existsSync(join(repo, "src/feature.ts")), ".gsd/ conflict auto-resolved: src file merged");
rmSync(repo, { recursive: true, force: true });
}
// ═══════════════════════════════════════════════════════════════════════
// S05: Enhanced features — merge guards, snapshots, auto-push, rich commits
// ═══════════════════════════════════════════════════════════════════════