feat: improve worktree merge, create, remove, and reload resilience (#61)

Merge improvements:
- Auto-detect current worktree: /worktree merge (bare) and /worktree merge main
  work from inside a worktree without specifying the worktree name
- Full repo diffs: preview and LLM prompt show all changed files, not just .gsd/
- Accurate preview: direct diff (main vs branch) shows actual merge impact
- Per-file line stats: +N/-N shown for each file in merge preview
- CWD fix: chdir to main tree before dispatching merge to prevent broken CWD
  after worktree cleanup
- Prompt includes explicit paths so the LLM knows where to read/write

Create/switch:
- /worktree create <name> works as alias for create-or-switch behavior
- Guard against creating a worktree when the branch is already in use

Remove:
- /worktree remove <name> validates the name exists before attempting removal
- /worktree remove <name> confirms before deleting
- /worktree remove all removes every worktree after confirmation prompt

Reload resilience:
- Detects if CWD is inside a worktree on extension init and restores
  originalCwd tracking, surviving /reload without losing worktree state

Command descriptions:
- /worktree shows '(also /wt)' in description
- /wt shows 'Alias for /worktree'
This commit is contained in:
jonathancostin 2026-03-11 16:46:34 -05:00 committed by GitHub
parent 09c5aa33ee
commit 85f60451fb
3 changed files with 377 additions and 83 deletions

View file

@ -1,8 +1,16 @@
You are merging GSD artifacts from worktree **{{worktreeName}}** (branch `{{worktreeBranch}}`) into target branch `{{mainBranch}}`.
You are merging changes from worktree **{{worktreeName}}** (branch `{{worktreeBranch}}`) into target branch `{{mainBranch}}`.
## Working Directory
Your current working directory has been set to the **main project tree** at `{{mainTreePath}}`. You are on the `{{mainBranch}}` branch. All git and file commands run from here.
- **Main tree (CWD):** `{{mainTreePath}}` — this is where you run `git merge`, read main-branch files, and commit
- **Worktree directory:** `{{worktreePath}}` — the worktree's working copy; read files here to inspect worktree versions before merging
- **Worktree branch:** `{{worktreeBranch}}`
## 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.
The worktree was created as a parallel workspace. It may contain code changes, new milestones, updated roadmaps, new plans, research, decisions, or other artifacts that need to be merged into the target branch.
### Commit History (worktree)
@ -10,7 +18,7 @@ The worktree was created as a parallel workspace. It may contain new milestones,
{{commitLog}}
```
### GSD Artifact Changes
### Changed Files
**Added files:**
{{addedFiles}}
@ -21,10 +29,16 @@ The worktree was created as a parallel workspace. It may contain new milestones,
**Removed files:**
{{removedFiles}}
### Full Diff
### Code Diff
```diff
{{fullDiff}}
{{codeDiff}}
```
### GSD Artifact Diff
```diff
{{gsdDiff}}
```
## Your Task
@ -33,7 +47,15 @@ Analyze the changes and guide the merge. Follow these steps exactly:
### Step 1: Categorize Changes
Classify each changed GSD artifact:
Classify each changed file:
**Code changes:**
- **New source files** — new modules, components, utilities, tests
- **Modified source files** — changes to existing code
- **Config changes** — package.json, tsconfig, build config, etc.
- **Deleted files** — removed source or config files
**GSD artifact changes:**
- **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
@ -47,7 +69,12 @@ Classify each changed GSD artifact:
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:
To compare versions:
- **Main-branch version:** read the file at its normal path (your CWD is the main tree)
- **Worktree version:** read the file at `{{worktreePath}}/<relative-path>`
- Use `git merge-base {{mainBranch}} {{worktreeBranch}}` to find the common ancestor if needed
Classify each modified file:
- **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
@ -58,28 +85,35 @@ 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
3. For **new files**: confirm they should be added to the main branch
4. For **removed files**: confirm the removals are intentional
Ask the user to confirm the merge plan before proceeding.
### Step 4: Execute Merge
Once confirmed:
Once confirmed, run all commands from `{{mainTreePath}}` (your CWD):
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
1. Ensure you are on the target branch: `git checkout {{mainBranch}}`
2. If there are conflicts requiring manual reconciliation, apply the reconciled versions first
3. Run `git merge --squash {{worktreeBranch}}` to bring in all changes
4. Review the staged changes — if any reconciled files need adjustment, apply them now
5. Commit with message: `merge(worktree/{{worktreeName}}): <summary of what was merged>`
6. 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
- **Remove the worktree** — delete the worktree directory and the `{{worktreeBranch}}` branch
- **Keep the worktree** — leave it for continued parallel work
If the user chooses to remove it, run `/worktree remove {{worktreeName}}`.
If the user chooses to remove it, run these commands from `{{mainTreePath}}`:
```
git worktree remove {{worktreePath}}
git branch -D {{worktreeBranch}}
```
**Do NOT use `/worktree remove` — the command handler may not have the correct state after the merge.** Use the git commands directly.
## Important

View file

@ -6,7 +6,7 @@
* Usage:
* /worktree <name> create a new worktree
* /worktree list list existing worktrees
* /worktree merge <branch> [target] start LLM-guided merge (default target: main)
* /worktree merge [name] [target] start LLM-guided merge (auto-detects when inside a worktree)
* /worktree remove <name> remove a worktree and its branch
*/
@ -18,15 +18,18 @@ import {
createWorktree,
listWorktrees,
removeWorktree,
diffWorktreeGSD,
diffWorktreeAll,
diffWorktreeNumstat,
getMainBranch,
getWorktreeGSDDiff,
getWorktreeCodeDiff,
getWorktreeLog,
worktreeBranchName,
worktreePath,
} from "./worktree-manager.js";
import type { FileLineStat } from "./worktree-manager.js";
import { existsSync, realpathSync, readFileSync, utimesSync } from "node:fs";
import { join, resolve } from "node:path";
import { join, resolve, sep } from "node:path";
/**
* Tracks the original project root so we can switch back.
@ -100,7 +103,7 @@ export function getActiveWorktreeName(): string | null {
function worktreeCompletions(prefix: string) {
const parts = prefix.trim().split(/\s+/);
const subcommands = ["list", "merge", "remove", "switch", "return"];
const subcommands = ["list", "merge", "remove", "switch", "create", "return"];
if (parts.length <= 1) {
const partial = parts[0] ?? "";
@ -119,13 +122,21 @@ function worktreeCompletions(prefix: string) {
}
}
if ((parts[0] === "merge" || parts[0] === "remove" || parts[0] === "switch") && parts.length <= 2) {
if ((parts[0] === "merge" || parts[0] === "remove" || parts[0] === "switch" || parts[0] === "create") && parts.length <= 2) {
const namePrefix = parts[1] ?? "";
try {
const existing = listWorktrees(process.cwd());
return existing
const mainBase = getWorktreeOriginalCwd() ?? process.cwd();
const existing = listWorktrees(mainBase);
const nameCompletions = existing
.filter(wt => wt.name.startsWith(namePrefix))
.map(wt => ({ value: `${parts[0]} ${wt.name}`, label: wt.name }));
// Add "all" option for remove
if (parts[0] === "remove" && "all".startsWith(namePrefix)) {
nameCompletions.push({ value: "remove all", label: "all" });
}
return nameCompletions;
} catch {
return [];
}
@ -151,8 +162,8 @@ async function worktreeHandler(
` /${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`,
` /${alias} merge [name] [target] — merge worktree into target branch (auto-detects when inside a worktree)`,
` /${alias} remove <name|all> — remove a worktree (or all) and its branch`,
].join("\n"),
"info",
);
@ -169,41 +180,76 @@ async function worktreeHandler(
return;
}
if (trimmed.startsWith("switch ")) {
const name = trimmed.replace(/^switch\s+/, "").trim();
if (trimmed.startsWith("switch ") || trimmed.startsWith("create ")) {
const name = trimmed.replace(/^(?:switch|create)\s+/, "").trim();
if (!name) {
ctx.ui.notify(`Usage: /${alias} switch <name>`, "warning");
ctx.ui.notify(`Usage: /${alias} ${trimmed.split(" ")[0]} <name>`, "warning");
return;
}
await handleSwitch(basePath, name, ctx);
// create and switch both do the same thing: switch if exists, create if not
const mainBase = originalCwd ?? basePath;
const existing = listWorktrees(mainBase);
if (existing.some(wt => wt.name === name)) {
await handleSwitch(basePath, name, ctx);
} else {
await handleCreate(basePath, name, ctx);
}
return;
}
if (trimmed.startsWith("merge ")) {
const mergeArgs = trimmed.replace(/^merge\s+/, "").trim().split(/\s+/);
const name = mergeArgs[0] ?? "";
if (trimmed === "merge" || trimmed.startsWith("merge ")) {
const mergeArgs = trimmed.replace(/^merge\s*/, "").trim().split(/\s+/).filter(Boolean);
const mainBase = originalCwd ?? basePath;
const activeWt = getActiveWorktreeName();
if (mergeArgs.length === 0) {
// Bare "/worktree merge" — only valid when inside a worktree
if (!activeWt) {
ctx.ui.notify(`Usage: /${alias} merge <name> [target]`, "warning");
return;
}
await handleMerge(mainBase, activeWt, ctx, pi, undefined);
return;
}
const name = mergeArgs[0]!;
const targetBranch = mergeArgs[1];
if (!name) {
ctx.ui.notify(`Usage: /${alias} merge <branch> [target]`, "warning");
return;
// Check if 'name' is an actual worktree
const worktrees = listWorktrees(mainBase);
const isWorktree = worktrees.some(w => w.name === name);
if (isWorktree) {
await handleMerge(mainBase, name, ctx, pi, targetBranch);
} else if (activeWt) {
// Not a worktree name — user is in a worktree and gave the target branch
// e.g. "/worktree merge main" while inside worktree "new"
await handleMerge(mainBase, activeWt, ctx, pi, name);
} else {
ctx.ui.notify(`Worktree "${name}" not found. Run /${alias} list to see available worktrees.`, "warning");
}
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");
if (trimmed === "remove" || trimmed.startsWith("remove ")) {
const name = trimmed.replace(/^remove\s*/, "").trim();
const mainBase = originalCwd ?? basePath;
if (name === "all") {
await handleRemoveAll(mainBase, ctx);
return;
}
const mainBase = originalCwd ?? basePath;
if (!name) {
ctx.ui.notify(`Usage: /${alias} remove <name|all>`, "warning");
return;
}
await handleRemove(mainBase, name, ctx);
return;
}
const RESERVED = ["list", "return", "switch", "merge", "remove"];
const RESERVED = ["list", "return", "switch", "create", "merge", "remove"];
if (RESERVED.includes(trimmed)) {
ctx.ui.notify(`Usage: /${alias} ${trimmed}${trimmed === "list" || trimmed === "return" ? "" : " <name>"}`, "warning");
return;
@ -225,8 +271,20 @@ async function worktreeHandler(
}
export function registerWorktreeCommand(pi: ExtensionAPI): void {
// Restore worktree state after /reload.
// The module-level originalCwd resets to null when extensions are re-loaded,
// but process.cwd() is still inside the worktree. Detect this and recover.
if (!originalCwd) {
const cwd = process.cwd();
const marker = `${sep}.gsd${sep}worktrees${sep}`;
const markerIdx = cwd.indexOf(marker);
if (markerIdx !== -1) {
originalCwd = cwd.slice(0, markerIdx);
}
}
pi.registerCommand("worktree", {
description: "Git worktrees: /worktree <name> | list | merge <branch> [target] | remove <name>",
description: "Git worktrees (also /wt): /worktree <name> | list | merge | remove",
getArgumentCompletions: worktreeCompletions,
async handler(args: string, ctx: ExtensionCommandContext) {
@ -236,7 +294,7 @@ export function registerWorktreeCommand(pi: ExtensionAPI): void {
// /wt alias — same handler, same completions
pi.registerCommand("wt", {
description: "Alias for /worktree — Git worktrees: /wt <name> | list | merge | remove",
description: "Alias for /worktree",
getArgumentCompletions: worktreeCompletions,
async handler(args: string, ctx: ExtensionCommandContext) {
await worktreeHandler(args, ctx, pi, "wt");
@ -362,6 +420,7 @@ const DIM = "\x1b[2m";
const RESET = "\x1b[0m";
const CYAN = "\x1b[36m";
const GREEN = "\x1b[32m";
const RED = "\x1b[31m";
const YELLOW = "\x1b[33m";
const WHITE = "\x1b[37m";
@ -423,9 +482,11 @@ async function handleMerge(
return;
}
// Gather merge context
const diffSummary = diffWorktreeGSD(basePath, name);
const fullDiff = getWorktreeGSDDiff(basePath, name);
// Gather merge context — full repo diff, not just .gsd/
const diffSummary = diffWorktreeAll(basePath, name);
const numstat = diffWorktreeNumstat(basePath, name);
const gsdDiff = getWorktreeGSDDiff(basePath, name);
const codeDiff = getWorktreeCodeDiff(basePath, name);
const commitLog = getWorktreeLog(basePath, name);
const totalChanges = diffSummary.added.length + diffSummary.modified.length + diffSummary.removed.length;
@ -434,27 +495,48 @@ async function handleMerge(
return;
}
// Build a map of file → line stats for the preview
const statMap = new Map<string, FileLineStat>();
for (const s of numstat) statMap.set(s.file, s);
// Compute totals
let totalAdded = 0;
let totalRemoved = 0;
for (const s of numstat) { totalAdded += s.added; totalRemoved += s.removed; }
// Split files into code vs GSD for the preview
const isGSD = (f: string) => f.startsWith(".gsd/");
const codeChanges = diffSummary.added.filter(f => !isGSD(f)).length
+ diffSummary.modified.filter(f => !isGSD(f)).length
+ diffSummary.removed.filter(f => !isGSD(f)).length;
const gsdChanges = diffSummary.added.filter(isGSD).length
+ diffSummary.modified.filter(isGSD).length
+ diffSummary.removed.filter(isGSD).length;
// Format a file line with +/- stats
const formatFileLine = (prefix: string, file: string): string => {
const s = statMap.get(file);
const stat = s ? ` ${GREEN}+${s.added}${RESET} ${RED}-${s.removed}${RESET}` : "";
return ` ${prefix} ${file}${stat}`;
};
// Preview confirmation before merge dispatch
const previewLines = [
`Merge worktree "${name}" → ${mainBranch}`,
"",
` ${diffSummary.added.length} added · ${diffSummary.modified.length} modified · ${diffSummary.removed.length} removed`,
` ${totalChanges} file${totalChanges === 1 ? "" : "s"} changed, ${GREEN}+${totalAdded}${RESET} ${RED}-${totalRemoved}${RESET} lines (${codeChanges} code, ${gsdChanges} GSD)`,
];
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 appendFileList = (label: string, files: string[], prefix: string, limit = 10) => {
if (files.length === 0) return;
previewLines.push("", ` ${label}:`);
for (const f of files.slice(0, limit)) previewLines.push(formatFileLine(prefix, f));
if (files.length > limit) previewLines.push(` … and ${files.length - limit} more`);
};
appendFileList("Added", diffSummary.added, "+");
appendFileList("Modified", diffSummary.modified, "~");
appendFileList("Removed", diffSummary.removed, "-");
const confirmed = await showConfirm(ctx, {
title: "Worktree Merge",
@ -467,20 +549,34 @@ async function handleMerge(
return;
}
// Switch to the main tree before dispatching the merge.
// The LLM needs to run git merge --squash from the main branch, and if
// it later removes the worktree, the agent's CWD must not be inside it.
if (originalCwd) {
const prevCwd = process.cwd();
process.chdir(basePath);
nudgeGitBranchCache(prevCwd);
originalCwd = null;
}
// 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 wtPath = worktreePath(basePath, name);
const prompt = loadPrompt("worktree-merge", {
worktreeName: name,
worktreeBranch: branch,
mainBranch,
mainTreePath: basePath,
worktreePath: wtPath,
commitLog: commitLog || "(no commits)",
addedFiles: formatFiles(diffSummary.added),
modifiedFiles: formatFiles(diffSummary.modified),
removedFiles: formatFiles(diffSummary.removed),
fullDiff: fullDiff || "(no diff)",
gsdDiff: gsdDiff || "(no GSD artifact changes)",
codeDiff: codeDiff || "(no code changes)",
});
// Dispatch to the LLM
@ -494,7 +590,7 @@ async function handleMerge(
);
ctx.ui.notify(
`Merge helper started for worktree "${name}" (${totalChanges} GSD artifact change${totalChanges === 1 ? "" : "s"}).`,
`Merge helper started for worktree "${name}" (${codeChanges} code + ${gsdChanges} GSD artifact change${totalChanges === 1 ? "" : "s"}).`,
"info",
);
} catch (error) {
@ -510,6 +606,26 @@ async function handleRemove(
): Promise<void> {
try {
const mainBase = originalCwd ?? basePath;
// Validate the worktree exists before attempting removal
const worktrees = listWorktrees(mainBase);
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;
}
const confirmed = await showConfirm(ctx, {
title: "Remove Worktree",
message: `Remove worktree "${name}" and delete branch ${wt.branch}?`,
confirmLabel: "Remove",
declineLabel: "Cancel",
});
if (!confirmed) {
ctx.ui.notify("Cancelled.", "info");
return;
}
const prevCwd = process.cwd();
removeWorktree(mainBase, name, { deleteBranch: true });
@ -525,3 +641,57 @@ async function handleRemove(
ctx.ui.notify(`Failed to remove worktree: ${msg}`, "error");
}
}
async function handleRemoveAll(
basePath: string,
ctx: ExtensionCommandContext,
): Promise<void> {
try {
const mainBase = originalCwd ?? basePath;
const worktrees = listWorktrees(mainBase);
if (worktrees.length === 0) {
ctx.ui.notify("No worktrees to remove.", "info");
return;
}
const names = worktrees.map(w => w.name);
const confirmed = await showConfirm(ctx, {
title: "Remove All Worktrees",
message: `This will remove ${worktrees.length} worktree${worktrees.length === 1 ? "" : "s"} and delete their branches:\n\n${names.map(n => `${n}`).join("\n")}`,
confirmLabel: "Remove all",
declineLabel: "Cancel",
});
if (!confirmed) {
ctx.ui.notify("Cancelled.", "info");
return;
}
const prevCwd = process.cwd();
const removed: string[] = [];
const failed: string[] = [];
for (const wt of worktrees) {
try {
removeWorktree(mainBase, wt.name, { deleteBranch: true });
removed.push(wt.name);
} catch {
failed.push(wt.name);
}
}
// If we were in a worktree that got removed, clear tracking
if (originalCwd && process.cwd() !== prevCwd) {
nudgeGitBranchCache(prevCwd);
originalCwd = null;
}
const lines: string[] = [];
if (removed.length > 0) lines.push(`Removed: ${removed.join(", ")}`);
if (failed.length > 0) lines.push(`Failed: ${failed.join(", ")}`);
ctx.ui.notify(lines.join("\n"), failed.length > 0 ? "warning" : "info");
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`Failed to remove worktrees: ${msg}`, "error");
}
}

View file

@ -28,6 +28,13 @@ export interface WorktreeInfo {
exists: boolean;
}
/** Per-file line change stats from git diff --numstat. */
export interface FileLineStat {
file: string;
added: number;
removed: number;
}
export interface WorktreeDiffSummary {
/** Files only in the worktree .gsd/ (new artifacts) */
added: string[];
@ -109,6 +116,18 @@ export function createWorktree(basePath: string, name: string): WorktreeInfo {
const mainBranch = getMainBranch(basePath);
if (branchExists) {
// Check if the branch is actively used by an existing worktree.
// `git branch -f` will fail if the branch is checked out somewhere.
const worktreeUsing = runGit(basePath, ["worktree", "list", "--porcelain"], { allowFailure: true });
const branchInUse = worktreeUsing.includes(`branch refs/heads/${branch}`);
if (branchInUse) {
throw new Error(
`Branch "${branch}" is already in use by another worktree. ` +
`Remove the existing worktree first with /worktree remove ${name}.`,
);
}
// Reset the stale branch to current main, then attach worktree to it
runGit(basePath, ["branch", "-f", branch, mainBranch]);
runGit(basePath, ["worktree", "add", wtPath, branch]);
@ -212,19 +231,17 @@ export function removeWorktree(
}
}
/**
* 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);
/** Paths to skip in all worktree diffs (internal/runtime artifacts). */
const SKIP_PATHS = [".gsd/worktrees/", ".gsd/runtime/", ".gsd/activity/"];
const SKIP_EXACT = [".gsd/STATE.md", ".gsd/auto.lock", ".gsd/metrics.json"];
// Use git diff to compare .gsd/ between branches
const diffOutput = runGit(basePath, [
"diff", "--name-status", `${mainBranch}...${branch}`, "--", ".gsd/",
], { allowFailure: true });
function shouldSkipPath(filePath: string): boolean {
if (SKIP_PATHS.some(p => filePath.startsWith(p))) return true;
if (SKIP_EXACT.includes(filePath)) return true;
return false;
}
function parseDiffNameStatus(diffOutput: string): WorktreeDiffSummary {
const added: string[] = [];
const modified: string[] = [];
const removed: string[] = [];
@ -235,11 +252,7 @@ export function diffWorktreeGSD(basePath: string, name: string): WorktreeDiffSum
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;
if (shouldSkipPath(filePath)) continue;
switch (status) {
case "A": added.push(filePath); break;
@ -256,6 +269,68 @@ export function diffWorktreeGSD(basePath: string, name: string): WorktreeDiffSum
return { added, modified, removed };
}
/**
* 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);
const diffOutput = runGit(basePath, [
"diff", "--name-status", `${mainBranch}...${branch}`, "--", ".gsd/",
], { allowFailure: true });
return parseDiffNameStatus(diffOutput);
}
/**
* Diff ALL files between the worktree branch and main branch.
* Returns a summary of added, modified, and removed files across the entire repo.
*/
/**
* Diff ALL files between the worktree branch and main branch.
* Uses direct diff (no merge-base) to show what will actually change
* on main when the merge is applied. If both branches have identical
* content, this correctly returns an empty diff.
*/
export function diffWorktreeAll(basePath: string, name: string): WorktreeDiffSummary {
const branch = worktreeBranchName(name);
const mainBranch = getMainBranch(basePath);
const diffOutput = runGit(basePath, [
"diff", "--name-status", mainBranch, branch,
], { allowFailure: true });
return parseDiffNameStatus(diffOutput);
}
/**
* Get per-file line addition/deletion stats for what will change on main.
* Uses direct diff (not merge-base) so the preview matches the actual merge outcome.
*/
export function diffWorktreeNumstat(basePath: string, name: string): FileLineStat[] {
const branch = worktreeBranchName(name);
const mainBranch = getMainBranch(basePath);
const raw = runGit(basePath, [
"diff", "--numstat", mainBranch, branch,
], { allowFailure: true });
if (!raw.trim()) return [];
const stats: FileLineStat[] = [];
for (const line of raw.split("\n").filter(Boolean)) {
const [a, r, ...pathParts] = line.split("\t");
const file = pathParts.join("\t");
if (shouldSkipPath(file)) continue;
const added = a === "-" ? 0 : parseInt(a ?? "0", 10);
const removed = r === "-" ? 0 : parseInt(r ?? "0", 10);
stats.push({ file, added, removed });
}
return stats;
}
/**
* Get the full diff content for .gsd/ between the worktree branch and main.
* Returns the raw unified diff for LLM consumption.
@ -269,6 +344,21 @@ export function getWorktreeGSDDiff(basePath: string, name: string): string {
], { allowFailure: true });
}
/**
* Get the full diff content for non-.gsd/ files between the worktree branch and main.
* Returns the raw unified diff for LLM consumption.
*/
export function getWorktreeCodeDiff(basePath: string, name: string): string {
const branch = worktreeBranchName(name);
const mainBranch = getMainBranch(basePath);
// Get full diff, then exclude .gsd/ paths
// We use pathspec magic to exclude .gsd/
return runGit(basePath, [
"diff", `${mainBranch}...${branch}`, "--", ".", ":(exclude).gsd/",
], { allowFailure: true });
}
/**
* Get commit log for the worktree branch since it diverged from main.
*/