Merge branch 'main' into copilot/fix-auto-mode-git-issues
This commit is contained in:
commit
658e83bd58
5 changed files with 379 additions and 42 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue