feat: /worktree (/wt) — git worktree lifecycle for GSD (#31)

This commit is contained in:
jonathancostin 2026-03-11 07:59:02 -05:00 committed by GitHub
parent 0b1f02219b
commit 6b2f8b0a05
7 changed files with 1121 additions and 2 deletions

View file

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

View file

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

View file

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

View file

@ -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}}): <summary of what was merged>`
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

View file

@ -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<T>(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<void> {
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);
});

View file

@ -0,0 +1,527 @@
/**
* GSD Worktree Command /worktree
*
* Create, list, merge, and remove git worktrees under .gsd/worktrees/.
*
* Usage:
* /worktree <name> create a new worktree
* /worktree list list existing worktrees
* /worktree merge <branch> [target] start LLM-guided merge (default target: main)
* /worktree remove <name> 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<void> {
const trimmed = (typeof args === "string" ? args : "").trim();
const basePath = process.cwd();
if (trimmed === "") {
ctx.ui.notify(
[
"Usage:",
` /${alias} <name> — create and switch into a new worktree`,
` /${alias} switch <name> — switch into an existing worktree`,
` /${alias} return — switch back to the main project tree`,
` /${alias} list — list all worktrees`,
` /${alias} merge <branch> [target] — merge worktree into target branch`,
` /${alias} remove <name> — 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 <name>`, "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 <branch> [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 <name>`, "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" ? "" : " <name>"}`, "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 <name> | list | merge <branch> [target] | remove <name>",
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 <name> | 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<void> {
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<void> {
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<void> {
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<void> {
try {
const mainBase = originalCwd ?? basePath;
const worktrees = listWorktrees(mainBase);
if (worktrees.length === 0) {
ctx.ui.notify("No GSD worktrees found. Create one with /worktree <name>.", "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<void> {
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<void> {
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");
}
}

View file

@ -0,0 +1,302 @@
/**
* GSD Worktree Manager
*
* Creates and manages git worktrees under .gsd/worktrees/<name>/.
* Each worktree gets its own branch (worktree/<name>) 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/<name> -b worktree/<name>
* 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/<name>/ with branch worktree/<name>.
* 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;
}