diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 59db2abda..064cf16c7 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -60,10 +60,12 @@ import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync } from import { execSync, execFileSync } from "node:child_process"; import { autoCommitCurrentBranch, + captureIntegrationBranch, ensureSliceBranch, getCurrentBranch, getMainBranch, parseSliceBranch, + setActiveMilestoneId, switchToMain, mergeSliceToMain, } from "./worktree.ts"; @@ -361,6 +363,8 @@ export async function startAuto( unitDispatchCount.clear(); // Re-initialize metrics in case ledger was lost during pause if (!getLedger()) initMetrics(base); + // Ensure milestone ID is set on git service for integration branch resolution + if (currentMilestoneId) setActiveMilestoneId(base, currentMilestoneId); ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto"); ctx.ui.setFooter(hideFooter); ctx.ui.notify(stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info"); @@ -468,6 +472,15 @@ export async function startAuto( originalModelId = ctx.model?.id ?? null; originalModelProvider = ctx.model?.provider ?? null; + // Capture the integration branch — records the branch the user was on when + // auto-mode started. Slice branches will merge back to this branch instead + // of the repo's default (main/master). Idempotent: only writes if not + // already recorded, so restarts/resumes don't overwrite. + if (currentMilestoneId) { + captureIntegrationBranch(base, currentMilestoneId); + setActiveMilestoneId(base, currentMilestoneId); + } + // Initialize metrics — loads existing ledger from disk initMetrics(base); @@ -1002,8 +1015,13 @@ async function dispatchNextUnit( // Reset stuck detection for new milestone unitDispatchCount.clear(); unitRecoveryCount.clear(); + // Capture integration branch for the new milestone and update git service + captureIntegrationBranch(basePath, mid); + } + if (mid) { + currentMilestoneId = mid; + setActiveMilestoneId(basePath, mid); } - if (mid) currentMilestoneId = mid; if (!mid) { // Save final session before stopping diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 2d7cb53bd..1264dfdab 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -9,7 +9,8 @@ */ import { execSync } from "node:child_process"; -import { sep } from "node:path"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, sep } from "node:path"; import { detectWorktreeName, @@ -68,6 +69,86 @@ export const RUNTIME_EXCLUSION_PATHS: readonly string[] = [ ".gsd/STATE.md", ]; +// ─── Integration Branch Metadata ─────────────────────────────────────────── + +/** + * Path to the milestone metadata file that stores the integration branch. + * Format: .gsd/milestones//-META.json + */ +function milestoneMetaPath(basePath: string, milestoneId: string): string { + return join(basePath, ".gsd", "milestones", milestoneId, `${milestoneId}-META.json`); +} + +/** + * Read the integration branch recorded for a milestone. + * Returns null if no metadata file exists or the branch isn't set. + */ +export function readIntegrationBranch(basePath: string, milestoneId: string): string | null { + try { + const metaFile = milestoneMetaPath(basePath, milestoneId); + if (!existsSync(metaFile)) return null; + const data = JSON.parse(readFileSync(metaFile, "utf-8")); + const branch = data?.integrationBranch; + if (typeof branch === "string" && branch.trim() !== "" && VALID_BRANCH_NAME.test(branch)) { + return branch; + } + return null; + } catch { + return null; + } +} + +/** + * 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. + * + * 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; + + const metaFile = milestoneMetaPath(basePath, milestoneId); + mkdirSync(join(basePath, ".gsd", "milestones", milestoneId), { recursive: true }); + + // Merge with existing metadata if present + let existing: Record = {}; + try { + if (existsSync(metaFile)) { + existing = JSON.parse(readFileSync(metaFile, "utf-8")); + } + } catch { /* corrupt file — overwrite */ } + + existing.integrationBranch = branch; + writeFileSync(metaFile, JSON.stringify(existing, null, 2) + "\n", "utf-8"); + + // Commit immediately — .gsd/ files are discarded during branch switches + // (ensureSliceBranch excludes .gsd/ from pre-switch auto-commit and runs + // git checkout -- .gsd/ to prevent checkout conflicts). Without this + // commit, the metadata would be lost on the first branch switch. + try { + runGit(basePath, ["add", "--force", metaFile]); + runGit(basePath, ["commit", "-F", "-"], { + input: `chore(${milestoneId}): record integration branch`, + }); + } catch { + // Non-fatal — file is on disk even if commit fails (e.g. nothing to commit + // because the file was already tracked with identical content) + } +} + // ─── Git Helper ──────────────────────────────────────────────────────────── /** @@ -115,11 +196,23 @@ export class GitServiceImpl { readonly basePath: string; readonly prefs: GitPreferences; + /** Active milestone ID — used to resolve the integration branch. */ + private _milestoneId: string | null = null; + constructor(basePath: string, prefs: GitPreferences = {}) { this.basePath = basePath; this.prefs = prefs; } + /** + * Set the active milestone ID for integration branch resolution. + * When set, getMainBranch() will check the milestone's metadata file + * for a recorded integration branch before falling back to repo defaults. + */ + setMilestoneId(milestoneId: string | null): void { + this._milestoneId = milestoneId; + } + /** Convenience wrapper: run git in this repo's basePath. */ private git(args: string[], options: { allowFailure?: boolean; input?: string } = {}): string { return runGit(this.basePath, args, options); @@ -212,9 +305,18 @@ export class GitServiceImpl { // ─── Branch Queries ──────────────────────────────────────────────────── /** - * Get the "main" branch for this repo. - * In a worktree: returns worktree/ (the worktree's base branch). - * In the main tree: origin/HEAD symbolic-ref → main/master fallback → current branch. + * Get the "main" (integration) branch for this repo. + * + * Resolution order: + * 1. Explicit `main_branch` preference (user override, highest priority) + * 2. Milestone integration branch from metadata file (recorded at milestone start) + * 3. Worktree base branch (worktree/) + * 4. origin/HEAD symbolic-ref → main/master fallback → current branch + * + * The integration branch (step 2) is what makes feature-branch workflows + * work correctly: when a user starts GSD on `f-123-new-thing`, that branch + * is recorded as the integration target, and all slice branches merge back + * to it instead of the repo's default branch. */ getMainBranch(): string { // Explicit preference takes priority (double-check validity as defense-in-depth) @@ -222,6 +324,16 @@ export class GitServiceImpl { return this.prefs.main_branch; } + // Check milestone integration branch — recorded when auto-mode starts + if (this._milestoneId) { + const integrationBranch = readIntegrationBranch(this.basePath, this._milestoneId); + if (integrationBranch) { + // Verify the branch still exists locally (could have been deleted) + const exists = this.git(["show-ref", "--verify", `refs/heads/${integrationBranch}`], { allowFailure: true }); + if (exists) return integrationBranch; + } + } + const wtName = detectWorktreeName(this.basePath); if (wtName) { const wtBranch = `worktree/${wtName}`; diff --git a/src/resources/extensions/gsd/tests/git-service.test.ts b/src/resources/extensions/gsd/tests/git-service.test.ts index 2811db1b1..b8d5738d2 100644 --- a/src/resources/extensions/gsd/tests/git-service.test.ts +++ b/src/resources/extensions/gsd/tests/git-service.test.ts @@ -9,6 +9,8 @@ import { RUNTIME_EXCLUSION_PATHS, VALID_BRANCH_NAME, runGit, + readIntegrationBranch, + writeIntegrationBranch, type GitPreferences, type CommitOptions, type MergeSliceResult, @@ -1370,6 +1372,230 @@ async function main(): Promise { assert(true, "PreMergeCheckResult type exported and usable"); } + // ═══════════════════════════════════════════════════════════════════════ + // Integration branch — feature-branch workflow support + // ═══════════════════════════════════════════════════════════════════════ + + // ─── writeIntegrationBranch / readIntegrationBranch: round-trip ──────── + + console.log("\n=== Integration branch: write and read ==="); + + { + const repo = initBranchTestRepo(); + + // Initially no integration branch + assertEq(readIntegrationBranch(repo, "M001"), null, "readIntegrationBranch returns null when no metadata"); + + // Write integration branch + writeIntegrationBranch(repo, "M001", "f-123-new-thing"); + assertEq(readIntegrationBranch(repo, "M001"), "f-123-new-thing", "readIntegrationBranch returns written branch"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ─── writeIntegrationBranch: idempotent — doesn't overwrite ─────────── + + console.log("\n=== Integration branch: idempotent write ==="); + + { + const repo = initBranchTestRepo(); + + writeIntegrationBranch(repo, "M001", "f-123-first"); + writeIntegrationBranch(repo, "M001", "f-456-second"); // should NOT overwrite + + assertEq(readIntegrationBranch(repo, "M001"), "f-123-first", "second write does not overwrite existing integration branch"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ─── writeIntegrationBranch: rejects slice branches ─────────────────── + + console.log("\n=== Integration branch: rejects slice branches ==="); + + { + const repo = initBranchTestRepo(); + + writeIntegrationBranch(repo, "M001", "gsd/M001/S01"); + assertEq(readIntegrationBranch(repo, "M001"), null, "slice branches are not recorded as integration branch"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ─── writeIntegrationBranch: rejects invalid branch names ───────────── + + console.log("\n=== Integration branch: rejects invalid names ==="); + + { + const repo = initBranchTestRepo(); + + writeIntegrationBranch(repo, "M001", "bad; rm -rf /"); + assertEq(readIntegrationBranch(repo, "M001"), null, "invalid branch name is not recorded"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ─── getMainBranch: uses integration branch when milestone set ──────── + + console.log("\n=== getMainBranch: integration branch from milestone metadata ==="); + + { + const repo = initBranchTestRepo(); + + // Create a feature branch + run("git checkout -b f-123-feature", repo); + run("git checkout main", repo); + + // Write integration branch metadata + writeIntegrationBranch(repo, "M001", "f-123-feature"); + + // Without milestone set, getMainBranch returns "main" + const svc = new GitServiceImpl(repo); + assertEq(svc.getMainBranch(), "main", "getMainBranch returns main when no milestone set"); + + // With milestone set, getMainBranch returns the integration branch + svc.setMilestoneId("M001"); + assertEq(svc.getMainBranch(), "f-123-feature", "getMainBranch returns integration branch when milestone set"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ─── getMainBranch: main_branch pref still takes priority ───────────── + + console.log("\n=== getMainBranch: main_branch pref overrides integration branch ==="); + + { + const repo = initBranchTestRepo(); + + run("git checkout -b f-123-feature", repo); + run("git checkout -b trunk", repo); + run("git checkout main", repo); + + writeIntegrationBranch(repo, "M001", "f-123-feature"); + + // Explicit preference still wins + const svc = new GitServiceImpl(repo, { main_branch: "trunk" }); + svc.setMilestoneId("M001"); + assertEq(svc.getMainBranch(), "trunk", "main_branch preference overrides integration branch"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ─── getMainBranch: falls back when integration branch deleted ──────── + + console.log("\n=== getMainBranch: fallback when integration branch deleted ==="); + + { + const repo = initBranchTestRepo(); + + // Write metadata pointing to a branch that doesn't exist + writeIntegrationBranch(repo, "M001", "deleted-branch"); + + const svc = new GitServiceImpl(repo); + svc.setMilestoneId("M001"); + assertEq(svc.getMainBranch(), "main", "getMainBranch falls back to main when integration branch no longer exists"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ─── End-to-end: feature branch workflow ────────────────────────────── + + console.log("\n=== End-to-end: feature branch workflow ==="); + + { + const repo = initBranchTestRepo(); + + // Simulate: user creates feature branch and starts GSD + run("git checkout -b f-123-new-thing", repo); + createFile(repo, "setup.txt", "initial setup"); + run("git add -A", repo); + run("git commit -m 'initial feature setup'", repo); + + // Record integration branch (this is what auto.ts does at startup) + writeIntegrationBranch(repo, "M001", "f-123-new-thing"); + + // Create GitServiceImpl with milestone set + const svc = new GitServiceImpl(repo); + svc.setMilestoneId("M001"); + + // Verify getMainBranch returns the feature branch, not "main" + assertEq(svc.getMainBranch(), "f-123-new-thing", "e2e: getMainBranch returns feature branch"); + + // Create slice branch — should branch from f-123-new-thing (current) + svc.ensureSliceBranch("M001", "S01"); + assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "e2e: slice branch created"); + + // The slice branch should have the feature branch's commit + const log = run("git log --oneline", repo); + assert(log.includes("initial feature setup"), "e2e: slice branch inherits feature branch content"); + + // Do work on the slice branch + createFile(repo, "src/feature.ts", "export const feature = true;"); + svc.commit({ message: "feat: add feature module" }); + + // switchToMain should go to feature branch + svc.switchToMain(); + assertEq(svc.getCurrentBranch(), "f-123-new-thing", "e2e: switchToMain goes to feature branch, not main"); + + // mergeSliceToMain should merge into feature branch + const result = svc.mergeSliceToMain("M001", "S01", "Add feature module"); + assertEq(result.mergedCommitMessage, "feat(M001/S01): Add feature module", "e2e: merge commit message correct"); + assertEq(svc.getCurrentBranch(), "f-123-new-thing", "e2e: after merge, still on feature branch"); + + // The feature branch should have the merged work + const files = run("git ls-files", repo); + assert(files.includes("src/feature.ts"), "e2e: merged file exists on feature branch"); + + // Main should NOT have the merged work + run("git checkout main", repo); + const mainFiles = run("git ls-files", repo); + assert(!mainFiles.includes("src/feature.ts"), "e2e: main does NOT have merged work — it stays on the feature branch"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ─── Per-milestone isolation: different milestones, different targets ── + + console.log("\n=== Integration branch: per-milestone isolation ==="); + + { + const repo = initBranchTestRepo(); + + run("git checkout -b feature-a", repo); + run("git checkout -b feature-b", repo); + run("git checkout main", repo); + + writeIntegrationBranch(repo, "M001", "feature-a"); + writeIntegrationBranch(repo, "M002", "feature-b"); + + const svc = new GitServiceImpl(repo); + + svc.setMilestoneId("M001"); + assertEq(svc.getMainBranch(), "feature-a", "M001 integration branch is feature-a"); + + svc.setMilestoneId("M002"); + assertEq(svc.getMainBranch(), "feature-b", "M002 integration branch is feature-b"); + + svc.setMilestoneId(null); + assertEq(svc.getMainBranch(), "main", "no milestone set → falls back to main"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ─── Backward compatibility: no metadata → existing behavior ────────── + + console.log("\n=== Integration branch: backward compat ==="); + + { + const repo = initBranchTestRepo(); + const svc = new GitServiceImpl(repo); + + // Set milestone but no metadata file exists + svc.setMilestoneId("M001"); + assertEq(svc.getMainBranch(), "main", "backward compat: no metadata file → falls back to main"); + + rmSync(repo, { recursive: true, force: true }); + } + // ─── untrackRuntimeFiles: removes tracked runtime files from index ─── console.log("\n=== untrackRuntimeFiles ==="); diff --git a/src/resources/extensions/gsd/tests/worktree.test.ts b/src/resources/extensions/gsd/tests/worktree.test.ts index 5411e9a54..381cffdf2 100644 --- a/src/resources/extensions/gsd/tests/worktree.test.ts +++ b/src/resources/extensions/gsd/tests/worktree.test.ts @@ -5,17 +5,21 @@ import { execSync } from "node:child_process"; import { autoCommitCurrentBranch, + captureIntegrationBranch, detectWorktreeName, ensureSliceBranch, getActiveSliceBranch, getCurrentBranch, + getMainBranch, getSliceBranchName, isOnSliceBranch, mergeSliceToMain, parseSliceBranch, + setActiveMilestoneId, SLICE_BRANCH_RE, switchToMain, } from "../worktree.ts"; +import { readIntegrationBranch } from "../git-service.ts"; import { deriveState } from "../state.ts"; import { indexWorkspace } from "../workspace-index.ts"; @@ -252,6 +256,354 @@ async function main(): Promise { rmSync(base3, { recursive: true, force: true }); + // ═══════════════════════════════════════════════════════════════════════ + // Integration branch — facade-level tests + // + // These exercise the same codepath auto.ts uses: + // captureIntegrationBranch() → setActiveMilestoneId() → getMainBranch() + // → switchToMain() → mergeSliceToMain() + // ═══════════════════════════════════════════════════════════════════════ + + // ── captureIntegrationBranch on a feature branch ────────────────────── + + console.log("\n=== captureIntegrationBranch: records current branch ==="); + + { + const repo = mkdtempSync(join(tmpdir(), "gsd-integ-facade-")); + run("git init -b main", repo); + run("git config user.name 'Pi Test'", repo); + run("git config user.email 'pi@example.com'", repo); + writeFileSync(join(repo, "README.md"), "init\n"); + run("git add -A && git commit -m init", repo); + + run("git checkout -b f-123-thing", repo); + assertEq(getCurrentBranch(repo), "f-123-thing", "on feature branch"); + + captureIntegrationBranch(repo, "M001"); + assertEq(readIntegrationBranch(repo, "M001"), "f-123-thing", + "captureIntegrationBranch records the current branch"); + + // Verify it was committed (not just written to disk) + const logOut = run("git log --oneline -1", repo); + assert(logOut.includes("integration branch"), "metadata committed to git"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ── captureIntegrationBranch is idempotent on same lineage ────────── + + console.log("\n=== captureIntegrationBranch: idempotent ==="); + + { + const repo = mkdtempSync(join(tmpdir(), "gsd-integ-idem-")); + run("git init -b main", repo); + run("git config user.name 'Pi Test'", repo); + run("git config user.email 'pi@example.com'", repo); + writeFileSync(join(repo, "README.md"), "init\n"); + run("git add -A && git commit -m init", repo); + run("git checkout -b f-first", repo); + + captureIntegrationBranch(repo, "M001"); + setActiveMilestoneId(repo, "M001"); + assertEq(readIntegrationBranch(repo, "M001"), "f-first", + "first capture records f-first"); + + // Capture again on the same branch (simulates restart/resume) — should NOT overwrite + captureIntegrationBranch(repo, "M001"); + assertEq(readIntegrationBranch(repo, "M001"), "f-first", + "second capture on same branch does not overwrite"); + + // After creating a slice branch (which inherits the metadata commit), + // capture should still be idempotent + ensureSliceBranch(repo, "M001", "S01"); + // Now on gsd/M001/S01 — capture should be no-op (slice branch rejected) + captureIntegrationBranch(repo, "M001"); + switchToMain(repo); + assertEq(readIntegrationBranch(repo, "M001"), "f-first", + "capture from slice branch is no-op, original preserved"); + assertEq(getCurrentBranch(repo), "f-first", + "switchToMain returns to feature branch, confirming integration branch works"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ── captureIntegrationBranch skips slice branches ───────────────────── + + console.log("\n=== captureIntegrationBranch: skips slice branches ==="); + + { + const repo = mkdtempSync(join(tmpdir(), "gsd-integ-skip-")); + run("git init -b main", repo); + run("git config user.name 'Pi Test'", repo); + run("git config user.email 'pi@example.com'", repo); + writeFileSync(join(repo, "README.md"), "init\n"); + run("git add -A && git commit -m init", repo); + + run("git checkout -b gsd/M001/S01", repo); + captureIntegrationBranch(repo, "M001"); + + assertEq(readIntegrationBranch(repo, "M001"), null, + "capture from slice branch is a no-op"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ── setActiveMilestoneId makes getMainBranch return integration branch ─ + + console.log("\n=== setActiveMilestoneId + getMainBranch ==="); + + { + const repo = mkdtempSync(join(tmpdir(), "gsd-integ-main-")); + run("git init -b main", repo); + run("git config user.name 'Pi Test'", repo); + run("git config user.email 'pi@example.com'", repo); + writeFileSync(join(repo, "README.md"), "init\n"); + run("git add -A && git commit -m init", repo); + + run("git checkout -b my-feature", repo); + captureIntegrationBranch(repo, "M001"); + + // Without milestone set, getMainBranch returns "main" + setActiveMilestoneId(repo, null); + assertEq(getMainBranch(repo), "main", + "getMainBranch returns main without milestone set"); + + // With milestone set, getMainBranch returns feature branch + setActiveMilestoneId(repo, "M001"); + assertEq(getMainBranch(repo), "my-feature", + "getMainBranch returns integration branch with milestone set"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ── Full multi-slice lifecycle on a feature branch ──────────────────── + // + // Simulates what auto.ts does: start on feature branch, capture it, + // create S01, work, merge S01 back to feature branch, then S02 branches + // from feature branch (not main), works, merges to feature branch. + // Main stays untouched throughout. + + console.log("\n=== Multi-slice lifecycle on feature branch ==="); + + { + const repo = mkdtempSync(join(tmpdir(), "gsd-integ-multi-")); + run("git init -b main", repo); + run("git config user.name 'Pi Test'", repo); + run("git config user.email 'pi@example.com'", repo); + writeFileSync(join(repo, "README.md"), "base\n"); + run("git add -A && git commit -m init", repo); + + // User creates feature branch + run("git checkout -b feature/big-change", repo); + writeFileSync(join(repo, "setup.txt"), "feature setup\n"); + run("git add -A && git commit -m 'feat: initial setup'", repo); + + // auto.ts startup: capture + set milestone + captureIntegrationBranch(repo, "M001"); + setActiveMilestoneId(repo, "M001"); + + assertEq(getMainBranch(repo), "feature/big-change", + "multi: getMainBranch returns feature branch"); + + // ── S01 lifecycle ────────────────────────────────────────────────── + ensureSliceBranch(repo, "M001", "S01"); + assertEq(getCurrentBranch(repo), "gsd/M001/S01", "multi: on S01"); + + // Verify S01 has feature branch content + assert(existsSync(join(repo, "setup.txt")), + "multi: S01 inherited feature branch content"); + + writeFileSync(join(repo, "s01-work.txt"), "s01 output\n"); + run("git add -A && git commit -m 'feat(S01): work'", repo); + + switchToMain(repo); + assertEq(getCurrentBranch(repo), "feature/big-change", + "multi: switchToMain goes to feature branch"); + + const s01merge = mergeSliceToMain(repo, "M001", "S01", "First slice"); + assertEq(getCurrentBranch(repo), "feature/big-change", + "multi: after S01 merge, on feature branch"); + assert(existsSync(join(repo, "s01-work.txt")), + "multi: S01 work merged to feature branch"); + assert(s01merge.deletedBranch, "multi: S01 branch deleted"); + + // Main should NOT have S01 work + run("git stash", repo); // stash any .gsd changes + run("git checkout main", repo); + assert(!existsSync(join(repo, "s01-work.txt")), + "multi: main does NOT have S01 work"); + run("git checkout feature/big-change", repo); + run("git stash pop || true", repo); + + // ── S02 lifecycle ────────────────────────────────────────────────── + // S02 should branch from feature/big-change which now has S01's work + ensureSliceBranch(repo, "M001", "S02"); + assertEq(getCurrentBranch(repo), "gsd/M001/S02", "multi: on S02"); + + // S02 should have S01's merged output (branched from feature branch) + assert(existsSync(join(repo, "s01-work.txt")), + "multi: S02 has S01 output (inherited via feature branch)"); + + writeFileSync(join(repo, "s02-work.txt"), "s02 output\n"); + run("git add -A && git commit -m 'feat(S02): work'", repo); + + switchToMain(repo); + assertEq(getCurrentBranch(repo), "feature/big-change", + "multi: switchToMain goes to feature branch after S02"); + + const s02merge = mergeSliceToMain(repo, "M001", "S02", "Second slice"); + assertEq(getCurrentBranch(repo), "feature/big-change", + "multi: after S02 merge, on feature branch"); + assert(existsSync(join(repo, "s02-work.txt")), + "multi: S02 work merged to feature branch"); + assert(existsSync(join(repo, "s01-work.txt")), + "multi: S01 work still on feature branch after S02 merge"); + assert(s02merge.deletedBranch, "multi: S02 branch deleted"); + + // Final check: main still untouched + run("git stash", repo); + run("git checkout main", repo); + assert(!existsSync(join(repo, "s01-work.txt")), + "multi: main still lacks S01 work at end"); + assert(!existsSync(join(repo, "s02-work.txt")), + "multi: main still lacks S02 work at end"); + assertEq(readFileSync(join(repo, "README.md"), "utf-8").trim(), "base", + "multi: main README unchanged"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ── Resume scenario: milestone ID re-set after restart ──────────────── + // + // Simulates crash + restart: the cached GitServiceImpl is lost, but the + // metadata file persists on disk. Re-calling setActiveMilestoneId should + // restore integration branch resolution. + + console.log("\n=== Resume: milestone ID re-set restores integration branch ==="); + + { + const repo = mkdtempSync(join(tmpdir(), "gsd-integ-resume-")); + run("git init -b main", repo); + run("git config user.name 'Pi Test'", repo); + run("git config user.email 'pi@example.com'", repo); + writeFileSync(join(repo, "README.md"), "init\n"); + run("git add -A && git commit -m init", repo); + + run("git checkout -b my-feature", repo); + captureIntegrationBranch(repo, "M001"); + setActiveMilestoneId(repo, "M001"); + + // Create a slice and do some work + ensureSliceBranch(repo, "M001", "S01"); + writeFileSync(join(repo, "work.txt"), "wip\n"); + run("git add -A && git commit -m 'wip'", repo); + + // Simulate "restart" — clear milestone ID (fresh service instance) + setActiveMilestoneId(repo, null); + assertEq(getMainBranch(repo), "main", + "resume: getMainBranch returns main when milestone cleared"); + + // Re-set milestone ID (what auto.ts does on resume) + setActiveMilestoneId(repo, "M001"); + assertEq(getMainBranch(repo), "my-feature", + "resume: getMainBranch returns feature branch after re-set"); + + // Full lifecycle still works after resume + switchToMain(repo); + assertEq(getCurrentBranch(repo), "my-feature", + "resume: switchToMain goes to feature branch after re-set"); + + const result = mergeSliceToMain(repo, "M001", "S01", "Resume slice"); + assertEq(getCurrentBranch(repo), "my-feature", + "resume: merge lands on feature branch after re-set"); + assert(existsSync(join(repo, "work.txt")), + "resume: merged work exists on feature branch"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ── Backward compat: no metadata file, plain main workflow ──────────── + // + // Simulates existing projects that were created before this feature. + // No metadata file exists, milestone ID is set — getMainBranch should + // still return "main" and the entire slice lifecycle works unchanged. + + console.log("\n=== Backward compat: no metadata, main workflow ==="); + + { + const repo = mkdtempSync(join(tmpdir(), "gsd-integ-compat-")); + run("git init -b main", repo); + run("git config user.name 'Pi Test'", repo); + run("git config user.email 'pi@example.com'", repo); + writeFileSync(join(repo, "README.md"), "init\n"); + run("git add -A && git commit -m init", repo); + + // Set milestone but DON'T capture integration branch (simulates old project) + setActiveMilestoneId(repo, "M001"); + + assertEq(getMainBranch(repo), "main", + "compat: getMainBranch returns main without metadata"); + + // Full lifecycle on main still works + ensureSliceBranch(repo, "M001", "S01"); + writeFileSync(join(repo, "feature.txt"), "new\n"); + run("git add -A && git commit -m 'feat: work'", repo); + + switchToMain(repo); + assertEq(getCurrentBranch(repo), "main", + "compat: switchToMain goes to main"); + + const result = mergeSliceToMain(repo, "M001", "S01", "Compat slice"); + assertEq(getCurrentBranch(repo), "main", + "compat: merge lands on main"); + assert(existsSync(join(repo, "feature.txt")), + "compat: merged work exists on main"); + assert(result.deletedBranch, "compat: branch deleted"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ── ensureSliceBranch from another slice with integration branch ────── + // + // When on gsd/M001/S01 and creating S02, the code falls back to + // getMainBranch() (not the current slice). With integration branch set, + // S02 should branch from the feature branch. + + console.log("\n=== ensureSliceBranch: S02 from S01 uses integration branch as base ==="); + + { + const repo = mkdtempSync(join(tmpdir(), "gsd-integ-chain-")); + run("git init -b main", repo); + run("git config user.name 'Pi Test'", repo); + run("git config user.email 'pi@example.com'", repo); + writeFileSync(join(repo, "README.md"), "init\n"); + run("git add -A && git commit -m init", repo); + + run("git checkout -b dev-branch", repo); + writeFileSync(join(repo, "dev-only.txt"), "from dev\n"); + run("git add -A && git commit -m 'dev setup'", repo); + + captureIntegrationBranch(repo, "M001"); + setActiveMilestoneId(repo, "M001"); + + // Create S01 (from dev-branch) + ensureSliceBranch(repo, "M001", "S01"); + writeFileSync(join(repo, "s01.txt"), "s01\n"); + run("git add -A && git commit -m 's01 work'", repo); + + // While on S01, create S02 — should fall back to integration branch + ensureSliceBranch(repo, "M001", "S02"); + assertEq(getCurrentBranch(repo), "gsd/M001/S02", "chain: on S02"); + + // S02 should be based on dev-branch (the integration branch) + assert(existsSync(join(repo, "dev-only.txt")), + "chain: S02 has dev-branch content"); + assert(!existsSync(join(repo, "s01.txt")), + "chain: S02 does NOT have S01 content (not chained from S01)"); + + rmSync(repo, { recursive: true, force: true }); + } + rmSync(base, { recursive: true, force: true }); console.log(`\nResults: ${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); diff --git a/src/resources/extensions/gsd/worktree.ts b/src/resources/extensions/gsd/worktree.ts index aa4a362f0..30cc35bcd 100644 --- a/src/resources/extensions/gsd/worktree.ts +++ b/src/resources/extensions/gsd/worktree.ts @@ -17,7 +17,7 @@ import { sep } from "node:path"; -import { GitServiceImpl } from "./git-service.ts"; +import { GitServiceImpl, writeIntegrationBranch } from "./git-service.ts"; import { loadEffectiveGSDPreferences } from "./preferences.ts"; // Re-export MergeSliceResult from the canonical source (D014 — type-only re-export) @@ -43,6 +43,25 @@ function getService(basePath: string): GitServiceImpl { return cachedService; } +/** + * Set the active milestone ID on the cached GitServiceImpl. + * This enables integration branch resolution in getMainBranch(). + */ +export function setActiveMilestoneId(basePath: string, milestoneId: string | null): void { + getService(basePath).setMilestoneId(milestoneId); +} + +/** + * Record the current branch as the integration branch for a milestone. + * Called once when auto-mode starts — captures where slice branches should + * merge back to. No-op if already recorded or if on a GSD slice branch. + */ +export function captureIntegrationBranch(basePath: string, milestoneId: string): void { + const svc = getService(basePath); + const current = svc.getCurrentBranch(); + writeIntegrationBranch(basePath, milestoneId, current); +} + // ─── Pure Utility Functions (unchanged) ──────────────────────────────────── /**