From 85f60451fb84d7d5912098f53ef5a06f6048c714 Mon Sep 17 00:00:00 2001 From: jonathancostin <66714927+jonathancostin@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:46:34 -0500 Subject: [PATCH] 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 works as alias for create-or-switch behavior - Guard against creating a worktree when the branch is already in use Remove: - /worktree remove validates the name exists before attempting removal - /worktree remove 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' --- .../extensions/gsd/prompts/worktree-merge.md | 68 +++-- .../extensions/gsd/worktree-command.ts | 270 ++++++++++++++---- .../extensions/gsd/worktree-manager.ts | 122 ++++++-- 3 files changed, 377 insertions(+), 83 deletions(-) diff --git a/src/resources/extensions/gsd/prompts/worktree-merge.md b/src/resources/extensions/gsd/prompts/worktree-merge.md index a89cb8905..65f865f21 100644 --- a/src/resources/extensions/gsd/prompts/worktree-merge.md +++ b/src/resources/extensions/gsd/prompts/worktree-merge.md @@ -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}}/` +- 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}}): ` -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}}): ` +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 diff --git a/src/resources/extensions/gsd/worktree-command.ts b/src/resources/extensions/gsd/worktree-command.ts index bd085df04..489976cd2 100644 --- a/src/resources/extensions/gsd/worktree-command.ts +++ b/src/resources/extensions/gsd/worktree-command.ts @@ -6,7 +6,7 @@ * Usage: * /worktree — create a new worktree * /worktree list — list existing worktrees - * /worktree merge [target] — start LLM-guided merge (default target: main) + * /worktree merge [name] [target] — start LLM-guided merge (auto-detects when inside a worktree) * /worktree remove — 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 — switch into an existing worktree`, ` /${alias} return — switch back to the main project tree`, ` /${alias} list — list all worktrees`, - ` /${alias} merge [target] — merge worktree into target branch`, - ` /${alias} remove — remove a worktree and its branch`, + ` /${alias} merge [name] [target] — merge worktree into target branch (auto-detects when inside a worktree)`, + ` /${alias} remove — 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 `, "warning"); + ctx.ui.notify(`Usage: /${alias} ${trimmed.split(" ")[0]} `, "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 [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 [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 `, "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 `, "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" ? "" : " "}`, "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 | list | merge [target] | remove ", + description: "Git worktrees (also /wt): /worktree | 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 | 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(); + 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 { 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 { + 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"); + } +} diff --git a/src/resources/extensions/gsd/worktree-manager.ts b/src/resources/extensions/gsd/worktree-manager.ts index e26000644..217826d9c 100644 --- a/src/resources/extensions/gsd/worktree-manager.ts +++ b/src/resources/extensions/gsd/worktree-manager.ts @@ -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. */