diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index df94ca7be..55fcb2c47 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -56,7 +56,7 @@ import { getProjectTotals, formatCost, formatTokenCount, } from "./metrics.js"; import { dirname, join } from "node:path"; -import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs"; import { execSync, execFileSync } from "node:child_process"; import { autoCommitCurrentBranch, @@ -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,60 @@ 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 or normal merge) + if (hasMergeHead || hasSquashMsg) { + try { + runGit(basePath, ["commit", "--no-edit"], { allowFailure: false }); + const mode = hasMergeHead ? "merge" : "squash commit"; + ctx.ui.notify(`Fix-merge session succeeded — finalized ${mode}.`, "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, abort merge/squash, reset and stop + if (hasMergeHead) { + // Properly abort an in-progress merge so MERGE_HEAD and related metadata are cleared + runGit(basePath, ["merge", "--abort"], { allowFailure: true }); + } else if (hasSquashMsg) { + // Squash-in-progress without MERGE_HEAD: remove stale squash metadata + try { + unlinkSync(squashMsgPath); + } catch { + // Best-effort cleanup; ignore failures + } + } + 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 +1136,58 @@ 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). + // Close out the previously active unit before overwriting currentUnit. + if (currentUnit) { + const modelId = ctx.model?.id ?? "unknown"; + snapshotUnitMetrics( + ctx, + currentUnit.type, + currentUnit.id, + currentUnit.startedAt, + modelId, + ); + saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id); + } + + // 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 }); @@ -2301,6 +2402,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 [ @@ -2875,6 +3015,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; } @@ -2889,7 +3031,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; @@ -2978,6 +3129,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 d4e07245f..be460082e 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -44,6 +44,35 @@ 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 { + readonly conflictedFiles: string[]; + readonly strategy: "squash" | "merge"; + readonly branch: string; + readonly mainBranch: string; + + constructor( + conflictedFiles: string[], + strategy: "squash" | "merge", + branch: string, + 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"; + this.conflictedFiles = conflictedFiles; + this.strategy = strategy; + this.branch = branch; + this.mainBranch = mainBranch; + } +} + export interface PreMergeCheckResult { passed: boolean; skipped?: boolean; @@ -101,24 +130,25 @@ export function readIntegrationBranch(basePath: string, milestoneId: string): st /** * Persist the integration branch for a milestone. * - * Called once when auto-mode starts on a milestone. Records the branch - * the user was on at that point, so that slice branches merge back to it - * instead of the repo's default branch. + * Called when auto-mode starts on a milestone. Records the branch the user + * was on at that point, so that slice branches merge back to it instead of + * the repo's default branch. Idempotent when the branch matches; updates + * the record when the user starts from a different branch. * * The file is committed immediately so it survives branch switches — the * pre-switch auto-commit excludes `.gsd/` to avoid merge conflicts, and * uncommitted `.gsd/` files are discarded during checkout. - * - * Skips writing if an integration branch is already recorded (idempotent - * across restarts) or if the current branch is already a GSD slice branch. */ export function writeIntegrationBranch(basePath: string, milestoneId: string, branch: string): void { // Don't record slice branches as the integration target if (SLICE_BRANCH_RE.test(branch)) return; - // Don't overwrite an existing integration branch - if (readIntegrationBranch(basePath, milestoneId) !== null) return; // Validate if (!VALID_BRANCH_NAME.test(branch)) return; + // Skip if already recorded with the same branch (idempotent across restarts). + // If recorded with a different branch, update it — the user started auto-mode + // from a new branch and expects slices to merge back there (#300). + const existingBranch = readIntegrationBranch(basePath, milestoneId); + if (existingBranch === branch) return; const metaFile = milestoneMetaPath(basePath, milestoneId); mkdirSync(join(basePath, ".gsd", "milestones", milestoneId), { recursive: true }); @@ -673,37 +703,60 @@ export class GitServiceImpl { 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(/\/$/, ""))), + const isRuntimeConflict = (f: string) => + RUNTIME_EXCLUSION_PATHS.some(excl => f.startsWith(excl.replace(/\/$/, ""))); + + const runtimeConflicts = conflictedFiles.filter(isRuntimeConflict); + const gsdConflicts = conflictedFiles.filter(f => f.startsWith(".gsd/") && !isRuntimeConflict(f)); + const otherConflicts = conflictedFiles.filter( + f => !isRuntimeConflict(f) && !f.startsWith(".gsd/"), ); - if (allRuntime) { - // Runtime-only conflicts: take ours and remove from index - for (const f of conflictedFiles) { + + let resolvedAny = false; + + if (runtimeConflicts.length > 0) { + // Runtime conflicts: take theirs and remove from index + for (const f of runtimeConflicts) { this.git(["checkout", "--theirs", "--", f], { allowFailure: true }); this.git(["rm", "--cached", "--ignore-unmatch", f], { allowFailure: true }); } - this.git(["add", "-A"], { allowFailure: true }); - // Don't throw — let the merge proceed - } else if (allGsd) { + resolvedAny = true; + } + + if (gsdConflicts.length > 0) { // 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) { + for (const f of gsdConflicts) { this.git(["checkout", "--theirs", "--", f], { allowFailure: true }); } + resolvedAny = true; + } + + if (resolvedAny) { this.git(["add", "-A"], { allowFailure: true }); - // Don't throw — let the merge proceed + + // Re-check remaining conflicts after auto-resolving runtime and .gsd/ files + const remaining = this.git(["diff", "--name-only", "--diff-filter=U"], { + allowFailure: true, + }); + if (remaining) { + const remainingFiles = remaining + .split("\n") + .filter(Boolean) + .filter(f => !isRuntimeConflict(f) && !f.startsWith(".gsd/")); + + if (remainingFiles.length > 0) { + // Non-runtime, non-.gsd/ conflicts: leave working tree in conflicted state and throw + // MergeConflictError so the caller can dispatch a fix-merge session. + throw new MergeConflictError(remainingFiles, strategy, branch, mainBranch); + } + } + // No remaining non-runtime, non-.gsd/ conflicts — 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}`, - ); + // No runtime or .gsd/ conflicts to auto-resolve; throw with original conflicted files + // so the caller can dispatch a fix-merge session. + throw new MergeConflictError(otherConflicts.length ? otherConflicts : conflictedFiles, strategy, branch, mainBranch); } } else { // No conflicted files detected but merge still failed — reset and throw diff --git a/src/resources/extensions/gsd/tests/git-service.test.ts b/src/resources/extensions/gsd/tests/git-service.test.ts index a08b29844..fbcbf1f34 100644 --- a/src/resources/extensions/gsd/tests/git-service.test.ts +++ b/src/resources/extensions/gsd/tests/git-service.test.ts @@ -1429,17 +1429,32 @@ async function main(): Promise { rmSync(repo, { recursive: true, force: true }); } - // ─── writeIntegrationBranch: idempotent — doesn't overwrite ─────────── + // ─── writeIntegrationBranch: updates when branch changes (#300) ────── - console.log("\n=== Integration branch: idempotent write ==="); + console.log("\n=== Integration branch: updates on branch change ==="); { const repo = initBranchTestRepo(); writeIntegrationBranch(repo, "M001", "f-123-first"); - writeIntegrationBranch(repo, "M001", "f-456-second"); // should NOT overwrite + writeIntegrationBranch(repo, "M001", "f-456-second"); // updates to new branch (#300) - assertEq(readIntegrationBranch(repo, "M001"), "f-123-first", "second write does not overwrite existing integration branch"); + assertEq(readIntegrationBranch(repo, "M001"), "f-456-second", "second write updates integration branch to new value"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ─── writeIntegrationBranch: same branch is idempotent ───────────────── + + console.log("\n=== Integration branch: same branch is idempotent ==="); + + { + const repo = initBranchTestRepo(); + + writeIntegrationBranch(repo, "M001", "f-123-first"); + writeIntegrationBranch(repo, "M001", "f-123-first"); // same branch — no-op + + assertEq(readIntegrationBranch(repo, "M001"), "f-123-first", "same branch write is idempotent"); rmSync(repo, { recursive: true, force: true }); } diff --git a/src/resources/extensions/gsd/tests/idle-recovery.test.ts b/src/resources/extensions/gsd/tests/idle-recovery.test.ts index 77fd50e38..ef1492e01 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 -b main", { 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 ─────────────────────────────────────────────