Merge pull request #310 from gsd-build/feat/fix-merge-session

feat: auto-resolve merge conflicts via fix-merge LLM session
This commit is contained in:
TÂCHES 2026-03-13 23:41:46 -06:00 committed by GitHub
commit e6d815a26a
5 changed files with 379 additions and 42 deletions

View file

@ -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 <file>\``,
`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;
}

View file

@ -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

View file

@ -1429,17 +1429,32 @@ async function main(): Promise<void> {
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 });
}

View file

@ -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
// ═════════════════════════════════════════════════════════════════════════════

View file

@ -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 ─────────────────────────────────────────────