diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index c766829a0..3aebe076c 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -64,12 +64,13 @@ import { ensureSliceBranch, getCurrentBranch, getMainBranch, + MergeConflictError, parseSliceBranch, setActiveMilestoneId, switchToMain, mergeSliceToMain, } from "./worktree.ts"; -import { GitServiceImpl } from "./git-service.ts"; +import { GitServiceImpl, runGit } from "./git-service.ts"; import { getPriorSliceCompletionBlocker } from "./dispatch-guard.ts"; import type { GitPreferences } from "./git-service.ts"; import { truncateToWidth, visibleWidth } from "@gsd/pi-tui"; @@ -696,6 +697,7 @@ function unitVerb(unitType: string): string { case "replan-slice": return "replanning"; case "reassess-roadmap": return "reassessing"; case "run-uat": return "running UAT"; + case "fix-merge": return "resolving conflicts"; default: return unitType; } } @@ -711,6 +713,7 @@ function unitPhaseLabel(unitType: string): string { case "replan-slice": return "REPLAN"; case "reassess-roadmap": return "REASSESS"; case "run-uat": return "UAT"; + case "fix-merge": return "MERGE-FIX"; default: return unitType.toUpperCase(); } } @@ -727,6 +730,7 @@ function peekNext(unitType: string, state: GSDState): string { case "replan-slice": return `re-execute ${sid}`; case "reassess-roadmap": return "advance to next slice"; case "run-uat": return "reassess roadmap"; + case "fix-merge": return "continue merge"; default: return ""; } } @@ -1042,6 +1046,48 @@ async function dispatchNextUnit( return; } + // ── Mid-merge safety check: detect leftover state from a prior fix-merge session ── + // If MERGE_HEAD or SQUASH_MSG exists, a fix-merge session ran previously. + // Check whether it succeeded (no unmerged entries → finalize) or failed (still conflicted → reset + stop). + { + const mergeHeadPath = join(basePath, ".git", "MERGE_HEAD"); + const squashMsgPath = join(basePath, ".git", "SQUASH_MSG"); + const hasMergeHead = existsSync(mergeHeadPath); + const hasSquashMsg = existsSync(squashMsgPath); + if (hasMergeHead || hasSquashMsg) { + const unmerged = runGit(basePath, ["diff", "--name-only", "--diff-filter=U"], { allowFailure: true }); + if (!unmerged || !unmerged.trim()) { + // fix-merge succeeded — finalize the commit if needed (squash case) + if (hasSquashMsg && !hasMergeHead) { + try { + runGit(basePath, ["commit", "--no-edit"], { allowFailure: false }); + ctx.ui.notify("Fix-merge session succeeded — finalized squash commit.", "info"); + } catch { + // Commit may already exist; non-fatal + } + } + // Re-derive state from the now-merged working tree + state = await deriveState(basePath); + mid = state.activeMilestone?.id; + midTitle = state.activeMilestone?.title; + } else { + // fix-merge failed — still has unresolved conflicts, reset and stop + runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true }); + ctx.ui.notify( + "Fix-merge session failed to resolve all conflicts. Working tree reset. Fix conflicts manually and restart.", + "error", + ); + if (currentUnit) { + const modelId = ctx.model?.id ?? "unknown"; + snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId); + saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id); + } + await stopAuto(ctx, pi); + return; + } + } + } + // ── General merge guard: merge completed slice branches before advancing ── // If we're on a gsd/MID/SID branch and that slice is done (roadmap [x]), // merge to main before dispatching the next unit. This handles: @@ -1078,15 +1124,45 @@ async function dispatchNextUnit( mid = state.activeMilestone?.id; midTitle = state.activeMilestone?.title; } catch (error) { - const message = error instanceof Error ? error.message : String(error); + // MergeConflictError: dispatch a fix-merge session to resolve conflicts + if (error instanceof MergeConflictError) { + const fixMergeUnitId = `${parsedBranch.milestoneId}/${parsedBranch.sliceId}`; + const fixMergePrompt = buildFixMergePrompt(error); + ctx.ui.notify( + `Merge conflict in ${error.conflictedFiles.length} file(s) — dispatching fix-merge session.`, + "warning", + ); - // Safety net: if mergeSliceToMain failed to clean up (or the error - // came from switchToMain), ensure the working tree isn't left in a - // conflicted/dirty merge state. Without this, state derivation reads - // conflict-marker-filled files, produces a corrupt phase, and - // dispatch loops forever (see: merge-bug-fix). + // Dispatch fix-merge as the next unit (early-dispatch-and-return) + const fixMergeUnitType = "fix-merge"; + currentUnit = { type: fixMergeUnitType, id: fixMergeUnitId, startedAt: Date.now() }; + writeUnitRuntimeRecord(basePath, fixMergeUnitType, fixMergeUnitId, currentUnit.startedAt, { + phase: "dispatched", + wrapupWarningSent: false, + timeoutAt: null, + lastProgressAt: currentUnit.startedAt, + progressCount: 0, + lastProgressKind: "dispatch", + }); + updateProgressWidget(ctx, fixMergeUnitType, fixMergeUnitId, state); + const result = await cmdCtx!.newSession(); + if (result.cancelled) { + runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true }); + await stopAuto(ctx, pi); + return; + } + const sessionFile = ctx.sessionManager.getSessionFile(); + writeLock(basePath, fixMergeUnitType, fixMergeUnitId, completedUnits.length, sessionFile); + pi.sendMessage( + { customType: "gsd-auto", content: fixMergePrompt, display: verbose }, + { triggerTurn: true }, + ); + return; + } + + // Non-conflict errors: reset and stop + const message = error instanceof Error ? error.message : String(error); try { - const { runGit } = await import("./git-service.ts"); const status = runGit(basePath, ["status", "--porcelain"], { allowFailure: true }); if (status && (status.includes("UU ") || status.includes("AA ") || status.includes("UD "))) { runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true }); @@ -2276,6 +2352,45 @@ async function buildReassessRoadmapPrompt( }); } +/** + * Build a prompt for the fix-merge LLM session that resolves merge conflicts. + */ +function buildFixMergePrompt(err: MergeConflictError): string { + const strategyLabel = err.strategy === "merge" ? "merge --no-ff" : "squash merge"; + const fileList = err.conflictedFiles.map(f => ` - \`${f}\``).join("\n"); + + return [ + `# Fix Merge Conflicts`, + ``, + `A ${strategyLabel} of branch \`${err.branch}\` into \`${err.mainBranch}\` produced conflicts in the following files:`, + ``, + fileList, + ``, + `## Instructions`, + ``, + `1. Read each conflicted file listed above`, + `2. Resolve all conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`) by choosing the correct content`, + `3. Stage the resolved files with \`git add \``, + `4. Commit the resolution:`, + err.strategy === "squash" + ? ` - This is a squash merge, so run: \`git commit --no-edit\` (the squash message is already prepared)` + : ` - This is a --no-ff merge, so run: \`git commit --no-edit\` (the merge message is already prepared)`, + ``, + `## Rules`, + ``, + `- Do NOT run \`git merge --abort\` or \`git reset\``, + `- Do NOT modify any files other than the conflicted ones listed above`, + `- Preserve the intent of both sides of the conflict — prefer the slice branch changes when the intent is unclear`, + ``, + `## Verification`, + ``, + `After committing, verify:`, + `1. \`git diff --name-only --diff-filter=U\` returns empty (no unmerged files)`, + `2. The conflicted files no longer contain any \`<<<<<<<\`, \`=======\`, or \`>>>>>>>\` markers`, + `3. \`git status\` shows a clean working tree`, + ].join("\n"); +} + function extractSliceExecutionExcerpt(content: string | null, relPath: string): string { if (!content) { return [ @@ -2850,6 +2965,8 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba const dir = resolveMilestonePath(base, mid); return dir ? join(dir, buildMilestoneFileName(mid, "SUMMARY")) : null; } + case "fix-merge": + return null; default: return null; } @@ -2864,7 +2981,16 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba * the summary allowed the unit to be marked complete when the LLM * skipped writing the UAT file (see #176). */ -function verifyExpectedArtifact(unitType: string, unitId: string, base: string): boolean { +export function verifyExpectedArtifact(unitType: string, unitId: string, base: string): boolean { + // fix-merge has no file artifact — verify by checking git state + if (unitType === "fix-merge") { + const unmerged = runGit(base, ["diff", "--name-only", "--diff-filter=U"], { allowFailure: true }); + if (unmerged && unmerged.trim()) return false; + if (existsSync(join(base, ".git", "MERGE_HEAD"))) return false; + if (existsSync(join(base, ".git", "SQUASH_MSG"))) return false; + return true; + } + const absPath = resolveExpectedArtifactPath(unitType, unitId, base); if (!absPath) return true; if (!existsSync(absPath)) return false; @@ -2953,6 +3079,8 @@ function diagnoseExpectedArtifact(unitType: string, unitId: string, base: string return `${relSliceFile(base, mid!, sid!, "UAT-RESULT")} (UAT result)`; case "complete-milestone": return `${relMilestoneFile(base, mid!, "SUMMARY")} (milestone summary)`; + case "fix-merge": + return "Clean working tree with no unmerged files, no MERGE_HEAD, no SQUASH_MSG (merge conflict resolution)"; default: return null; } diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 2a376bd7a..e23fc9bf9 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -44,6 +44,26 @@ export interface MergeSliceResult { deletedBranch: boolean; } +/** + * Thrown when a slice merge hits code conflicts in non-.gsd files. + * The working tree is left in a conflicted state (no reset) so the + * caller can dispatch a fix-merge session to resolve it. + */ +export class MergeConflictError extends Error { + constructor( + public readonly conflictedFiles: string[], + public readonly strategy: "squash" | "merge", + public readonly branch: string, + public readonly mainBranch: string, + ) { + super( + `${strategy === "merge" ? "Merge" : "Squash-merge"} of "${branch}" into "${mainBranch}" ` + + `failed with conflicts in ${conflictedFiles.length} non-.gsd file(s): ${conflictedFiles.join(", ")}`, + ); + this.name = "MergeConflictError"; + } +} + export interface PreMergeCheckResult { passed: boolean; skipped?: boolean; @@ -696,15 +716,9 @@ export class GitServiceImpl { this.git(["add", "-A"], { allowFailure: true }); // Don't throw — let the merge proceed } else { - // 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 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}`, - ); + // Non-.gsd/ conflicts: leave working tree in conflicted state and throw + // MergeConflictError so the caller can dispatch a fix-merge session. + throw new MergeConflictError(conflictedFiles, strategy, branch, mainBranch); } } else { // No conflicted files detected but merge still failed — reset and throw diff --git a/src/resources/extensions/gsd/tests/idle-recovery.test.ts b/src/resources/extensions/gsd/tests/idle-recovery.test.ts index 77fd50e38..6a424e196 100644 --- a/src/resources/extensions/gsd/tests/idle-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/idle-recovery.test.ts @@ -1,10 +1,12 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; import { resolveExpectedArtifactPath, writeBlockerPlaceholder, skipExecuteTask, + verifyExpectedArtifact, } from "../auto.ts"; let passed = 0; @@ -294,6 +296,119 @@ function cleanup(base: string): void { } } +// ═══ verifyExpectedArtifact: fix-merge ════════════════════════════════════════ + +/** Create a real git repo for fix-merge tests */ +function createGitBase(): string { + const base = mkdtempSync(join(tmpdir(), "gsd-fixmerge-test-")); + execSync("git init", { cwd: base, stdio: "ignore" }); + execSync("git config user.email test@test.com", { cwd: base, stdio: "ignore" }); + execSync("git config user.name Test", { cwd: base, stdio: "ignore" }); + writeFileSync(join(base, "README.md"), "init\n", "utf-8"); + execSync("git add -A && git commit -m init", { cwd: base, stdio: "ignore" }); + // Create .gsd structure for the fixture + mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true }); + return base; +} + +{ + console.log("\n=== verifyExpectedArtifact: fix-merge — clean repo returns true ==="); + const base = createGitBase(); + try { + const result = verifyExpectedArtifact("fix-merge", "M001/S01", base); + assert(result === true, "clean repo should verify as true"); + } finally { + cleanup(base); + } +} + +{ + console.log("\n=== verifyExpectedArtifact: fix-merge — MERGE_HEAD present returns false ==="); + const base = createGitBase(); + try { + writeFileSync(join(base, ".git", "MERGE_HEAD"), "abc123\n", "utf-8"); + const result = verifyExpectedArtifact("fix-merge", "M001/S01", base); + assert(result === false, "MERGE_HEAD present should return false"); + } finally { + cleanup(base); + } +} + +{ + console.log("\n=== verifyExpectedArtifact: fix-merge — SQUASH_MSG present returns false ==="); + const base = createGitBase(); + try { + writeFileSync(join(base, ".git", "SQUASH_MSG"), "squash msg\n", "utf-8"); + const result = verifyExpectedArtifact("fix-merge", "M001/S01", base); + assert(result === false, "SQUASH_MSG present should return false"); + } finally { + cleanup(base); + } +} + +{ + console.log("\n=== verifyExpectedArtifact: fix-merge — real UU conflict returns false ==="); + const base = createGitBase(); + try { + // Create a conflict: modify same file on two branches + writeFileSync(join(base, "conflict.txt"), "main content\n", "utf-8"); + execSync("git add -A && git commit -m 'main change'", { cwd: base, stdio: "ignore" }); + execSync("git checkout -b feature", { cwd: base, stdio: "ignore" }); + writeFileSync(join(base, "conflict.txt"), "feature content\n", "utf-8"); + execSync("git add -A && git commit -m 'feature change'", { cwd: base, stdio: "ignore" }); + execSync("git checkout main", { cwd: base, stdio: "ignore" }); + writeFileSync(join(base, "conflict.txt"), "different main content\n", "utf-8"); + execSync("git add -A && git commit -m 'diverge'", { cwd: base, stdio: "ignore" }); + try { execSync("git merge feature", { cwd: base, stdio: "ignore" }); } catch { /* expected conflict */ } + const result = verifyExpectedArtifact("fix-merge", "M001/S01", base); + assert(result === false, "UU conflict should return false"); + } finally { + execSync("git reset --hard HEAD", { cwd: base, stdio: "ignore" }); + cleanup(base); + } +} + +{ + console.log("\n=== verifyExpectedArtifact: fix-merge — real DU conflict returns false ==="); + const base = createGitBase(); + try { + writeFileSync(join(base, "deleted.txt"), "content\n", "utf-8"); + execSync("git add -A && git commit -m 'add file'", { cwd: base, stdio: "ignore" }); + execSync("git checkout -b feature2", { cwd: base, stdio: "ignore" }); + writeFileSync(join(base, "deleted.txt"), "modified on feature\n", "utf-8"); + execSync("git add -A && git commit -m 'modify on feature'", { cwd: base, stdio: "ignore" }); + execSync("git checkout main", { cwd: base, stdio: "ignore" }); + execSync("git rm deleted.txt", { cwd: base, stdio: "ignore" }); + execSync("git commit -m 'delete on main'", { cwd: base, stdio: "ignore" }); + try { execSync("git merge feature2", { cwd: base, stdio: "ignore" }); } catch { /* expected conflict */ } + const result = verifyExpectedArtifact("fix-merge", "M001/S01", base); + assert(result === false, "DU conflict should return false"); + } finally { + execSync("git reset --hard HEAD", { cwd: base, stdio: "ignore" }); + cleanup(base); + } +} + +{ + console.log("\n=== verifyExpectedArtifact: fix-merge — real AA conflict returns false ==="); + const base = createGitBase(); + try { + execSync("git checkout -b branch-a", { cwd: base, stdio: "ignore" }); + writeFileSync(join(base, "both.txt"), "branch-a content\n", "utf-8"); + execSync("git add -A && git commit -m 'add on branch-a'", { cwd: base, stdio: "ignore" }); + execSync("git checkout main", { cwd: base, stdio: "ignore" }); + execSync("git checkout -b branch-b", { cwd: base, stdio: "ignore" }); + writeFileSync(join(base, "both.txt"), "branch-b content\n", "utf-8"); + execSync("git add -A && git commit -m 'add on branch-b'", { cwd: base, stdio: "ignore" }); + try { execSync("git merge branch-a", { cwd: base, stdio: "ignore" }); } catch { /* expected conflict */ } + const result = verifyExpectedArtifact("fix-merge", "M001/S01", base); + assert(result === false, "AA conflict should return false"); + } finally { + execSync("git reset --hard HEAD", { cwd: base, stdio: "ignore" }); + cleanup(base); + } +} + // ═════════════════════════════════════════════════════════════════════════════ // Results // ═════════════════════════════════════════════════════════════════════════════ diff --git a/src/resources/extensions/gsd/worktree.ts b/src/resources/extensions/gsd/worktree.ts index 30cc35bcd..ecbfdfffe 100644 --- a/src/resources/extensions/gsd/worktree.ts +++ b/src/resources/extensions/gsd/worktree.ts @@ -22,6 +22,7 @@ import { loadEffectiveGSDPreferences } from "./preferences.ts"; // Re-export MergeSliceResult from the canonical source (D014 — type-only re-export) export type { MergeSliceResult } from "./git-service.ts"; +export { MergeConflictError } from "./git-service.ts"; // ─── Lazy GitServiceImpl Cache ─────────────────────────────────────────────