From 0607fba4dc6c27b699ed65ed6fb12937d649cdc7 Mon Sep 17 00:00:00 2001 From: jonathancostin <66714927+jonathancostin@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:48:04 -0500 Subject: [PATCH] =?UTF-8?q?fix:=20worktree=20branch=20safety=20=E2=80=94?= =?UTF-8?q?=20namespacing=20and=20slice=20branch=20base=20selection=20(#92?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: worktree branch namespacing and fresh-start flow - Namespace slice branches by worktree name (gsd///) to prevent git checkout conflicts when multiple worktrees work on the same milestone - getMainBranch() returns worktree/ inside a worktree so slice merges target the worktree branch instead of main (which is checked out elsewhere) - Add continue/fresh-start prompt when creating a worktree with existing milestones - Restyle all worktree command output with consistent semantic color palette - Add parseSliceBranch() and SLICE_BRANCH_RE for robust branch name parsing - Fix duplicate getCurrentBranch import in auto.ts - Add 40-assertion integration test covering full worktree lifecycle * fix: branch slice from current branch, not main ensureSliceBranch always branched from getMainBranch() (main/master), but planning artifacts (CONTEXT, ROADMAP, etc.) may only exist on the working branch (e.g. "developer"). The slice branch would lose all planning artifacts, causing deriveState to see pre-planning and the rebuildState post-hook to overwrite STATE.md with a blank state. Now branches from the current branch when it is not itself a slice branch. Falls back to main when on a slice branch to avoid chaining. Adds regression tests for both cases. --- src/resources/extensions/gsd/auto.ts | 13 +- .../extensions/gsd/prompts/system.md | 2 +- .../gsd/tests/worktree-integration.test.ts | 253 ++++++++++++++++++ .../extensions/gsd/tests/worktree.test.ts | 117 +++++++- .../extensions/gsd/workspace-index.ts | 4 +- .../extensions/gsd/worktree-command.ts | 188 ++++++++++--- src/resources/extensions/gsd/worktree.ts | 102 ++++++- 7 files changed, 622 insertions(+), 57 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/worktree-integration.test.ts diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 2bd8b05c2..498b6ffa8 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -61,7 +61,9 @@ import { autoCommitCurrentBranch, ensureSliceBranch, getCurrentBranch, + getMainBranch, getSliceBranchName, + parseSliceBranch, switchToMain, mergeSliceToMain, } from "./worktree.ts"; @@ -905,10 +907,10 @@ async function dispatchNextUnit( // - complete-milestone runs on a slice branch (last slice bypass) { const currentBranch = getCurrentBranch(basePath); - const branchMatch = currentBranch.match(/^gsd\/(M\d+)\/(S\d+)$/); - if (branchMatch) { - const branchMid = branchMatch[1]!; - const branchSid = branchMatch[2]!; + const parsedBranch = parseSliceBranch(currentBranch); + if (parsedBranch) { + const branchMid = parsedBranch.milestoneId; + const branchSid = parsedBranch.sliceId; // Check if this slice is marked done in the roadmap const roadmapFile = resolveMilestoneFile(basePath, branchMid, "ROADMAP"); const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; @@ -922,8 +924,9 @@ async function dispatchNextUnit( const mergeResult = mergeSliceToMain( basePath, branchMid, branchSid, sliceTitleForMerge, ); + const targetBranch = getMainBranch(basePath); ctx.ui.notify( - `Merged ${mergeResult.branch} → main.`, + `Merged ${mergeResult.branch} → ${targetBranch}.`, "info", ); // Re-derive state from main so downstream logic sees merged state diff --git a/src/resources/extensions/gsd/prompts/system.md b/src/resources/extensions/gsd/prompts/system.md index 9d50470d4..eb2caaa49 100644 --- a/src/resources/extensions/gsd/prompts/system.md +++ b/src/resources/extensions/gsd/prompts/system.md @@ -76,7 +76,7 @@ Titles live inside file content (headings, frontmatter), not in file or director - **Slices** are demoable vertical increments (S01, S02, ...) ordered by risk. After each slice completes, the roadmap is reassessed before the next slice begins. - **Tasks** are single-context-window units of work (T01, T02, ...) - Checkboxes in roadmap and plan files track completion (`[ ]` → `[x]`) -- Each slice gets its own git branch: `gsd/M001/S01` +- Each slice gets its own git branch: `gsd/M001/S01` (or `gsd//M001/S01` when inside a worktree) - Slices are squash-merged to main when complete - Summaries compress prior work — read them instead of re-reading all task details - `STATE.md` is the quick-glance status file — keep it updated after changes diff --git a/src/resources/extensions/gsd/tests/worktree-integration.test.ts b/src/resources/extensions/gsd/tests/worktree-integration.test.ts new file mode 100644 index 000000000..0e576abfa --- /dev/null +++ b/src/resources/extensions/gsd/tests/worktree-integration.test.ts @@ -0,0 +1,253 @@ +/** + * Worktree Integration Tests + * + * Tests the full lifecycle of GSD operations inside a worktree: + * - Branch namespacing (gsd/// instead of gsd//) + * - getMainBranch returns worktree/ inside a worktree + * - switchToMain goes to worktree/, not main + * - mergeSliceToMain merges into worktree/ + * - Parallel worktrees don't conflict on branch names + * - State derivation works correctly inside worktrees + */ + +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; + +import { + createWorktree, + listWorktrees, + removeWorktree, + worktreePath, + worktreeBranchName, +} from "../worktree-manager.ts"; + +import { + detectWorktreeName, + ensureSliceBranch, + getActiveSliceBranch, + getCurrentBranch, + getMainBranch, + getSliceBranchName, + isOnSliceBranch, + mergeSliceToMain, + switchToMain, + autoCommitCurrentBranch, +} from "../worktree.ts"; + +import { deriveState } from "../state.ts"; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) passed++; + else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) passed++; + else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +function run(command: string, cwd: string): string { + return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim(); +} + +// ─── Test repo setup ────────────────────────────────────────────────────────── + +const base = mkdtempSync(join(tmpdir(), "gsd-wt-integration-")); +run("git init -b main", base); +run("git config user.name 'Pi Test'", base); +run("git config user.email 'pi@example.com'", base); + +// Create a project with one milestone and two slices +mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true }); +mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S02", "tasks"), { recursive: true }); +writeFileSync(join(base, "README.md"), "# Test Project\n", "utf-8"); +writeFileSync( + join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), + [ + "# M001: Demo", + "", + "## Slices", + "- [ ] **S01: First** `risk:low` `depends:[]`", + " > After this: part one works", + "- [ ] **S02: Second** `risk:low` `depends:[]`", + " > After this: part two works", + ].join("\n") + "\n", + "utf-8", +); +writeFileSync( + join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"), + "# S01: First\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Must-Haves\n- done\n\n## Tasks\n- [ ] **T01: Implement** `est:10m`\n do it\n", + "utf-8", +); +writeFileSync( + join(base, ".gsd", "milestones", "M001", "slices", "S02", "S02-PLAN.md"), + "# S02: Second\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Must-Haves\n- done\n\n## Tasks\n- [ ] **T01: Implement** `est:10m`\n do it\n", + "utf-8", +); +run("git add .", base); +run("git commit -m 'chore: init'", base); + +async function main(): Promise { + // ── Verify main tree baseline ────────────────────────────────────────────── + + console.log("\n=== Main tree baseline ==="); + assertEq(getMainBranch(base), "main", "main tree getMainBranch returns main"); + assertEq(detectWorktreeName(base), null, "main tree not detected as worktree"); + + // ── Create worktree and verify detection ─────────────────────────────────── + + console.log("\n=== Create worktree ==="); + const wt = createWorktree(base, "alpha"); + assert(existsSync(wt.path), "worktree created on disk"); + assertEq(wt.branch, "worktree/alpha", "worktree branch name"); + + console.log("\n=== Worktree detection ==="); + assertEq(detectWorktreeName(wt.path), "alpha", "detectWorktreeName inside worktree"); + assertEq(getMainBranch(wt.path), "worktree/alpha", "getMainBranch returns worktree branch inside worktree"); + + // ── Verify current branch inside worktree ────────────────────────────────── + + console.log("\n=== Worktree initial branch ==="); + assertEq(getCurrentBranch(wt.path), "worktree/alpha", "worktree starts on its own branch"); + + // ── ensureSliceBranch inside worktree ────────────────────────────────────── + + console.log("\n=== ensureSliceBranch in worktree ==="); + const created = ensureSliceBranch(wt.path, "M001", "S01"); + assert(created, "slice branch created"); + assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S01", "worktree-namespaced slice branch"); + assert(isOnSliceBranch(wt.path), "isOnSliceBranch returns true"); + assertEq(getActiveSliceBranch(wt.path), "gsd/alpha/M001/S01", "getActiveSliceBranch returns namespaced branch"); + + // ── Verify branch name helper ────────────────────────────────────────────── + + console.log("\n=== getSliceBranchName with worktree ==="); + assertEq(getSliceBranchName("M001", "S01", "alpha"), "gsd/alpha/M001/S01", "explicit worktree param"); + assertEq(getSliceBranchName("M001", "S01"), "gsd/M001/S01", "no worktree param = plain branch"); + + // ── Do work on slice branch, then merge to worktree branch ───────────────── + + console.log("\n=== Work and merge slice in worktree ==="); + writeFileSync(join(wt.path, "feature.txt"), "new feature\n", "utf-8"); + run("git add .", wt.path); + run("git commit -m 'feat: add feature'", wt.path); + + // switchToMain should go to worktree/alpha, NOT main + switchToMain(wt.path); + assertEq(getCurrentBranch(wt.path), "worktree/alpha", "switchToMain goes to worktree branch, not main"); + + // mergeSliceToMain should merge into worktree/alpha + const merge = mergeSliceToMain(wt.path, "M001", "S01", "First"); + assertEq(merge.branch, "gsd/alpha/M001/S01", "merged the namespaced branch"); + assert(merge.deletedBranch, "slice branch deleted after merge"); + assertEq(getCurrentBranch(wt.path), "worktree/alpha", "still on worktree branch after merge"); + assert(readFileSync(join(wt.path, "feature.txt"), "utf-8").includes("new feature"), "merge brought feature to worktree branch"); + + // Verify slice branch is gone + const branches = run("git branch", base); + assert(!branches.includes("gsd/alpha/M001/S01"), "slice branch cleaned up"); + + // ── Second slice in same worktree ────────────────────────────────────────── + + console.log("\n=== Second slice in worktree ==="); + const created2 = ensureSliceBranch(wt.path, "M001", "S02"); + assert(created2, "S02 branch created"); + assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S02", "on S02 namespaced branch"); + + writeFileSync(join(wt.path, "feature2.txt"), "second feature\n", "utf-8"); + run("git add .", wt.path); + run("git commit -m 'feat: add feature 2'", wt.path); + + switchToMain(wt.path); + const merge2 = mergeSliceToMain(wt.path, "M001", "S02", "Second"); + assertEq(merge2.branch, "gsd/alpha/M001/S02", "S02 merge correct"); + assertEq(getCurrentBranch(wt.path), "worktree/alpha", "back on worktree branch"); + + // ── Main tree can still do its own slice work independently ──────────────── + + console.log("\n=== Main tree independent slice work ==="); + assertEq(getCurrentBranch(base), "main", "main tree still on main"); + const mainCreated = ensureSliceBranch(base, "M001", "S01"); + assert(mainCreated, "main tree can create S01 branch (no conflict with worktree)"); + assertEq(getCurrentBranch(base), "gsd/M001/S01", "main tree on plain branch name"); + + writeFileSync(join(base, "main-feature.txt"), "main work\n", "utf-8"); + run("git add .", base); + run("git commit -m 'feat: main work'", base); + + switchToMain(base); + assertEq(getCurrentBranch(base), "main", "main tree switchToMain goes to main"); + const mainMerge = mergeSliceToMain(base, "M001", "S01", "First"); + assertEq(mainMerge.branch, "gsd/M001/S01", "main tree merge uses plain branch"); + + // ── Parallel worktrees don't conflict ────────────────────────────────────── + + console.log("\n=== Parallel worktrees ==="); + const wt2 = createWorktree(base, "beta"); + assertEq(getMainBranch(wt2.path), "worktree/beta", "second worktree has its own base branch"); + + // Both worktrees can create S01 branches without conflict + const betaCreated = ensureSliceBranch(wt2.path, "M001", "S01"); + assert(betaCreated, "beta worktree can create S01"); + assertEq(getCurrentBranch(wt2.path), "gsd/beta/M001/S01", "beta has its own namespaced branch"); + + // Alpha worktree can re-create S01 too (it was already merged+deleted earlier) + const alphaReCreated = ensureSliceBranch(wt.path, "M001", "S01"); + assert(alphaReCreated, "alpha worktree can re-create S01"); + assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S01", "alpha re-created S01"); + + // Both exist simultaneously + const allBranches = run("git branch", base); + assert(allBranches.includes("gsd/alpha/M001/S01"), "alpha S01 branch exists"); + assert(allBranches.includes("gsd/beta/M001/S01"), "beta S01 branch exists"); + + // ── State derivation in worktree ─────────────────────────────────────────── + + console.log("\n=== State derivation in worktree ==="); + // Switch alpha back to its base so deriveState sees milestone files + switchToMain(wt.path); + const state = await deriveState(wt.path); + assert(state.activeMilestone !== null, "worktree has active milestone"); + assertEq(state.activeMilestone?.id, "M001", "correct milestone"); + + // ── autoCommitCurrentBranch in worktree ──────────────────────────────────── + + console.log("\n=== autoCommitCurrentBranch in worktree ==="); + ensureSliceBranch(wt2.path, "M001", "S01"); // re-checkout if needed + writeFileSync(join(wt2.path, "dirty.txt"), "uncommitted\n", "utf-8"); + const commitMsg = autoCommitCurrentBranch(wt2.path, "execute-task", "M001/S01/T01"); + assert(commitMsg !== null, "auto-commit works in worktree"); + assertEq(run("git status --short", wt2.path), "", "worktree clean after auto-commit"); + + // ── Cleanup ──────────────────────────────────────────────────────────────── + + console.log("\n=== Cleanup ==="); + // Switch worktrees back to their base branches before removal + switchToMain(wt.path); + switchToMain(wt2.path); + removeWorktree(base, "alpha", { deleteBranch: true }); + removeWorktree(base, "beta", { deleteBranch: true }); + assertEq(listWorktrees(base).length, 0, "all worktrees removed"); + + rmSync(base, { recursive: true, force: true }); + + console.log(`\nResults: ${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); + console.log("All tests passed ✓"); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/resources/extensions/gsd/tests/worktree.test.ts b/src/resources/extensions/gsd/tests/worktree.test.ts index 5e9c0c1cf..5411e9a54 100644 --- a/src/resources/extensions/gsd/tests/worktree.test.ts +++ b/src/resources/extensions/gsd/tests/worktree.test.ts @@ -1,15 +1,19 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { execSync } from "node:child_process"; import { autoCommitCurrentBranch, + detectWorktreeName, ensureSliceBranch, getActiveSliceBranch, getCurrentBranch, getSliceBranchName, + isOnSliceBranch, mergeSliceToMain, + parseSliceBranch, + SLICE_BRANCH_RE, switchToMain, } from "../worktree.ts"; import { deriveState } from "../state.ts"; @@ -136,6 +140,117 @@ async function main(): Promise { console.log("\n=== getSliceBranchName ==="); assertEq(getSliceBranchName("M001", "S01"), "gsd/M001/S01", "branch name format correct"); + assertEq(getSliceBranchName("M001", "S01", null), "gsd/M001/S01", "null worktree = plain branch"); + assertEq(getSliceBranchName("M001", "S01", "my-wt"), "gsd/my-wt/M001/S01", "worktree-namespaced branch"); + + console.log("\n=== parseSliceBranch ==="); + const plain = parseSliceBranch("gsd/M001/S01"); + assert(plain !== null, "parses plain branch"); + assertEq(plain!.worktreeName, null, "plain branch has no worktree name"); + assertEq(plain!.milestoneId, "M001", "plain branch milestone"); + assertEq(plain!.sliceId, "S01", "plain branch slice"); + + const namespaced = parseSliceBranch("gsd/feature-auth/M001/S01"); + assert(namespaced !== null, "parses worktree-namespaced branch"); + assertEq(namespaced!.worktreeName, "feature-auth", "worktree name extracted"); + assertEq(namespaced!.milestoneId, "M001", "namespaced branch milestone"); + assertEq(namespaced!.sliceId, "S01", "namespaced branch slice"); + + const invalid = parseSliceBranch("main"); + assertEq(invalid, null, "non-slice branch returns null"); + + const worktreeBranch = parseSliceBranch("worktree/foo"); + assertEq(worktreeBranch, null, "worktree/ prefix is not a slice branch"); + + console.log("\n=== SLICE_BRANCH_RE ==="); + assert(SLICE_BRANCH_RE.test("gsd/M001/S01"), "regex matches plain branch"); + assert(SLICE_BRANCH_RE.test("gsd/my-wt/M001/S01"), "regex matches worktree branch"); + assert(!SLICE_BRANCH_RE.test("main"), "regex rejects main"); + assert(!SLICE_BRANCH_RE.test("gsd/"), "regex rejects bare gsd/"); + assert(!SLICE_BRANCH_RE.test("worktree/foo"), "regex rejects worktree/foo"); + + console.log("\n=== detectWorktreeName ==="); + assertEq(detectWorktreeName("/projects/myapp"), null, "no worktree in plain path"); + assertEq(detectWorktreeName("/projects/myapp/.gsd/worktrees/feature-auth"), "feature-auth", "detects worktree name"); + assertEq(detectWorktreeName("/projects/myapp/.gsd/worktrees/my-wt/subdir"), "my-wt", "detects worktree with subdir"); + + // ── Regression: slice branch from non-main working branch ─────────── + // Reproduces the bug where planning artifacts committed to a working + // branch (e.g. "developer") are lost when the slice branch is created + // from "main" which doesn't have them. + console.log("\n=== ensureSliceBranch from non-main working branch ==="); + const base2 = mkdtempSync(join(tmpdir(), "gsd-branch-base-test-")); + run("git init -b main", base2); + run("git config user.name 'Pi Test'", base2); + run("git config user.email 'pi@example.com'", base2); + writeFileSync(join(base2, "README.md"), "hello\n", "utf-8"); + run("git add .", base2); + run("git commit -m 'chore: init'", base2); + + // Create a "developer" branch with planning artifacts (like the real scenario) + run("git checkout -b developer", base2); + mkdirSync(join(base2, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true }); + writeFileSync(join(base2, ".gsd", "milestones", "M001", "M001-CONTEXT.md"), "# M001 Context\nGoal: fix eslint\n", "utf-8"); + writeFileSync(join(base2, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), [ + "# M001: ESLint Cleanup", "", "## Slices", + "- [ ] **S01: Config Fix** `risk:low` `depends:[]`", " > Fix config", + ].join("\n") + "\n", "utf-8"); + run("git add .", base2); + run("git commit -m 'docs(M001): context and roadmap'", base2); + + // Verify main does NOT have the artifacts + const mainRoadmap = run("git show main:.gsd/milestones/M001/M001-ROADMAP.md 2>&1 || echo MISSING", base2); + assert(mainRoadmap.includes("MISSING") || mainRoadmap.includes("does not exist"), "main branch lacks roadmap"); + + // Now create slice branch from developer — should inherit artifacts + assertEq(getCurrentBranch(base2), "developer", "on developer branch before ensure"); + const created3 = ensureSliceBranch(base2, "M001", "S01"); + assert(created3, "slice branch created from developer"); + assertEq(getCurrentBranch(base2), "gsd/M001/S01", "switched to slice branch"); + + // The critical assertion: planning artifacts must exist on the slice branch + assert(existsSync(join(base2, ".gsd", "milestones", "M001", "M001-ROADMAP.md")), "roadmap exists on slice branch"); + assert(existsSync(join(base2, ".gsd", "milestones", "M001", "M001-CONTEXT.md")), "context exists on slice branch"); + + // Verify deriveState sees the correct phase (not pre-planning) + const state2 = await deriveState(base2); + assertEq(state2.phase, "planning", "deriveState sees planning phase on slice branch"); + assert(state2.activeSlice !== null, "active slice found"); + assertEq(state2.activeSlice!.id, "S01", "active slice is S01"); + + rmSync(base2, { recursive: true, force: true }); + + // ── Slice branch from another slice branch falls back to main ─────── + console.log("\n=== ensureSliceBranch from slice branch falls back to main ==="); + const base3 = mkdtempSync(join(tmpdir(), "gsd-branch-chain-test-")); + run("git init -b main", base3); + run("git config user.name 'Pi Test'", base3); + run("git config user.email 'pi@example.com'", base3); + mkdirSync(join(base3, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true }); + mkdirSync(join(base3, ".gsd", "milestones", "M001", "slices", "S02", "tasks"), { recursive: true }); + writeFileSync(join(base3, "README.md"), "hello\n", "utf-8"); + writeFileSync(join(base3, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), [ + "# M001: Demo", "", "## Slices", + "- [ ] **S01: First** `risk:low` `depends:[]`", " > first", + "- [ ] **S02: Second** `risk:low` `depends:[]`", " > second", + ].join("\n") + "\n", "utf-8"); + run("git add .", base3); + run("git commit -m 'chore: init'", base3); + + ensureSliceBranch(base3, "M001", "S01"); + assertEq(getCurrentBranch(base3), "gsd/M001/S01", "on S01 slice branch"); + + // Creating S02 while on S01 should NOT chain from S01 — should use main + const created4 = ensureSliceBranch(base3, "M001", "S02"); + assert(created4, "S02 branch created"); + assertEq(getCurrentBranch(base3), "gsd/M001/S02", "switched to S02"); + + // S02 should be based on main, not on gsd/M001/S01 + const s02Base = run("git merge-base main gsd/M001/S02", base3); + const mainHead = run("git rev-parse main", base3); + assertEq(s02Base, mainHead, "S02 is based on main, not on S01 slice branch"); + + rmSync(base3, { recursive: true, force: true }); rmSync(base, { recursive: true, force: true }); console.log(`\nResults: ${passed} passed, ${failed} failed`); diff --git a/src/resources/extensions/gsd/workspace-index.ts b/src/resources/extensions/gsd/workspace-index.ts index 767e3dab7..ada57dfe9 100644 --- a/src/resources/extensions/gsd/workspace-index.ts +++ b/src/resources/extensions/gsd/workspace-index.ts @@ -12,7 +12,7 @@ import { } from "./paths.ts"; import { deriveState } from "./state.ts"; import { type ValidationIssue, validateCompleteBoundary, validatePlanBoundary } from "./observability-validator.ts"; -import { getSliceBranchName } from "./worktree.ts"; +import { getSliceBranchName, detectWorktreeName } from "./worktree.ts"; export interface WorkspaceTaskTarget { id: string; @@ -112,7 +112,7 @@ async function indexSlice(basePath: string, milestoneId: string, sliceId: string summaryPath, uatPath, tasksDir, - branch: getSliceBranchName(milestoneId, sliceId), + branch: getSliceBranchName(milestoneId, sliceId, detectWorktreeName(basePath)), tasks, }; } diff --git a/src/resources/extensions/gsd/worktree-command.ts b/src/resources/extensions/gsd/worktree-command.ts index 489976cd2..569da3108 100644 --- a/src/resources/extensions/gsd/worktree-command.ts +++ b/src/resources/extensions/gsd/worktree-command.ts @@ -14,6 +14,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-cod import { loadPrompt } from "./prompt-loader.js"; import { autoCommitCurrentBranch } from "./worktree.js"; import { showConfirm } from "../shared/confirm-ui.js"; +import { gsdRoot, milestonesDir } from "./paths.js"; import { createWorktree, listWorktrees, @@ -28,7 +29,7 @@ import { worktreePath, } from "./worktree-manager.js"; import type { FileLineStat } from "./worktree-manager.js"; -import { existsSync, realpathSync, readFileSync, utimesSync } from "node:fs"; +import { existsSync, realpathSync, readFileSync, readdirSync, rmSync, unlinkSync, utimesSync } from "node:fs"; import { join, resolve, sep } from "node:path"; /** @@ -304,6 +305,44 @@ export function registerWorktreeCommand(pi: ExtensionAPI): void { // ─── Handlers ────────────────────────────────────────────────────────────── +/** + * Check if the worktree has existing GSD milestones that would + * cause auto-mode to continue previous work instead of starting fresh. + */ +function hasExistingMilestones(wtPath: string): boolean { + const mDir = milestonesDir(wtPath); + if (!existsSync(mDir)) return false; + try { + const entries = readdirSync(mDir, { withFileTypes: true }) + .filter(d => d.isDirectory() && /^M\d+/.test(d.name)); + return entries.length > 0; + } catch { + return false; + } +} + +/** + * Clear GSD planning artifacts so auto-mode starts fresh with the discuss flow. + * Keeps the .gsd/ directory structure intact but removes milestones and root planning files. + */ +function clearGSDPlans(wtPath: string): void { + const mDir = milestonesDir(wtPath); + if (existsSync(mDir)) { + rmSync(mDir, { recursive: true, force: true }); + } + + // Remove root planning files — PROJECT.md, DECISIONS.md, QUEUE.md, REQUIREMENTS.md + // Keep STATE.md (gitignored, will be rebuilt) and other runtime files + const root = gsdRoot(wtPath); + const planningFiles = ["PROJECT.md", "DECISIONS.md", "QUEUE.md", "REQUIREMENTS.md"]; + for (const file of planningFiles) { + const filePath = join(root, file); + if (existsSync(filePath)) { + unlinkSync(filePath); + } + } +} + async function handleCreate( basePath: string, name: string, @@ -324,16 +363,45 @@ async function handleCreate( process.chdir(info.path); nudgeGitBranchCache(prevCwd); - const commitNote = commitMsg ? `\n Auto-committed on previous branch before switching.` : ""; + // If the worktree inherited existing milestones, ask whether to keep or clear them + let clearedPlans = false; + if (hasExistingMilestones(info.path)) { + // confirmLabel = Continue (safe default, on the left / first) + // declineLabel = Start fresh (destructive, on the right) + const keepExisting = await showConfirm(ctx, { + title: "Worktree Setup", + message: [ + `This worktree inherited existing GSD milestones from the main branch.`, + ``, + ` Continue — keep milestones and pick up where main left off`, + ` Start fresh — clear milestones so /gsd auto starts a new project`, + ].join("\n"), + confirmLabel: "Continue", + declineLabel: "Start fresh", + }); + if (!keepExisting) { + clearGSDPlans(info.path); + clearedPlans = true; + } + } + + const commitNote = commitMsg + ? ` ${CLR.muted("Auto-committed on previous branch before switching.")}` + : ""; + const freshNote = clearedPlans + ? ` ${CLR.ok("✓")} Cleared milestones — ${CLR.hint("/gsd auto")} will start fresh.` + : ""; ctx.ui.notify( [ - `Worktree "${name}" created and activated.`, - ` Path: ${info.path}`, - ` Branch: ${info.branch}`, + `${CLR.ok("✓")} Worktree ${CLR.name(name)} created and activated.`, + "", + ` ${CLR.label("path")} ${CLR.path(info.path)}`, + ` ${CLR.label("branch")} ${CLR.branch(info.branch)}`, commitNote, - `Session is now in the worktree. All commands run here.`, - `Use /worktree merge ${name} to merge back when done.`, - `Use /worktree return to switch back to the main tree.`, + freshNote, + "", + ` ${CLR.hint(`/worktree merge ${name}`)} ${CLR.muted("merge back when done")}`, + ` ${CLR.hint("/worktree return")}${" ".repeat(Math.max(1, name.length - 2))} ${CLR.muted("switch back to main tree")}`, ].filter(Boolean).join("\n"), "info", ); @@ -370,14 +438,18 @@ async function handleSwitch( process.chdir(wtPath); nudgeGitBranchCache(prevCwd); - const commitNote = commitMsg ? `\n Auto-committed on previous branch before switching.` : ""; + const commitNote = commitMsg + ? ` ${CLR.muted("Auto-committed on previous branch before switching.")}` + : ""; ctx.ui.notify( [ - `Switched to worktree "${name}".`, - ` Path: ${wtPath}`, - ` Branch: ${worktreeBranchName(name)}`, + `${CLR.ok("✓")} Switched to worktree ${CLR.name(name)}.`, + "", + ` ${CLR.label("path")} ${CLR.path(wtPath)}`, + ` ${CLR.label("branch")} ${CLR.branch(worktreeBranchName(name))}`, commitNote, - `Use /worktree return to switch back to the main tree.`, + "", + ` ${CLR.hint("/worktree return")} ${CLR.muted("switch back to main tree")}`, ].filter(Boolean).join("\n"), "info", ); @@ -403,26 +475,56 @@ async function handleReturn(ctx: ExtensionCommandContext): Promise { process.chdir(returnTo); nudgeGitBranchCache(prevCwd); - const commitNote = commitMsg ? `\n Auto-committed on worktree branch before returning.` : ""; + const commitNote = commitMsg + ? ` ${CLR.muted("Auto-committed on worktree branch before returning.")}` + : ""; ctx.ui.notify( [ - `Returned to main project tree.`, - ` Path: ${returnTo}`, + `${CLR.ok("✓")} Returned to main project tree.`, + "", + ` ${CLR.label("path")} ${CLR.path(returnTo)}`, commitNote, ].filter(Boolean).join("\n"), "info", ); } -// ANSI helpers for list formatting -const BOLD = "\x1b[1m"; -const DIM = "\x1b[2m"; -const RESET = "\x1b[0m"; -const CYAN = "\x1b[36m"; -const GREEN = "\x1b[32m"; -const RED = "\x1b[31m"; +// ─── ANSI styling ───────────────────────────────────────────────────────── +// Consistent palette for all worktree command output. + +const BOLD = "\x1b[1m"; +const DIM = "\x1b[2m"; +const RESET = "\x1b[0m"; +const CYAN = "\x1b[36m"; +const GREEN = "\x1b[32m"; +const RED = "\x1b[31m"; const YELLOW = "\x1b[33m"; -const WHITE = "\x1b[37m"; +const WHITE = "\x1b[37m"; +const MAGENTA = "\x1b[35m"; + +// Semantic aliases for consistent use across all handlers +const CLR = { + /** Worktree names and primary emphasis */ + name: (s: string) => `${BOLD}${CYAN}${s}${RESET}`, + /** Active worktree name */ + nameActive: (s: string) => `${BOLD}${GREEN}${s}${RESET}`, + /** Branch names */ + branch: (s: string) => `${MAGENTA}${s}${RESET}`, + /** File paths */ + path: (s: string) => `${DIM}${s}${RESET}`, + /** Labels (key in key:value pairs) */ + label: (s: string) => `${WHITE}${s}${RESET}`, + /** Hints and commands the user can run */ + hint: (s: string) => `${DIM}${CYAN}${s}${RESET}`, + /** Success messages and checks */ + ok: (s: string) => `${GREEN}${s}${RESET}`, + /** Warning badges */ + warn: (s: string) => `${YELLOW}${s}${RESET}`, + /** Section headers */ + header: (s: string) => `${BOLD}${WHITE}${s}${RESET}`, + /** Muted secondary info */ + muted: (s: string) => `${DIM}${s}${RESET}`, +} as const; async function handleList( basePath: string, @@ -438,22 +540,26 @@ async function handleList( } const cwd = process.cwd(); - const lines = [`${BOLD}${WHITE}GSD Worktrees${RESET}`, ""]; + const lines = [CLR.header("GSD Worktrees"), ""]; for (const wt of worktrees) { const isCurrent = cwd === wt.path || (existsSync(cwd) && existsSync(wt.path) && realpathSync(cwd) === realpathSync(wt.path)); - const nameColor = isCurrent ? GREEN : CYAN; - const badge = isCurrent ? ` ${GREEN}● active${RESET}` : !wt.exists ? ` ${YELLOW}✗ missing${RESET}` : ""; - lines.push(` ${BOLD}${nameColor}${wt.name}${RESET}${badge}`); - lines.push(` ${DIM} branch${RESET} ${wt.branch}`); - lines.push(` ${DIM} path${RESET} ${DIM}${wt.path}${RESET}`); + const styledName = isCurrent ? CLR.nameActive(wt.name) : CLR.name(wt.name); + const badge = isCurrent + ? ` ${CLR.ok("● active")}` + : !wt.exists + ? ` ${CLR.warn("✗ missing")}` + : ""; + lines.push(` ${styledName}${badge}`); + lines.push(` ${CLR.label("branch")} ${CLR.branch(wt.branch)}`); + lines.push(` ${CLR.label("path")} ${CLR.path(wt.path)}`); lines.push(""); } if (originalCwd) { - lines.push(`${DIM}Main tree: ${originalCwd}${RESET}`); + lines.push(` ${CLR.label("main tree")} ${CLR.path(originalCwd)}`); } ctx.ui.notify(lines.join("\n"), "info"); @@ -491,7 +597,7 @@ async function handleMerge( const totalChanges = diffSummary.added.length + diffSummary.modified.length + diffSummary.removed.length; if (totalChanges === 0 && !commitLog.trim()) { - ctx.ui.notify(`Worktree "${name}" has no changes to merge.`, "info"); + ctx.ui.notify(`Worktree ${CLR.name(name)} has no changes to merge.`, "info"); return; } @@ -516,15 +622,15 @@ async function handleMerge( // Format a file line with +/- stats const formatFileLine = (prefix: string, file: string): string => { const s = statMap.get(file); - const stat = s ? ` ${GREEN}+${s.added}${RESET} ${RED}-${s.removed}${RESET}` : ""; + const stat = s ? ` ${CLR.ok(`+${s.added}`)} ${RED}-${s.removed}${RESET}` : ""; return ` ${prefix} ${file}${stat}`; }; // Preview confirmation before merge dispatch const previewLines = [ - `Merge worktree "${name}" → ${mainBranch}`, + `Merge ${CLR.name(name)} → ${CLR.branch(mainBranch)}`, "", - ` ${totalChanges} file${totalChanges === 1 ? "" : "s"} changed, ${GREEN}+${totalAdded}${RESET} ${RED}-${totalRemoved}${RESET} lines (${codeChanges} code, ${gsdChanges} GSD)`, + ` ${totalChanges} file${totalChanges === 1 ? "" : "s"} changed, ${CLR.ok(`+${totalAdded}`)} ${RED}-${totalRemoved}${RESET} lines ${CLR.muted(`(${codeChanges} code, ${gsdChanges} GSD)`)}`, ]; const appendFileList = (label: string, files: string[], prefix: string, limit = 10) => { @@ -590,7 +696,7 @@ async function handleMerge( ); ctx.ui.notify( - `Merge helper started for worktree "${name}" (${codeChanges} code + ${gsdChanges} GSD artifact change${totalChanges === 1 ? "" : "s"}).`, + `${CLR.ok("✓")} Merge helper started for ${CLR.name(name)} ${CLR.muted(`(${codeChanges} code + ${gsdChanges} GSD artifact change${totalChanges === 1 ? "" : "s"})`)}`, "info", ); } catch (error) { @@ -617,7 +723,7 @@ async function handleRemove( const confirmed = await showConfirm(ctx, { title: "Remove Worktree", - message: `Remove worktree "${name}" and delete branch ${wt.branch}?`, + message: `Remove worktree ${CLR.name(name)} and delete branch ${CLR.branch(wt.branch)}?`, confirmLabel: "Remove", declineLabel: "Cancel", }); @@ -635,7 +741,7 @@ async function handleRemove( originalCwd = null; } - ctx.ui.notify(`Worktree "${name}" removed (branch deleted).`, "info"); + ctx.ui.notify(`${CLR.ok("✓")} Worktree ${CLR.name(name)} removed ${CLR.muted("(branch deleted)")}.`, "info"); } catch (error) { const msg = error instanceof Error ? error.message : String(error); ctx.ui.notify(`Failed to remove worktree: ${msg}`, "error"); @@ -658,7 +764,7 @@ async function handleRemoveAll( const names = worktrees.map(w => w.name); const confirmed = await showConfirm(ctx, { title: "Remove All Worktrees", - message: `This will remove ${worktrees.length} worktree${worktrees.length === 1 ? "" : "s"} and delete their branches:\n\n${names.map(n => ` • ${n}`).join("\n")}`, + message: `Remove ${worktrees.length} worktree${worktrees.length === 1 ? "" : "s"} and delete their branches?\n\n${names.map(n => ` • ${CLR.name(n)}`).join("\n")}`, confirmLabel: "Remove all", declineLabel: "Cancel", }); @@ -687,8 +793,8 @@ async function handleRemoveAll( } const lines: string[] = []; - if (removed.length > 0) lines.push(`Removed: ${removed.join(", ")}`); - if (failed.length > 0) lines.push(`Failed: ${failed.join(", ")}`); + if (removed.length > 0) lines.push(`${CLR.ok("✓")} Removed: ${removed.map(n => CLR.name(n)).join(", ")}`); + if (failed.length > 0) lines.push(`${CLR.warn("✗")} Failed: ${failed.map(n => CLR.name(n)).join(", ")}`); ctx.ui.notify(lines.join("\n"), failed.length > 0 ? "warning" : "info"); } catch (error) { const msg = error instanceof Error ? error.message : String(error); diff --git a/src/resources/extensions/gsd/worktree.ts b/src/resources/extensions/gsd/worktree.ts index 6699a5fd2..67be797a8 100644 --- a/src/resources/extensions/gsd/worktree.ts +++ b/src/resources/extensions/gsd/worktree.ts @@ -13,6 +13,7 @@ import { existsSync } from "node:fs"; import { execSync } from "node:child_process"; +import { sep } from "node:path"; export interface MergeSliceResult { branch: string; @@ -34,11 +35,83 @@ function runGit(basePath: string, args: string[], options: { allowFailure?: bool } } -export function getSliceBranchName(milestoneId: string, sliceId: string): string { +/** + * Detect the active worktree name from the current working directory. + * Returns null if not inside a GSD worktree (.gsd/worktrees//). + */ +export function detectWorktreeName(basePath: string): string | null { + const marker = `${sep}.gsd${sep}worktrees${sep}`; + const idx = basePath.indexOf(marker); + if (idx === -1) return null; + const afterMarker = basePath.slice(idx + marker.length); + const name = afterMarker.split(sep)[0] ?? afterMarker.split("/")[0]; + return name || null; +} + +/** + * Get the slice branch name, namespaced by worktree when inside one. + * + * In the main tree: gsd// + * In a worktree: gsd/// + * + * This prevents branch conflicts when multiple worktrees work on the + * same milestone/slice IDs — git doesn't allow a branch to be checked + * out in more than one worktree simultaneously. + */ +export function getSliceBranchName(milestoneId: string, sliceId: string, worktreeName?: string | null): string { + if (worktreeName) { + return `gsd/${worktreeName}/${milestoneId}/${sliceId}`; + } return `gsd/${milestoneId}/${sliceId}`; } +/** Regex that matches both plain and worktree-namespaced slice branches. */ +export const SLICE_BRANCH_RE = /^gsd\/(?:([a-zA-Z0-9_-]+)\/)?(M\d+)\/(S\d+)$/; + +/** + * Parse a slice branch name into its components. + * Handles both `gsd/M001/S01` and `gsd/myworktree/M001/S01`. + */ +export function parseSliceBranch(branchName: string): { + worktreeName: string | null; + milestoneId: string; + sliceId: string; +} | null { + const match = branchName.match(SLICE_BRANCH_RE); + if (!match) return null; + return { + worktreeName: match[1] ?? null, + milestoneId: match[2]!, + sliceId: match[3]!, + }; +} + +/** + * Get the "main" branch for GSD slice operations. + * + * In the main working tree: returns main/master (the repo's default branch). + * In a worktree: returns worktree/ — the worktree's own base branch. + * + * This is critical because git doesn't allow a branch to be checked out + * in more than one worktree. Slice branches merge into the worktree's base + * branch, and the worktree branch later merges into the real main via + * /worktree merge. + */ export function getMainBranch(basePath: string): string { + // When inside a worktree, slice branches should merge into the worktree's + // own branch (worktree/), not main — main is checked out by the + // parent working tree and git would refuse the checkout. + const wtName = detectWorktreeName(basePath); + if (wtName) { + const wtBranch = `worktree/${wtName}`; + // Verify the branch exists (it should — createWorktree made it) + const exists = runGit(basePath, ["show-ref", "--verify", `refs/heads/${wtBranch}`], { allowFailure: true }); + if (exists) return wtBranch; + // Worktree branch is gone — return current branch rather than falling + // through to main/master which would cause a checkout conflict + return runGit(basePath, ["branch", "--show-current"]); + } + const symbolic = runGit(basePath, ["symbolic-ref", "refs/remotes/origin/HEAD"], { allowFailure: true }); if (symbolic) { const match = symbolic.match(/refs\/remotes\/origin\/(.+)$/); @@ -69,11 +142,16 @@ function branchExists(basePath: string, branch: string): boolean { /** * Ensure the slice branch exists and is checked out. - * Creates the branch from main if it doesn't exist. + * Creates the branch from the current branch if it's not a slice branch, + * otherwise from main. This preserves planning artifacts (CONTEXT, ROADMAP, + * etc.) that were committed on the working branch — which may differ from + * the repo's default branch (e.g. `developer` vs `main`). + * When inside a worktree, the branch is namespaced to avoid conflicts. * Returns true if the branch was newly created. */ export function ensureSliceBranch(basePath: string, milestoneId: string, sliceId: string): boolean { - const branch = getSliceBranchName(milestoneId, sliceId); + const wtName = detectWorktreeName(basePath); + const branch = getSliceBranchName(milestoneId, sliceId, wtName); const current = getCurrentBranch(basePath); if (current === branch) return false; @@ -82,7 +160,14 @@ export function ensureSliceBranch(basePath: string, milestoneId: string, sliceId let created = false; if (!branchExists(basePath, branch)) { - runGit(basePath, ["branch", branch, mainBranch]); + // Branch from the current branch when it's a normal working branch + // (not itself a slice branch). This ensures the new slice branch + // inherits planning artifacts that may only exist on the working + // branch and haven't been merged to main yet. + // If we're already on a slice branch (e.g. creating S02 while S01 + // wasn't merged yet), fall back to main to avoid chaining slice branches. + const base = SLICE_BRANCH_RE.test(current) ? mainBranch : current; + runGit(basePath, ["branch", branch, base]); created = true; } else { // Check if the branch is already checked out in another worktree @@ -152,7 +237,8 @@ export function switchToMain(basePath: string): void { export function mergeSliceToMain( basePath: string, milestoneId: string, sliceId: string, sliceTitle: string, ): MergeSliceResult { - const branch = getSliceBranchName(milestoneId, sliceId); + const wtName = detectWorktreeName(basePath); + const branch = getSliceBranchName(milestoneId, sliceId, wtName); const mainBranch = getMainBranch(basePath); const current = getCurrentBranch(basePath); @@ -183,19 +269,21 @@ export function mergeSliceToMain( /** * Check if we're currently on a slice branch (not main). + * Handles both plain (gsd/M001/S01) and worktree-namespaced (gsd/wt/M001/S01) branches. */ export function isOnSliceBranch(basePath: string): boolean { const current = getCurrentBranch(basePath); - return current.startsWith("gsd/"); + return SLICE_BRANCH_RE.test(current); } /** * Get the active slice branch name, or null if on main. + * Handles both plain and worktree-namespaced branch patterns. */ export function getActiveSliceBranch(basePath: string): string | null { try { const current = getCurrentBranch(basePath); - return current.startsWith("gsd/") ? current : null; + return SLICE_BRANCH_RE.test(current) ? current : null; } catch { return null; }