From 6f50c02a19a9f066ee82a73b89484b9eada23a66 Mon Sep 17 00:00:00 2001 From: Juan Francisco Lebrero Date: Fri, 13 Mar 2026 18:30:38 -0300 Subject: [PATCH] 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. --- src/resources/extensions/gsd/git-service.ts | 16 +++++++-- .../extensions/gsd/tests/git-service.test.ts | 36 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 3a37b6a42..f561cc651 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -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}`, diff --git a/src/resources/extensions/gsd/tests/git-service.test.ts b/src/resources/extensions/gsd/tests/git-service.test.ts index b8d5738d2..a08b29844 100644 --- a/src/resources/extensions/gsd/tests/git-service.test.ts +++ b/src/resources/extensions/gsd/tests/git-service.test.ts @@ -953,6 +953,42 @@ async function main(): Promise { 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 // ═══════════════════════════════════════════════════════════════════════