From 6b2f8b0a0544cc00b0029504ba8cbabba84bfd86 Mon Sep 17 00:00:00 2001 From: jonathancostin <66714927+jonathancostin@users.noreply.github.com> Date: Wed, 11 Mar 2026 07:59:02 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20/worktree=20(/wt)=20=E2=80=94=20git=20w?= =?UTF-8?q?orktree=20lifecycle=20for=20GSD=20(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../extensions/gsd/dashboard-overlay.ts | 7 +- src/resources/extensions/gsd/gitignore.ts | 1 + src/resources/extensions/gsd/index.ts | 37 +- .../extensions/gsd/prompts/worktree-merge.md | 89 +++ .../gsd/tests/worktree-manager.test.ts | 160 ++++++ .../extensions/gsd/worktree-command.ts | 527 ++++++++++++++++++ .../extensions/gsd/worktree-manager.ts | 302 ++++++++++ 7 files changed, 1121 insertions(+), 2 deletions(-) create mode 100644 src/resources/extensions/gsd/prompts/worktree-merge.md create mode 100644 src/resources/extensions/gsd/tests/worktree-manager.test.ts create mode 100644 src/resources/extensions/gsd/worktree-command.ts create mode 100644 src/resources/extensions/gsd/worktree-manager.ts diff --git a/src/resources/extensions/gsd/dashboard-overlay.ts b/src/resources/extensions/gsd/dashboard-overlay.ts index 6f220d5c5..ad30dc0da 100644 --- a/src/resources/extensions/gsd/dashboard-overlay.ts +++ b/src/resources/extensions/gsd/dashboard-overlay.ts @@ -17,6 +17,7 @@ import { aggregateByModel, formatCost, formatTokenCount, formatCostProjection, } from "./metrics.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; +import { getActiveWorktreeName } from "./worktree-command.js"; function formatDuration(ms: number): string { const s = Math.floor(ms / 1000); @@ -273,8 +274,12 @@ export class GSDDashboardOverlay { : this.dashData.paused ? th.fg("warning", "⏸ PAUSED") : th.fg("dim", "idle"); + const worktreeName = getActiveWorktreeName(); + const worktreeTag = worktreeName + ? ` ${th.fg("warning", `⎇ ${worktreeName}`)}` + : ""; const elapsed = th.fg("dim", formatDuration(this.dashData.elapsed)); - lines.push(row(joinColumns(`${title} ${status}`, elapsed, contentWidth))); + lines.push(row(joinColumns(`${title} ${status}${worktreeTag}`, elapsed, contentWidth))); lines.push(blank()); if (this.dashData.currentUnit) { diff --git a/src/resources/extensions/gsd/gitignore.ts b/src/resources/extensions/gsd/gitignore.ts index 3a6fd59b5..bd4847c12 100644 --- a/src/resources/extensions/gsd/gitignore.ts +++ b/src/resources/extensions/gsd/gitignore.ts @@ -17,6 +17,7 @@ const BASELINE_PATTERNS = [ // ── GSD runtime (not source artifacts) ── ".gsd/activity/", ".gsd/runtime/", + ".gsd/worktrees/", ".gsd/auto.lock", ".gsd/metrics.json", ".gsd/STATE.md", diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index c46be19c1..018843df1 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -22,8 +22,10 @@ import type { ExtensionAPI, ExtensionContext, } from "@mariozechner/pi-coding-agent"; +import { createBashTool } from "@mariozechner/pi-coding-agent"; import { registerGSDCommand } from "./commands.js"; +import { registerWorktreeCommand, getWorktreeOriginalCwd, getActiveWorktreeName } from "./worktree-command.js"; import { saveFile, formatContinue, loadFile, parseContinue, parseSummary } from "./files.js"; import { loadPrompt } from "./prompt-loader.js"; import { deriveState } from "./state.js"; @@ -59,6 +61,16 @@ const GSD_LOGO_LINES = [ export default function (pi: ExtensionAPI) { registerGSDCommand(pi); + registerWorktreeCommand(pi); + + // ── Dynamic-cwd bash tool ────────────────────────────────────────────── + // The built-in bash tool captures cwd at startup. This replacement uses + // a spawnHook to read process.cwd() dynamically so that process.chdir() + // (used by /worktree switch) propagates to shell commands. + const dynamicBash = createBashTool(process.cwd(), { + spawnHook: (ctx) => ({ ...ctx, cwd: process.cwd() }), + }); + pi.registerTool(dynamicBash as any); // ── session_start: render branded GSD header ─────────────────────────── pi.on("session_start", async (_event, ctx) => { @@ -131,8 +143,31 @@ export default function (pi: ExtensionAPI) { const injection = await buildGuidedExecuteContextInjection(event.prompt, process.cwd()); + // Worktree context — override the static CWD in the system prompt + let worktreeBlock = ""; + const worktreeName = getActiveWorktreeName(); + const worktreeMainCwd = getWorktreeOriginalCwd(); + if (worktreeName && worktreeMainCwd) { + worktreeBlock = [ + "", + "", + "[WORKTREE CONTEXT — OVERRIDES CURRENT WORKING DIRECTORY ABOVE]", + `IMPORTANT: Ignore the "Current working directory" shown earlier in this prompt.`, + `The actual current working directory is: ${process.cwd()}`, + "", + `You are working inside a GSD worktree.`, + `- Worktree name: ${worktreeName}`, + `- Worktree path (this is the real cwd): ${process.cwd()}`, + `- Main project: ${worktreeMainCwd}`, + `- Branch: worktree/${worktreeName}`, + "", + "All file operations, bash commands, and GSD state resolve against the worktree path above.", + "Use /worktree merge to merge changes back. Use /worktree return to switch back to the main tree.", + ].join("\n"); + } + return { - systemPrompt: `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${newSkillsBlock}`, + systemPrompt: `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${newSkillsBlock}${worktreeBlock}`, ...(injection ? { message: { diff --git a/src/resources/extensions/gsd/prompts/worktree-merge.md b/src/resources/extensions/gsd/prompts/worktree-merge.md new file mode 100644 index 000000000..a89cb8905 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/worktree-merge.md @@ -0,0 +1,89 @@ +You are merging GSD artifacts from worktree **{{worktreeName}}** (branch `{{worktreeBranch}}`) into target branch `{{mainBranch}}`. + +## Context + +The worktree was created as a parallel workspace. It may contain new milestones, updated roadmaps, new plans, research, decisions, or other GSD artifacts that need to be reconciled with the main branch. + +### Commit History (worktree) + +``` +{{commitLog}} +``` + +### GSD Artifact Changes + +**Added files:** +{{addedFiles}} + +**Modified files:** +{{modifiedFiles}} + +**Removed files:** +{{removedFiles}} + +### Full Diff + +```diff +{{fullDiff}} +``` + +## Your Task + +Analyze the changes and guide the merge. Follow these steps exactly: + +### Step 1: Categorize Changes + +Classify each changed GSD artifact: +- **New milestones** — entirely new M###/ directories with roadmaps +- **New slices/tasks** — new planning artifacts within existing milestones +- **Updated roadmaps** — modifications to existing M###-ROADMAP.md files +- **Updated plans** — modifications to existing slice or task plans +- **Research/context** — new or updated RESEARCH.md, CONTEXT.md files +- **Decisions** — changes to DECISIONS.md +- **Requirements** — changes to REQUIREMENTS.md +- **Other** — anything else + +### Step 2: Conflict Assessment + +For each **modified** file, check whether the main branch version has also changed since the worktree branched off. Flag any files where both branches have diverged — these need manual reconciliation. + +Read the current main-branch version of each modified file and compare it against both the worktree version and the common ancestor to identify: +- **Clean merges** — main hasn't changed, worktree changes can apply directly +- **Conflicts** — both branches changed the same file; needs reconciliation +- **Stale changes** — worktree modified a file that main has since replaced or removed + +### Step 3: Merge Strategy + +Present a merge plan to the user: + +1. For **clean merges**: list files that will merge without conflict +2. For **conflicts**: show both versions side-by-side and propose a reconciled version +3. For **new artifacts**: confirm they should be added to the main branch +4. For **removed artifacts**: confirm the removals are intentional + +Ask the user to confirm the merge plan before proceeding. + +### Step 4: Execute Merge + +Once confirmed: + +1. If there are conflicts requiring manual reconciliation, apply the reconciled versions to the main branch working tree +2. Run `git merge --squash {{worktreeBranch}}` to bring in all changes +3. Review the staged changes — if any reconciled files need adjustment, apply them now +4. Commit with message: `merge(worktree/{{worktreeName}}): ` +5. Report what was merged + +### Step 5: Cleanup Prompt + +After a successful merge, ask the user whether to: +- **Remove the worktree** — delete `.gsd/worktrees/{{worktreeName}}/` and the `{{worktreeBranch}}` branch +- **Keep the worktree** — leave it for continued parallel work + +If the user chooses to remove it, run `/worktree remove {{worktreeName}}`. + +## Important + +- Never silently discard changes from either branch +- When in doubt about a conflict, show both versions and ask the user +- Preserve all GSD artifact formatting conventions (frontmatter, section structure, checkbox states) +- If the worktree introduced new milestone IDs that conflict with main, flag this immediately diff --git a/src/resources/extensions/gsd/tests/worktree-manager.test.ts b/src/resources/extensions/gsd/tests/worktree-manager.test.ts new file mode 100644 index 000000000..54973d653 --- /dev/null +++ b/src/resources/extensions/gsd/tests/worktree-manager.test.ts @@ -0,0 +1,160 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; + +import { + createWorktree, + listWorktrees, + removeWorktree, + diffWorktreeGSD, + getWorktreeGSDDiff, + getWorktreeLog, + worktreeBranchName, + worktreePath, +} from "../worktree-manager.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(); +} + +// Set up a test repo +const base = mkdtempSync(join(tmpdir(), "gsd-worktree-mgr-test-")); +run("git init -b main", base); +run("git config user.name 'Pi Test'", base); +run("git config user.email 'pi@example.com'", base); + +// Create initial project structure +mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); +writeFileSync(join(base, "README.md"), "# Test Project\n", "utf-8"); +writeFileSync( + join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), + "# M001: Demo\n\n## Slices\n- [ ] **S01: First** `risk:low` `depends:[]`\n > After this: it works\n", + "utf-8", +); +run("git add .", base); +run("git commit -m 'chore: init'", base); + +async function main(): Promise { + console.log("\n=== worktreeBranchName ==="); + assertEq(worktreeBranchName("feature-x"), "worktree/feature-x", "branch name format"); + + console.log("\n=== createWorktree ==="); + const info = createWorktree(base, "feature-x"); + assert(info.name === "feature-x", "name matches"); + assert(info.branch === "worktree/feature-x", "branch matches"); + assert(info.exists, "worktree exists"); + assert(existsSync(info.path), "worktree path exists on disk"); + assert(existsSync(join(info.path, "README.md")), "README.md copied to worktree"); + assert(existsSync(join(info.path, ".gsd", "milestones", "M001", "M001-ROADMAP.md")), ".gsd files copied"); + + // Branch was created + const branches = run("git branch", base); + assert(branches.includes("worktree/feature-x"), "branch was created"); + + console.log("\n=== createWorktree — duplicate ==="); + let duplicateError = ""; + try { + createWorktree(base, "feature-x"); + } catch (e) { + duplicateError = (e as Error).message; + } + assert(duplicateError.includes("already exists"), "duplicate creation fails"); + + console.log("\n=== createWorktree — invalid name ==="); + let invalidError = ""; + try { + createWorktree(base, "bad name!"); + } catch (e) { + invalidError = (e as Error).message; + } + assert(invalidError.includes("Invalid worktree name"), "invalid name rejected"); + + console.log("\n=== listWorktrees ==="); + const list = listWorktrees(base); + assertEq(list.length, 1, "one worktree listed"); + assertEq(list[0]!.name, "feature-x", "correct name"); + assertEq(list[0]!.branch, "worktree/feature-x", "correct branch"); + assert(list[0]!.exists, "exists flag is true"); + + console.log("\n=== make changes in worktree ==="); + const wtPath = worktreePath(base, "feature-x"); + // Add a new GSD artifact in the worktree + mkdirSync(join(wtPath, ".gsd", "milestones", "M002"), { recursive: true }); + writeFileSync( + join(wtPath, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), + "# M002: New Feature\n\n## Slices\n- [ ] **S01: Setup** `risk:low` `depends:[]`\n > After this: new feature ready\n", + "utf-8", + ); + // Modify an existing artifact + writeFileSync( + join(wtPath, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), + "# M001: Demo (updated)\n\n## Slices\n- [x] **S01: First** `risk:low` `depends:[]`\n > Done\n", + "utf-8", + ); + run("git add .", wtPath); + run("git commit -m 'feat: add M002 and update M001'", wtPath); + + console.log("\n=== diffWorktreeGSD ==="); + const diff = diffWorktreeGSD(base, "feature-x"); + assert(diff.added.length > 0, "has added files"); + assert(diff.added.some(f => f.includes("M002")), "M002 roadmap is in added"); + assert(diff.modified.length > 0, "has modified files"); + assert(diff.modified.some(f => f.includes("M001")), "M001 roadmap is in modified"); + assertEq(diff.removed.length, 0, "no removed files"); + + console.log("\n=== getWorktreeGSDDiff ==="); + const fullDiff = getWorktreeGSDDiff(base, "feature-x"); + assert(fullDiff.includes("M002"), "full diff mentions M002"); + assert(fullDiff.includes("updated"), "full diff mentions update"); + + console.log("\n=== getWorktreeLog ==="); + const log = getWorktreeLog(base, "feature-x"); + assert(log.includes("add M002"), "log shows commit message"); + + console.log("\n=== removeWorktree ==="); + removeWorktree(base, "feature-x", { deleteBranch: true }); + assert(!existsSync(wtPath), "worktree directory removed"); + const branchesAfter = run("git branch", base); + assert(!branchesAfter.includes("worktree/feature-x"), "branch deleted"); + + console.log("\n=== listWorktrees after removal ==="); + const listAfter = listWorktrees(base); + assertEq(listAfter.length, 0, "no worktrees after removal"); + + console.log("\n=== removeWorktree — already gone ==="); + // Should not throw + removeWorktree(base, "feature-x", { deleteBranch: true }); + passed++; + + // Cleanup + 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/worktree-command.ts b/src/resources/extensions/gsd/worktree-command.ts new file mode 100644 index 000000000..bd085df04 --- /dev/null +++ b/src/resources/extensions/gsd/worktree-command.ts @@ -0,0 +1,527 @@ +/** + * GSD Worktree Command — /worktree + * + * Create, list, merge, and remove git worktrees under .gsd/worktrees/. + * + * Usage: + * /worktree — create a new worktree + * /worktree list — list existing worktrees + * /worktree merge [target] — start LLM-guided merge (default target: main) + * /worktree remove — remove a worktree and its branch + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { loadPrompt } from "./prompt-loader.js"; +import { autoCommitCurrentBranch } from "./worktree.js"; +import { showConfirm } from "../shared/confirm-ui.js"; +import { + createWorktree, + listWorktrees, + removeWorktree, + diffWorktreeGSD, + getMainBranch, + getWorktreeGSDDiff, + getWorktreeLog, + worktreeBranchName, + worktreePath, +} from "./worktree-manager.js"; +import { existsSync, realpathSync, readFileSync, utimesSync } from "node:fs"; +import { join, resolve } from "node:path"; + +/** + * Tracks the original project root so we can switch back. + * Set when we first chdir into a worktree, cleared on return. + */ +let originalCwd: string | null = null; + +/** Get the original project root if currently in a worktree, or null. */ +export function getWorktreeOriginalCwd(): string | null { + return originalCwd; +} + +/** + * Resolve the git HEAD file path for a given directory. + * Handles both normal repos (.git is a directory) and worktrees (.git is a file). + */ +function resolveGitHeadPath(dir: string): string | null { + const gitPath = join(dir, ".git"); + if (!existsSync(gitPath)) return null; + + try { + const content = readFileSync(gitPath, "utf8").trim(); + if (content.startsWith("gitdir: ")) { + // Worktree — .git is a file pointing to the real gitdir + const gitDir = resolve(dir, content.slice(8)); + const headPath = join(gitDir, "HEAD"); + return existsSync(headPath) ? headPath : null; + } + // Normal repo — .git is a directory + const headPath = join(dir, ".git", "HEAD"); + return existsSync(headPath) ? headPath : null; + } catch { + return null; + } +} + +/** + * Nudge pi's FooterDataProvider to re-read the git branch. + * + * The footer caches the branch and watches a single .git dir for changes. + * After process.chdir() into a worktree (or back), the watcher is stale — + * it's still watching the old git dir. We touch HEAD in both the old and + * new git dirs to ensure the watcher fires regardless of which one it's + * monitoring. This clears cachedBranch; the next getGitBranch() call uses + * the new process.cwd() and picks up the correct branch. + */ +function nudgeGitBranchCache(previousCwd: string): void { + const now = new Date(); + for (const dir of [previousCwd, process.cwd()]) { + try { + const headPath = resolveGitHeadPath(dir); + if (headPath) utimesSync(headPath, now, now); + } catch { + // Best-effort — branch display may be stale + } + } +} + +/** Get the name of the active worktree, or null if not in one. */ +export function getActiveWorktreeName(): string | null { + if (!originalCwd) return null; + const cwd = process.cwd(); + const wtDir = join(originalCwd, ".gsd", "worktrees"); + if (!cwd.startsWith(wtDir)) return null; + const rel = cwd.slice(wtDir.length + 1); + const name = rel.split("/")[0] ?? rel.split("\\")[0]; + return name || null; +} + +// ─── Shared completions and handler (used by both /worktree and /wt) ──────── + +function worktreeCompletions(prefix: string) { + const parts = prefix.trim().split(/\s+/); + const subcommands = ["list", "merge", "remove", "switch", "return"]; + + if (parts.length <= 1) { + const partial = parts[0] ?? ""; + const cmdCompletions = subcommands + .filter(cmd => cmd.startsWith(partial)) + .map(cmd => ({ value: cmd, label: cmd })); + try { + const mainBase = getWorktreeOriginalCwd() ?? process.cwd(); + const existing = listWorktrees(mainBase); + const nameCompletions = existing + .filter(wt => wt.name.startsWith(partial)) + .map(wt => ({ value: wt.name, label: wt.name })); + return [...cmdCompletions, ...nameCompletions]; + } catch { + return cmdCompletions; + } + } + + if ((parts[0] === "merge" || parts[0] === "remove" || parts[0] === "switch") && parts.length <= 2) { + const namePrefix = parts[1] ?? ""; + try { + const existing = listWorktrees(process.cwd()); + return existing + .filter(wt => wt.name.startsWith(namePrefix)) + .map(wt => ({ value: `${parts[0]} ${wt.name}`, label: wt.name })); + } catch { + return []; + } + } + + return []; +} + +async function worktreeHandler( + args: string, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + alias: string, +): Promise { + const trimmed = (typeof args === "string" ? args : "").trim(); + const basePath = process.cwd(); + + if (trimmed === "") { + ctx.ui.notify( + [ + "Usage:", + ` /${alias} — create and switch into a new worktree`, + ` /${alias} switch — switch into an existing worktree`, + ` /${alias} return — switch back to the main project tree`, + ` /${alias} list — list all worktrees`, + ` /${alias} merge [target] — merge worktree into target branch`, + ` /${alias} remove — remove a worktree and its branch`, + ].join("\n"), + "info", + ); + return; + } + + if (trimmed === "list") { + await handleList(basePath, ctx); + return; + } + + if (trimmed === "return") { + await handleReturn(ctx); + return; + } + + if (trimmed.startsWith("switch ")) { + const name = trimmed.replace(/^switch\s+/, "").trim(); + if (!name) { + ctx.ui.notify(`Usage: /${alias} switch `, "warning"); + return; + } + await handleSwitch(basePath, name, ctx); + return; + } + + if (trimmed.startsWith("merge ")) { + const mergeArgs = trimmed.replace(/^merge\s+/, "").trim().split(/\s+/); + const name = mergeArgs[0] ?? ""; + const targetBranch = mergeArgs[1]; + if (!name) { + ctx.ui.notify(`Usage: /${alias} merge [target]`, "warning"); + return; + } + const mainBase = originalCwd ?? basePath; + await handleMerge(mainBase, name, ctx, pi, targetBranch); + return; + } + + if (trimmed.startsWith("remove ")) { + const name = trimmed.replace(/^remove\s+/, "").trim(); + if (!name) { + ctx.ui.notify(`Usage: /${alias} remove `, "warning"); + return; + } + const mainBase = originalCwd ?? basePath; + await handleRemove(mainBase, name, ctx); + return; + } + + const RESERVED = ["list", "return", "switch", "merge", "remove"]; + if (RESERVED.includes(trimmed)) { + ctx.ui.notify(`Usage: /${alias} ${trimmed}${trimmed === "list" || trimmed === "return" ? "" : " "}`, "warning"); + return; + } + + const mainBase = originalCwd ?? basePath; + const nameOnly = trimmed.split(/\s+/)[0]!; + if (trimmed !== nameOnly) { + ctx.ui.notify(`Unknown command. Did you mean /${alias} switch ${nameOnly}?`, "warning"); + return; + } + + const existing = listWorktrees(mainBase); + if (existing.some(wt => wt.name === nameOnly)) { + await handleSwitch(basePath, nameOnly, ctx); + } else { + await handleCreate(basePath, nameOnly, ctx); + } +} + +export function registerWorktreeCommand(pi: ExtensionAPI): void { + pi.registerCommand("worktree", { + description: "Git worktrees: /worktree | list | merge [target] | remove ", + getArgumentCompletions: worktreeCompletions, + + async handler(args: string, ctx: ExtensionCommandContext) { + await worktreeHandler(args, ctx, pi, "worktree"); + }, + }); + + // /wt alias — same handler, same completions + pi.registerCommand("wt", { + description: "Alias for /worktree — Git worktrees: /wt | list | merge | remove", + getArgumentCompletions: worktreeCompletions, + async handler(args: string, ctx: ExtensionCommandContext) { + await worktreeHandler(args, ctx, pi, "wt"); + }, + }); +} + +// ─── Handlers ────────────────────────────────────────────────────────────── + +async function handleCreate( + basePath: string, + name: string, + ctx: ExtensionCommandContext, +): Promise { + try { + // Create from the main tree, not from inside another worktree + const mainBase = originalCwd ?? basePath; + const info = createWorktree(mainBase, name); + + // Auto-commit dirty files before leaving current workspace + const commitMsg = autoCommitCurrentBranch(basePath, "worktree-switch", name); + + // Track original cwd before switching + if (!originalCwd) originalCwd = basePath; + + const prevCwd = process.cwd(); + process.chdir(info.path); + nudgeGitBranchCache(prevCwd); + + const commitNote = commitMsg ? `\n Auto-committed on previous branch before switching.` : ""; + ctx.ui.notify( + [ + `Worktree "${name}" created and activated.`, + ` Path: ${info.path}`, + ` 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.`, + ].filter(Boolean).join("\n"), + "info", + ); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + ctx.ui.notify(`Failed to create worktree: ${msg}`, "error"); + } +} + +async function handleSwitch( + basePath: string, + name: string, + ctx: ExtensionCommandContext, +): Promise { + try { + const mainBase = originalCwd ?? basePath; + const wtPath = worktreePath(mainBase, name); + + if (!existsSync(wtPath)) { + ctx.ui.notify( + `Worktree "${name}" not found. Run /worktree list to see available worktrees.`, + "warning", + ); + return; + } + + // Auto-commit dirty files before leaving current workspace + const commitMsg = autoCommitCurrentBranch(basePath, "worktree-switch", name); + + // Track original cwd before switching + if (!originalCwd) originalCwd = basePath; + + const prevCwd = process.cwd(); + process.chdir(wtPath); + nudgeGitBranchCache(prevCwd); + + const commitNote = commitMsg ? `\n Auto-committed on previous branch before switching.` : ""; + ctx.ui.notify( + [ + `Switched to worktree "${name}".`, + ` Path: ${wtPath}`, + ` Branch: ${worktreeBranchName(name)}`, + commitNote, + `Use /worktree return to switch back to the main tree.`, + ].filter(Boolean).join("\n"), + "info", + ); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + ctx.ui.notify(`Failed to switch to worktree: ${msg}`, "error"); + } +} + +async function handleReturn(ctx: ExtensionCommandContext): Promise { + if (!originalCwd) { + ctx.ui.notify("Already in the main project tree.", "info"); + return; + } + + // Auto-commit dirty files before leaving worktree + const commitMsg = autoCommitCurrentBranch(process.cwd(), "worktree-return", "worktree"); + + const returnTo = originalCwd; + originalCwd = null; + + const prevCwd = process.cwd(); + process.chdir(returnTo); + nudgeGitBranchCache(prevCwd); + + const commitNote = commitMsg ? `\n Auto-committed on worktree branch before returning.` : ""; + ctx.ui.notify( + [ + `Returned to main project tree.`, + ` 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 YELLOW = "\x1b[33m"; +const WHITE = "\x1b[37m"; + +async function handleList( + basePath: string, + ctx: ExtensionCommandContext, +): Promise { + try { + const mainBase = originalCwd ?? basePath; + const worktrees = listWorktrees(mainBase); + + if (worktrees.length === 0) { + ctx.ui.notify("No GSD worktrees found. Create one with /worktree .", "info"); + return; + } + + const cwd = process.cwd(); + const lines = [`${BOLD}${WHITE}GSD Worktrees${RESET}`, ""]; + 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}`); + lines.push(""); + } + + if (originalCwd) { + lines.push(`${DIM}Main tree: ${originalCwd}${RESET}`); + } + + ctx.ui.notify(lines.join("\n"), "info"); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + ctx.ui.notify(`Failed to list worktrees: ${msg}`, "error"); + } +} + +async function handleMerge( + basePath: string, + name: string, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + targetBranch?: string, +): Promise { + try { + const branch = worktreeBranchName(name); + const mainBranch = targetBranch ?? getMainBranch(basePath); + + // Validate the worktree/branch exists + const worktrees = listWorktrees(basePath); + const wt = worktrees.find(w => w.name === name); + if (!wt) { + ctx.ui.notify(`Worktree "${name}" not found. Run /worktree list to see available worktrees.`, "warning"); + return; + } + + // Gather merge context + const diffSummary = diffWorktreeGSD(basePath, name); + const fullDiff = getWorktreeGSDDiff(basePath, name); + const commitLog = getWorktreeLog(basePath, name); + + 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"); + return; + } + + // Preview confirmation before merge dispatch + const previewLines = [ + `Merge worktree "${name}" → ${mainBranch}`, + "", + ` ${diffSummary.added.length} added · ${diffSummary.modified.length} modified · ${diffSummary.removed.length} removed`, + ]; + if (diffSummary.added.length > 0) { + previewLines.push("", " Added:"); + for (const f of diffSummary.added.slice(0, 10)) previewLines.push(` + ${f}`); + if (diffSummary.added.length > 10) previewLines.push(` … and ${diffSummary.added.length - 10} more`); + } + if (diffSummary.modified.length > 0) { + previewLines.push("", " Modified:"); + for (const f of diffSummary.modified.slice(0, 10)) previewLines.push(` ~ ${f}`); + if (diffSummary.modified.length > 10) previewLines.push(` … and ${diffSummary.modified.length - 10} more`); + } + if (diffSummary.removed.length > 0) { + previewLines.push("", " Removed:"); + for (const f of diffSummary.removed.slice(0, 10)) previewLines.push(` - ${f}`); + if (diffSummary.removed.length > 10) previewLines.push(` … and ${diffSummary.removed.length - 10} more`); + } + + const confirmed = await showConfirm(ctx, { + title: "Worktree Merge", + message: previewLines.join("\n"), + confirmLabel: "Merge", + declineLabel: "Cancel", + }); + if (!confirmed) { + ctx.ui.notify("Merge cancelled.", "info"); + return; + } + + // Format file lists for the prompt + const formatFiles = (files: string[]) => + files.length > 0 ? files.map(f => `- \`${f}\``).join("\n") : "_(none)_"; + + // Load and populate the merge prompt + const prompt = loadPrompt("worktree-merge", { + worktreeName: name, + worktreeBranch: branch, + mainBranch, + commitLog: commitLog || "(no commits)", + addedFiles: formatFiles(diffSummary.added), + modifiedFiles: formatFiles(diffSummary.modified), + removedFiles: formatFiles(diffSummary.removed), + fullDiff: fullDiff || "(no diff)", + }); + + // Dispatch to the LLM + pi.sendMessage( + { + customType: "gsd-worktree-merge", + content: prompt, + display: false, + }, + { triggerTurn: true }, + ); + + ctx.ui.notify( + `Merge helper started for worktree "${name}" (${totalChanges} GSD artifact change${totalChanges === 1 ? "" : "s"}).`, + "info", + ); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + ctx.ui.notify(`Failed to start merge: ${msg}`, "error"); + } +} + +async function handleRemove( + basePath: string, + name: string, + ctx: ExtensionCommandContext, +): Promise { + try { + const mainBase = originalCwd ?? basePath; + const prevCwd = process.cwd(); + removeWorktree(mainBase, name, { deleteBranch: true }); + + // If we were in that worktree, removeWorktree chdir'd us out — clear tracking + if (originalCwd && process.cwd() !== prevCwd) { + nudgeGitBranchCache(prevCwd); + originalCwd = null; + } + + ctx.ui.notify(`Worktree "${name}" removed (branch deleted).`, "info"); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + ctx.ui.notify(`Failed to remove worktree: ${msg}`, "error"); + } +} diff --git a/src/resources/extensions/gsd/worktree-manager.ts b/src/resources/extensions/gsd/worktree-manager.ts new file mode 100644 index 000000000..e26000644 --- /dev/null +++ b/src/resources/extensions/gsd/worktree-manager.ts @@ -0,0 +1,302 @@ +/** + * GSD Worktree Manager + * + * Creates and manages git worktrees under .gsd/worktrees//. + * Each worktree gets its own branch (worktree/) and a full + * working copy of the project, enabling parallel work streams. + * + * The merge helper compares .gsd/ artifacts between a worktree and + * the main branch, then dispatches an LLM-guided merge flow. + * + * Flow: + * 1. create() — git worktree add .gsd/worktrees/ -b worktree/ + * 2. user works in the worktree (new plans, milestones, etc.) + * 3. merge() — LLM-guided reconciliation of .gsd/ artifacts back to main + * 4. remove() — git worktree remove + branch cleanup + */ + +import { existsSync, mkdirSync, realpathSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { join, relative, resolve } from "node:path"; + +// ─── Types ───────────────────────────────────────────────────────────────── + +export interface WorktreeInfo { + name: string; + path: string; + branch: string; + exists: boolean; +} + +export interface WorktreeDiffSummary { + /** Files only in the worktree .gsd/ (new artifacts) */ + added: string[]; + /** Files in both but with different content */ + modified: string[]; + /** Files only in main .gsd/ (deleted in worktree) */ + removed: string[]; +} + +// ─── Git Helpers ─────────────────────────────────────────────────────────── + +function runGit(cwd: string, args: string[], opts: { allowFailure?: boolean } = {}): string { + try { + return execSync(`git ${args.join(" ")}`, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }).trim(); + } catch (error) { + if (opts.allowFailure) return ""; + const message = error instanceof Error ? error.message : String(error); + throw new Error(`git ${args.join(" ")} failed in ${cwd}: ${message}`); + } +} + +export function getMainBranch(basePath: string): string { + const symbolic = runGit(basePath, ["symbolic-ref", "refs/remotes/origin/HEAD"], { allowFailure: true }); + if (symbolic) { + const match = symbolic.match(/refs\/remotes\/origin\/(.+)$/); + if (match) return match[1]!; + } + if (runGit(basePath, ["show-ref", "--verify", "refs/heads/main"], { allowFailure: true })) return "main"; + if (runGit(basePath, ["show-ref", "--verify", "refs/heads/master"], { allowFailure: true })) return "master"; + return runGit(basePath, ["branch", "--show-current"]); +} + +// ─── Path Helpers ────────────────────────────────────────────────────────── + +export function worktreesDir(basePath: string): string { + return join(basePath, ".gsd", "worktrees"); +} + +export function worktreePath(basePath: string, name: string): string { + return join(worktreesDir(basePath), name); +} + +export function worktreeBranchName(name: string): string { + return `worktree/${name}`; +} + +// ─── Core Operations ─────────────────────────────────────────────────────── + +/** + * Create a new git worktree under .gsd/worktrees// with branch worktree/. + * The branch is created from the current HEAD of the main branch. + */ +export function createWorktree(basePath: string, name: string): WorktreeInfo { + // Validate name: alphanumeric, hyphens, underscores only + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + throw new Error(`Invalid worktree name "${name}". Use only letters, numbers, hyphens, and underscores.`); + } + + const wtPath = worktreePath(basePath, name); + const branch = worktreeBranchName(name); + + if (existsSync(wtPath)) { + throw new Error(`Worktree "${name}" already exists at ${wtPath}`); + } + + // Ensure the .gsd/worktrees/ directory exists + const wtDir = worktreesDir(basePath); + mkdirSync(wtDir, { recursive: true }); + + // Prune any stale worktree entries from a previous removal + runGit(basePath, ["worktree", "prune"], { allowFailure: true }); + + // Check if the branch already exists (leftover from a previous worktree) + const branchExists = runGit(basePath, ["show-ref", "--verify", `refs/heads/${branch}`], { allowFailure: true }); + const mainBranch = getMainBranch(basePath); + + if (branchExists) { + // Reset the stale branch to current main, then attach worktree to it + runGit(basePath, ["branch", "-f", branch, mainBranch]); + runGit(basePath, ["worktree", "add", wtPath, branch]); + } else { + runGit(basePath, ["worktree", "add", "-b", branch, wtPath, mainBranch]); + } + + return { + name, + path: wtPath, + branch, + exists: true, + }; +} + +/** + * List all GSD-managed worktrees. + * Parses `git worktree list` and filters to those under .gsd/worktrees/. + */ +export function listWorktrees(basePath: string): WorktreeInfo[] { + // Resolve real paths to handle symlinks (e.g. /tmp → /private/tmp on macOS) + const resolvedBase = existsSync(basePath) ? realpathSync(basePath) : resolve(basePath); + const wtDir = join(resolvedBase, ".gsd", "worktrees"); + const rawList = runGit(basePath, ["worktree", "list", "--porcelain"]); + + if (!rawList.trim()) return []; + + const worktrees: WorktreeInfo[] = []; + const entries = rawList.split("\n\n").filter(Boolean); + + for (const entry of entries) { + const lines = entry.split("\n"); + const wtLine = lines.find(l => l.startsWith("worktree ")); + const branchLine = lines.find(l => l.startsWith("branch ")); + + if (!wtLine || !branchLine) continue; + + const entryPath = wtLine.replace("worktree ", ""); + const branch = branchLine.replace("branch refs/heads/", ""); + + // Only include worktrees under .gsd/worktrees/ + if (!entryPath.startsWith(wtDir)) continue; + + const name = relative(wtDir, entryPath); + // Skip nested paths — only direct children + if (name.includes("/") || name.includes("\\")) continue; + + worktrees.push({ + name, + path: entryPath, + branch, + exists: existsSync(entryPath), + }); + } + + return worktrees; +} + +/** + * Remove a worktree and optionally delete its branch. + * If the process is currently inside the worktree, chdir out first. + */ +export function removeWorktree( + basePath: string, + name: string, + opts: { deleteBranch?: boolean; force?: boolean } = {}, +): void { + const wtPath = worktreePath(basePath, name); + const resolvedWtPath = existsSync(wtPath) ? realpathSync(wtPath) : wtPath; + const branch = worktreeBranchName(name); + const { deleteBranch = true, force = false } = opts; + + // If we're inside the worktree, move out first — git can't remove an in-use directory + const cwd = process.cwd(); + const resolvedCwd = existsSync(cwd) ? realpathSync(cwd) : cwd; + if (resolvedCwd === resolvedWtPath || resolvedCwd.startsWith(resolvedWtPath + "/")) { + process.chdir(basePath); + } + + if (!existsSync(wtPath)) { + runGit(basePath, ["worktree", "prune"], { allowFailure: true }); + if (deleteBranch) { + runGit(basePath, ["branch", "-D", branch], { allowFailure: true }); + } + return; + } + + // Force-remove to handle dirty worktrees + runGit(basePath, ["worktree", "remove", "--force", wtPath], { allowFailure: true }); + + // If the directory is still there (e.g. locked), try harder + if (existsSync(wtPath)) { + runGit(basePath, ["worktree", "remove", "--force", "--force", wtPath], { allowFailure: true }); + } + + // Prune stale entries so git knows the worktree is gone + runGit(basePath, ["worktree", "prune"], { allowFailure: true }); + + if (deleteBranch) { + runGit(basePath, ["branch", "-D", branch], { allowFailure: true }); + } +} + +/** + * Diff the .gsd/ directory between the worktree branch and main branch. + * Returns a summary of added, modified, and removed GSD artifacts. + */ +export function diffWorktreeGSD(basePath: string, name: string): WorktreeDiffSummary { + const branch = worktreeBranchName(name); + const mainBranch = getMainBranch(basePath); + + // Use git diff to compare .gsd/ between branches + const diffOutput = runGit(basePath, [ + "diff", "--name-status", `${mainBranch}...${branch}`, "--", ".gsd/", + ], { allowFailure: true }); + + const added: string[] = []; + const modified: string[] = []; + const removed: string[] = []; + + if (!diffOutput.trim()) return { added, modified, removed }; + + for (const line of diffOutput.split("\n").filter(Boolean)) { + const [status, ...pathParts] = line.split("\t"); + const filePath = pathParts.join("\t"); + + // Skip worktree-internal paths (e.g. .gsd/worktrees/, .gsd/runtime/) + if (filePath.startsWith(".gsd/worktrees/") || filePath.startsWith(".gsd/runtime/")) continue; + // Skip gitignored runtime files + if (filePath === ".gsd/STATE.md" || filePath === ".gsd/auto.lock" || filePath === ".gsd/metrics.json") continue; + if (filePath.startsWith(".gsd/activity/")) continue; + + switch (status) { + case "A": added.push(filePath); break; + case "M": modified.push(filePath); break; + case "D": removed.push(filePath); break; + default: + // Renames, copies — treat as modified + if (status?.startsWith("R") || status?.startsWith("C")) { + modified.push(filePath); + } + } + } + + return { added, modified, removed }; +} + +/** + * Get the full diff content for .gsd/ between the worktree branch and main. + * Returns the raw unified diff for LLM consumption. + */ +export function getWorktreeGSDDiff(basePath: string, name: string): string { + const branch = worktreeBranchName(name); + const mainBranch = getMainBranch(basePath); + + return runGit(basePath, [ + "diff", `${mainBranch}...${branch}`, "--", ".gsd/", + ], { allowFailure: true }); +} + +/** + * Get commit log for the worktree branch since it diverged from main. + */ +export function getWorktreeLog(basePath: string, name: string): string { + const branch = worktreeBranchName(name); + const mainBranch = getMainBranch(basePath); + + return runGit(basePath, [ + "log", "--oneline", `${mainBranch}..${branch}`, + ], { allowFailure: true }); +} + +/** + * Merge the worktree branch into main using squash merge. + * Must be called from the main working tree (not the worktree itself). + * Returns the merge commit message. + */ +export function mergeWorktreeToMain(basePath: string, name: string, commitMessage: string): string { + const branch = worktreeBranchName(name); + const mainBranch = getMainBranch(basePath); + const current = runGit(basePath, ["branch", "--show-current"]); + + if (current !== mainBranch) { + throw new Error(`Must be on ${mainBranch} to merge. Currently on ${current}.`); + } + + runGit(basePath, ["merge", "--squash", branch]); + runGit(basePath, ["commit", "-m", commitMessage]); + + return commitMessage; +}