feat: /worktree (/wt) — git worktree lifecycle for GSD (#31)
This commit is contained in:
parent
0b1f02219b
commit
6b2f8b0a05
7 changed files with 1121 additions and 2 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
89
src/resources/extensions/gsd/prompts/worktree-merge.md
Normal file
89
src/resources/extensions/gsd/prompts/worktree-merge.md
Normal 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
|
||||
160
src/resources/extensions/gsd/tests/worktree-manager.test.ts
Normal file
160
src/resources/extensions/gsd/tests/worktree-manager.test.ts
Normal 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);
|
||||
});
|
||||
527
src/resources/extensions/gsd/worktree-command.ts
Normal file
527
src/resources/extensions/gsd/worktree-command.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
302
src/resources/extensions/gsd/worktree-manager.ts
Normal file
302
src/resources/extensions/gsd/worktree-manager.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue