gsd-pi 0.3.0 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +3 -1
  2. package/dist/cli.js +112 -5
  3. package/dist/loader.js +0 -0
  4. package/dist/resource-loader.d.ts +3 -3
  5. package/dist/resource-loader.js +10 -4
  6. package/dist/tool-bootstrap.d.ts +4 -0
  7. package/dist/tool-bootstrap.js +74 -0
  8. package/dist/wizard.js +15 -5
  9. package/package.json +6 -2
  10. package/patches/@mariozechner+pi-coding-agent+0.57.1.patch +48 -0
  11. package/patches/@mariozechner+pi-tui+0.57.1.patch +47 -0
  12. package/scripts/postinstall.js +8 -0
  13. package/src/resources/extensions/bg-shell/index.ts +57 -8
  14. package/src/resources/extensions/browser-tools/index.ts +80 -7
  15. package/src/resources/extensions/github/gh-api.ts +46 -30
  16. package/src/resources/extensions/gsd/auto.ts +188 -10
  17. package/src/resources/extensions/gsd/commands.ts +13 -6
  18. package/src/resources/extensions/gsd/doctor.ts +7 -0
  19. package/src/resources/extensions/gsd/guided-flow.ts +9 -6
  20. package/src/resources/extensions/gsd/index.ts +32 -2
  21. package/src/resources/extensions/gsd/prompts/discuss.md +73 -27
  22. package/src/resources/extensions/gsd/prompts/system.md +1 -1
  23. package/src/resources/extensions/gsd/prompts/worktree-merge.md +51 -17
  24. package/src/resources/extensions/gsd/tests/discuss-prompt.test.ts +38 -0
  25. package/src/resources/extensions/gsd/worktree-command.ts +219 -49
  26. package/src/resources/extensions/gsd/worktree-manager.ts +106 -16
  27. package/src/resources/extensions/mcporter/index.ts +410 -0
  28. package/src/resources/extensions/slash-commands/clear.ts +10 -0
  29. package/src/resources/extensions/slash-commands/index.ts +2 -2
  30. package/src/resources/extensions/voice/index.ts +176 -0
  31. package/src/resources/extensions/voice/speech-recognizer +0 -0
  32. package/src/resources/extensions/voice/speech-recognizer.swift +76 -0
  33. package/dist/modes/interactive/theme/dark.json +0 -85
  34. package/dist/modes/interactive/theme/light.json +0 -84
  35. package/dist/modes/interactive/theme/theme-schema.json +0 -335
  36. package/dist/modes/interactive/theme/theme.d.ts +0 -78
  37. package/dist/modes/interactive/theme/theme.d.ts.map +0 -1
  38. package/dist/modes/interactive/theme/theme.js +0 -949
  39. package/dist/modes/interactive/theme/theme.js.map +0 -1
  40. package/src/resources/extensions/slash-commands/gsd-run.ts +0 -34
@@ -6,7 +6,7 @@
6
6
  * Usage:
7
7
  * /worktree <name> — create a new worktree
8
8
  * /worktree list — list existing worktrees
9
- * /worktree merge <branch> [target] — start LLM-guided merge (default target: main)
9
+ * /worktree merge [name] [target] — start LLM-guided merge (auto-detects when inside a worktree)
10
10
  * /worktree remove <name> — remove a worktree and its branch
11
11
  */
12
12
 
@@ -18,15 +18,18 @@ import {
18
18
  createWorktree,
19
19
  listWorktrees,
20
20
  removeWorktree,
21
- diffWorktreeGSD,
21
+ diffWorktreeAll,
22
+ diffWorktreeNumstat,
22
23
  getMainBranch,
23
24
  getWorktreeGSDDiff,
25
+ getWorktreeCodeDiff,
24
26
  getWorktreeLog,
25
27
  worktreeBranchName,
26
28
  worktreePath,
27
29
  } from "./worktree-manager.js";
30
+ import type { FileLineStat } from "./worktree-manager.js";
28
31
  import { existsSync, realpathSync, readFileSync, utimesSync } from "node:fs";
29
- import { join, resolve } from "node:path";
32
+ import { join, resolve, sep } from "node:path";
30
33
 
31
34
  /**
32
35
  * Tracks the original project root so we can switch back.
@@ -100,7 +103,7 @@ export function getActiveWorktreeName(): string | null {
100
103
 
101
104
  function worktreeCompletions(prefix: string) {
102
105
  const parts = prefix.trim().split(/\s+/);
103
- const subcommands = ["list", "merge", "remove", "switch", "return"];
106
+ const subcommands = ["list", "merge", "remove", "switch", "create", "return"];
104
107
 
105
108
  if (parts.length <= 1) {
106
109
  const partial = parts[0] ?? "";
@@ -119,13 +122,21 @@ function worktreeCompletions(prefix: string) {
119
122
  }
120
123
  }
121
124
 
122
- if ((parts[0] === "merge" || parts[0] === "remove" || parts[0] === "switch") && parts.length <= 2) {
125
+ if ((parts[0] === "merge" || parts[0] === "remove" || parts[0] === "switch" || parts[0] === "create") && parts.length <= 2) {
123
126
  const namePrefix = parts[1] ?? "";
124
127
  try {
125
- const existing = listWorktrees(process.cwd());
126
- return existing
128
+ const mainBase = getWorktreeOriginalCwd() ?? process.cwd();
129
+ const existing = listWorktrees(mainBase);
130
+ const nameCompletions = existing
127
131
  .filter(wt => wt.name.startsWith(namePrefix))
128
132
  .map(wt => ({ value: `${parts[0]} ${wt.name}`, label: wt.name }));
133
+
134
+ // Add "all" option for remove
135
+ if (parts[0] === "remove" && "all".startsWith(namePrefix)) {
136
+ nameCompletions.push({ value: "remove all", label: "all" });
137
+ }
138
+
139
+ return nameCompletions;
129
140
  } catch {
130
141
  return [];
131
142
  }
@@ -151,8 +162,8 @@ async function worktreeHandler(
151
162
  ` /${alias} switch <name> — switch into an existing worktree`,
152
163
  ` /${alias} return — switch back to the main project tree`,
153
164
  ` /${alias} list — list all worktrees`,
154
- ` /${alias} merge <branch> [target] — merge worktree into target branch`,
155
- ` /${alias} remove <name> — remove a worktree and its branch`,
165
+ ` /${alias} merge [name] [target] — merge worktree into target branch (auto-detects when inside a worktree)`,
166
+ ` /${alias} remove <name|all> — remove a worktree (or all) and its branch`,
156
167
  ].join("\n"),
157
168
  "info",
158
169
  );
@@ -169,41 +180,76 @@ async function worktreeHandler(
169
180
  return;
170
181
  }
171
182
 
172
- if (trimmed.startsWith("switch ")) {
173
- const name = trimmed.replace(/^switch\s+/, "").trim();
183
+ if (trimmed.startsWith("switch ") || trimmed.startsWith("create ")) {
184
+ const name = trimmed.replace(/^(?:switch|create)\s+/, "").trim();
174
185
  if (!name) {
175
- ctx.ui.notify(`Usage: /${alias} switch <name>`, "warning");
186
+ ctx.ui.notify(`Usage: /${alias} ${trimmed.split(" ")[0]} <name>`, "warning");
176
187
  return;
177
188
  }
178
- await handleSwitch(basePath, name, ctx);
189
+ // create and switch both do the same thing: switch if exists, create if not
190
+ const mainBase = originalCwd ?? basePath;
191
+ const existing = listWorktrees(mainBase);
192
+ if (existing.some(wt => wt.name === name)) {
193
+ await handleSwitch(basePath, name, ctx);
194
+ } else {
195
+ await handleCreate(basePath, name, ctx);
196
+ }
179
197
  return;
180
198
  }
181
199
 
182
- if (trimmed.startsWith("merge ")) {
183
- const mergeArgs = trimmed.replace(/^merge\s+/, "").trim().split(/\s+/);
184
- const name = mergeArgs[0] ?? "";
185
- const targetBranch = mergeArgs[1];
186
- if (!name) {
187
- ctx.ui.notify(`Usage: /${alias} merge <branch> [target]`, "warning");
200
+ if (trimmed === "merge" || trimmed.startsWith("merge ")) {
201
+ const mergeArgs = trimmed.replace(/^merge\s*/, "").trim().split(/\s+/).filter(Boolean);
202
+ const mainBase = originalCwd ?? basePath;
203
+ const activeWt = getActiveWorktreeName();
204
+
205
+ if (mergeArgs.length === 0) {
206
+ // Bare "/worktree merge" — only valid when inside a worktree
207
+ if (!activeWt) {
208
+ ctx.ui.notify(`Usage: /${alias} merge <name> [target]`, "warning");
209
+ return;
210
+ }
211
+ await handleMerge(mainBase, activeWt, ctx, pi, undefined);
188
212
  return;
189
213
  }
190
- const mainBase = originalCwd ?? basePath;
191
- await handleMerge(mainBase, name, ctx, pi, targetBranch);
214
+
215
+ const name = mergeArgs[0]!;
216
+ const targetBranch = mergeArgs[1];
217
+
218
+ // Check if 'name' is an actual worktree
219
+ const worktrees = listWorktrees(mainBase);
220
+ const isWorktree = worktrees.some(w => w.name === name);
221
+
222
+ if (isWorktree) {
223
+ await handleMerge(mainBase, name, ctx, pi, targetBranch);
224
+ } else if (activeWt) {
225
+ // Not a worktree name — user is in a worktree and gave the target branch
226
+ // e.g. "/worktree merge main" while inside worktree "new"
227
+ await handleMerge(mainBase, activeWt, ctx, pi, name);
228
+ } else {
229
+ ctx.ui.notify(`Worktree "${name}" not found. Run /${alias} list to see available worktrees.`, "warning");
230
+ }
192
231
  return;
193
232
  }
194
233
 
195
- if (trimmed.startsWith("remove ")) {
196
- const name = trimmed.replace(/^remove\s+/, "").trim();
234
+ if (trimmed === "remove" || trimmed.startsWith("remove ")) {
235
+ const name = trimmed.replace(/^remove\s*/, "").trim();
236
+ const mainBase = originalCwd ?? basePath;
237
+
238
+ if (name === "all") {
239
+ await handleRemoveAll(mainBase, ctx);
240
+ return;
241
+ }
242
+
197
243
  if (!name) {
198
- ctx.ui.notify(`Usage: /${alias} remove <name>`, "warning");
244
+ ctx.ui.notify(`Usage: /${alias} remove <name|all>`, "warning");
199
245
  return;
200
246
  }
201
- const mainBase = originalCwd ?? basePath;
247
+
202
248
  await handleRemove(mainBase, name, ctx);
203
249
  return;
204
250
  }
205
251
 
206
- const RESERVED = ["list", "return", "switch", "merge", "remove"];
252
+ const RESERVED = ["list", "return", "switch", "create", "merge", "remove"];
207
253
  if (RESERVED.includes(trimmed)) {
208
254
  ctx.ui.notify(`Usage: /${alias} ${trimmed}${trimmed === "list" || trimmed === "return" ? "" : " <name>"}`, "warning");
209
255
  return;
@@ -225,8 +271,20 @@ async function worktreeHandler(
225
271
  }
226
272
 
227
273
  export function registerWorktreeCommand(pi: ExtensionAPI): void {
274
+ // Restore worktree state after /reload.
275
+ // The module-level originalCwd resets to null when extensions are re-loaded,
276
+ // but process.cwd() is still inside the worktree. Detect this and recover.
277
+ if (!originalCwd) {
278
+ const cwd = process.cwd();
279
+ const marker = `${sep}.gsd${sep}worktrees${sep}`;
280
+ const markerIdx = cwd.indexOf(marker);
281
+ if (markerIdx !== -1) {
282
+ originalCwd = cwd.slice(0, markerIdx);
283
+ }
284
+ }
285
+
228
286
  pi.registerCommand("worktree", {
229
- description: "Git worktrees: /worktree <name> | list | merge <branch> [target] | remove <name>",
287
+ description: "Git worktrees (also /wt): /worktree <name> | list | merge | remove",
230
288
  getArgumentCompletions: worktreeCompletions,
231
289
 
232
290
  async handler(args: string, ctx: ExtensionCommandContext) {
@@ -236,7 +294,7 @@ export function registerWorktreeCommand(pi: ExtensionAPI): void {
236
294
 
237
295
  // /wt alias — same handler, same completions
238
296
  pi.registerCommand("wt", {
239
- description: "Alias for /worktree — Git worktrees: /wt <name> | list | merge | remove",
297
+ description: "Alias for /worktree",
240
298
  getArgumentCompletions: worktreeCompletions,
241
299
  async handler(args: string, ctx: ExtensionCommandContext) {
242
300
  await worktreeHandler(args, ctx, pi, "wt");
@@ -362,6 +420,7 @@ const DIM = "\x1b[2m";
362
420
  const RESET = "\x1b[0m";
363
421
  const CYAN = "\x1b[36m";
364
422
  const GREEN = "\x1b[32m";
423
+ const RED = "\x1b[31m";
365
424
  const YELLOW = "\x1b[33m";
366
425
  const WHITE = "\x1b[37m";
367
426
 
@@ -423,9 +482,11 @@ async function handleMerge(
423
482
  return;
424
483
  }
425
484
 
426
- // Gather merge context
427
- const diffSummary = diffWorktreeGSD(basePath, name);
428
- const fullDiff = getWorktreeGSDDiff(basePath, name);
485
+ // Gather merge context — full repo diff, not just .gsd/
486
+ const diffSummary = diffWorktreeAll(basePath, name);
487
+ const numstat = diffWorktreeNumstat(basePath, name);
488
+ const gsdDiff = getWorktreeGSDDiff(basePath, name);
489
+ const codeDiff = getWorktreeCodeDiff(basePath, name);
429
490
  const commitLog = getWorktreeLog(basePath, name);
430
491
 
431
492
  const totalChanges = diffSummary.added.length + diffSummary.modified.length + diffSummary.removed.length;
@@ -434,27 +495,48 @@ async function handleMerge(
434
495
  return;
435
496
  }
436
497
 
498
+ // Build a map of file → line stats for the preview
499
+ const statMap = new Map<string, FileLineStat>();
500
+ for (const s of numstat) statMap.set(s.file, s);
501
+
502
+ // Compute totals
503
+ let totalAdded = 0;
504
+ let totalRemoved = 0;
505
+ for (const s of numstat) { totalAdded += s.added; totalRemoved += s.removed; }
506
+
507
+ // Split files into code vs GSD for the preview
508
+ const isGSD = (f: string) => f.startsWith(".gsd/");
509
+ const codeChanges = diffSummary.added.filter(f => !isGSD(f)).length
510
+ + diffSummary.modified.filter(f => !isGSD(f)).length
511
+ + diffSummary.removed.filter(f => !isGSD(f)).length;
512
+ const gsdChanges = diffSummary.added.filter(isGSD).length
513
+ + diffSummary.modified.filter(isGSD).length
514
+ + diffSummary.removed.filter(isGSD).length;
515
+
516
+ // Format a file line with +/- stats
517
+ const formatFileLine = (prefix: string, file: string): string => {
518
+ const s = statMap.get(file);
519
+ const stat = s ? ` ${GREEN}+${s.added}${RESET} ${RED}-${s.removed}${RESET}` : "";
520
+ return ` ${prefix} ${file}${stat}`;
521
+ };
522
+
437
523
  // Preview confirmation before merge dispatch
438
524
  const previewLines = [
439
525
  `Merge worktree "${name}" → ${mainBranch}`,
440
526
  "",
441
- ` ${diffSummary.added.length} added · ${diffSummary.modified.length} modified · ${diffSummary.removed.length} removed`,
527
+ ` ${totalChanges} file${totalChanges === 1 ? "" : "s"} changed, ${GREEN}+${totalAdded}${RESET} ${RED}-${totalRemoved}${RESET} lines (${codeChanges} code, ${gsdChanges} GSD)`,
442
528
  ];
443
- if (diffSummary.added.length > 0) {
444
- previewLines.push("", " Added:");
445
- for (const f of diffSummary.added.slice(0, 10)) previewLines.push(` + ${f}`);
446
- if (diffSummary.added.length > 10) previewLines.push(` … and ${diffSummary.added.length - 10} more`);
447
- }
448
- if (diffSummary.modified.length > 0) {
449
- previewLines.push("", " Modified:");
450
- for (const f of diffSummary.modified.slice(0, 10)) previewLines.push(` ~ ${f}`);
451
- if (diffSummary.modified.length > 10) previewLines.push(` … and ${diffSummary.modified.length - 10} more`);
452
- }
453
- if (diffSummary.removed.length > 0) {
454
- previewLines.push("", " Removed:");
455
- for (const f of diffSummary.removed.slice(0, 10)) previewLines.push(` - ${f}`);
456
- if (diffSummary.removed.length > 10) previewLines.push(` … and ${diffSummary.removed.length - 10} more`);
457
- }
529
+
530
+ const appendFileList = (label: string, files: string[], prefix: string, limit = 10) => {
531
+ if (files.length === 0) return;
532
+ previewLines.push("", ` ${label}:`);
533
+ for (const f of files.slice(0, limit)) previewLines.push(formatFileLine(prefix, f));
534
+ if (files.length > limit) previewLines.push(` … and ${files.length - limit} more`);
535
+ };
536
+
537
+ appendFileList("Added", diffSummary.added, "+");
538
+ appendFileList("Modified", diffSummary.modified, "~");
539
+ appendFileList("Removed", diffSummary.removed, "-");
458
540
 
459
541
  const confirmed = await showConfirm(ctx, {
460
542
  title: "Worktree Merge",
@@ -467,20 +549,34 @@ async function handleMerge(
467
549
  return;
468
550
  }
469
551
 
552
+ // Switch to the main tree before dispatching the merge.
553
+ // The LLM needs to run git merge --squash from the main branch, and if
554
+ // it later removes the worktree, the agent's CWD must not be inside it.
555
+ if (originalCwd) {
556
+ const prevCwd = process.cwd();
557
+ process.chdir(basePath);
558
+ nudgeGitBranchCache(prevCwd);
559
+ originalCwd = null;
560
+ }
561
+
470
562
  // Format file lists for the prompt
471
563
  const formatFiles = (files: string[]) =>
472
564
  files.length > 0 ? files.map(f => `- \`${f}\``).join("\n") : "_(none)_";
473
565
 
474
566
  // Load and populate the merge prompt
567
+ const wtPath = worktreePath(basePath, name);
475
568
  const prompt = loadPrompt("worktree-merge", {
476
569
  worktreeName: name,
477
570
  worktreeBranch: branch,
478
571
  mainBranch,
572
+ mainTreePath: basePath,
573
+ worktreePath: wtPath,
479
574
  commitLog: commitLog || "(no commits)",
480
575
  addedFiles: formatFiles(diffSummary.added),
481
576
  modifiedFiles: formatFiles(diffSummary.modified),
482
577
  removedFiles: formatFiles(diffSummary.removed),
483
- fullDiff: fullDiff || "(no diff)",
578
+ gsdDiff: gsdDiff || "(no GSD artifact changes)",
579
+ codeDiff: codeDiff || "(no code changes)",
484
580
  });
485
581
 
486
582
  // Dispatch to the LLM
@@ -494,7 +590,7 @@ async function handleMerge(
494
590
  );
495
591
 
496
592
  ctx.ui.notify(
497
- `Merge helper started for worktree "${name}" (${totalChanges} GSD artifact change${totalChanges === 1 ? "" : "s"}).`,
593
+ `Merge helper started for worktree "${name}" (${codeChanges} code + ${gsdChanges} GSD artifact change${totalChanges === 1 ? "" : "s"}).`,
498
594
  "info",
499
595
  );
500
596
  } catch (error) {
@@ -510,6 +606,26 @@ async function handleRemove(
510
606
  ): Promise<void> {
511
607
  try {
512
608
  const mainBase = originalCwd ?? basePath;
609
+
610
+ // Validate the worktree exists before attempting removal
611
+ const worktrees = listWorktrees(mainBase);
612
+ const wt = worktrees.find(w => w.name === name);
613
+ if (!wt) {
614
+ ctx.ui.notify(`Worktree "${name}" not found. Run /worktree list to see available worktrees.`, "warning");
615
+ return;
616
+ }
617
+
618
+ const confirmed = await showConfirm(ctx, {
619
+ title: "Remove Worktree",
620
+ message: `Remove worktree "${name}" and delete branch ${wt.branch}?`,
621
+ confirmLabel: "Remove",
622
+ declineLabel: "Cancel",
623
+ });
624
+ if (!confirmed) {
625
+ ctx.ui.notify("Cancelled.", "info");
626
+ return;
627
+ }
628
+
513
629
  const prevCwd = process.cwd();
514
630
  removeWorktree(mainBase, name, { deleteBranch: true });
515
631
 
@@ -525,3 +641,57 @@ async function handleRemove(
525
641
  ctx.ui.notify(`Failed to remove worktree: ${msg}`, "error");
526
642
  }
527
643
  }
644
+
645
+ async function handleRemoveAll(
646
+ basePath: string,
647
+ ctx: ExtensionCommandContext,
648
+ ): Promise<void> {
649
+ try {
650
+ const mainBase = originalCwd ?? basePath;
651
+ const worktrees = listWorktrees(mainBase);
652
+
653
+ if (worktrees.length === 0) {
654
+ ctx.ui.notify("No worktrees to remove.", "info");
655
+ return;
656
+ }
657
+
658
+ const names = worktrees.map(w => w.name);
659
+ const confirmed = await showConfirm(ctx, {
660
+ title: "Remove All Worktrees",
661
+ message: `This will remove ${worktrees.length} worktree${worktrees.length === 1 ? "" : "s"} and delete their branches:\n\n${names.map(n => ` • ${n}`).join("\n")}`,
662
+ confirmLabel: "Remove all",
663
+ declineLabel: "Cancel",
664
+ });
665
+ if (!confirmed) {
666
+ ctx.ui.notify("Cancelled.", "info");
667
+ return;
668
+ }
669
+
670
+ const prevCwd = process.cwd();
671
+ const removed: string[] = [];
672
+ const failed: string[] = [];
673
+
674
+ for (const wt of worktrees) {
675
+ try {
676
+ removeWorktree(mainBase, wt.name, { deleteBranch: true });
677
+ removed.push(wt.name);
678
+ } catch {
679
+ failed.push(wt.name);
680
+ }
681
+ }
682
+
683
+ // If we were in a worktree that got removed, clear tracking
684
+ if (originalCwd && process.cwd() !== prevCwd) {
685
+ nudgeGitBranchCache(prevCwd);
686
+ originalCwd = null;
687
+ }
688
+
689
+ const lines: string[] = [];
690
+ if (removed.length > 0) lines.push(`Removed: ${removed.join(", ")}`);
691
+ if (failed.length > 0) lines.push(`Failed: ${failed.join(", ")}`);
692
+ ctx.ui.notify(lines.join("\n"), failed.length > 0 ? "warning" : "info");
693
+ } catch (error) {
694
+ const msg = error instanceof Error ? error.message : String(error);
695
+ ctx.ui.notify(`Failed to remove worktrees: ${msg}`, "error");
696
+ }
697
+ }
@@ -28,6 +28,13 @@ export interface WorktreeInfo {
28
28
  exists: boolean;
29
29
  }
30
30
 
31
+ /** Per-file line change stats from git diff --numstat. */
32
+ export interface FileLineStat {
33
+ file: string;
34
+ added: number;
35
+ removed: number;
36
+ }
37
+
31
38
  export interface WorktreeDiffSummary {
32
39
  /** Files only in the worktree .gsd/ (new artifacts) */
33
40
  added: string[];
@@ -109,6 +116,18 @@ export function createWorktree(basePath: string, name: string): WorktreeInfo {
109
116
  const mainBranch = getMainBranch(basePath);
110
117
 
111
118
  if (branchExists) {
119
+ // Check if the branch is actively used by an existing worktree.
120
+ // `git branch -f` will fail if the branch is checked out somewhere.
121
+ const worktreeUsing = runGit(basePath, ["worktree", "list", "--porcelain"], { allowFailure: true });
122
+ const branchInUse = worktreeUsing.includes(`branch refs/heads/${branch}`);
123
+
124
+ if (branchInUse) {
125
+ throw new Error(
126
+ `Branch "${branch}" is already in use by another worktree. ` +
127
+ `Remove the existing worktree first with /worktree remove ${name}.`,
128
+ );
129
+ }
130
+
112
131
  // Reset the stale branch to current main, then attach worktree to it
113
132
  runGit(basePath, ["branch", "-f", branch, mainBranch]);
114
133
  runGit(basePath, ["worktree", "add", wtPath, branch]);
@@ -212,19 +231,17 @@ export function removeWorktree(
212
231
  }
213
232
  }
214
233
 
215
- /**
216
- * Diff the .gsd/ directory between the worktree branch and main branch.
217
- * Returns a summary of added, modified, and removed GSD artifacts.
218
- */
219
- export function diffWorktreeGSD(basePath: string, name: string): WorktreeDiffSummary {
220
- const branch = worktreeBranchName(name);
221
- const mainBranch = getMainBranch(basePath);
234
+ /** Paths to skip in all worktree diffs (internal/runtime artifacts). */
235
+ const SKIP_PATHS = [".gsd/worktrees/", ".gsd/runtime/", ".gsd/activity/"];
236
+ const SKIP_EXACT = [".gsd/STATE.md", ".gsd/auto.lock", ".gsd/metrics.json"];
222
237
 
223
- // Use git diff to compare .gsd/ between branches
224
- const diffOutput = runGit(basePath, [
225
- "diff", "--name-status", `${mainBranch}...${branch}`, "--", ".gsd/",
226
- ], { allowFailure: true });
238
+ function shouldSkipPath(filePath: string): boolean {
239
+ if (SKIP_PATHS.some(p => filePath.startsWith(p))) return true;
240
+ if (SKIP_EXACT.includes(filePath)) return true;
241
+ return false;
242
+ }
227
243
 
244
+ function parseDiffNameStatus(diffOutput: string): WorktreeDiffSummary {
228
245
  const added: string[] = [];
229
246
  const modified: string[] = [];
230
247
  const removed: string[] = [];
@@ -235,11 +252,7 @@ export function diffWorktreeGSD(basePath: string, name: string): WorktreeDiffSum
235
252
  const [status, ...pathParts] = line.split("\t");
236
253
  const filePath = pathParts.join("\t");
237
254
 
238
- // Skip worktree-internal paths (e.g. .gsd/worktrees/, .gsd/runtime/)
239
- if (filePath.startsWith(".gsd/worktrees/") || filePath.startsWith(".gsd/runtime/")) continue;
240
- // Skip gitignored runtime files
241
- if (filePath === ".gsd/STATE.md" || filePath === ".gsd/auto.lock" || filePath === ".gsd/metrics.json") continue;
242
- if (filePath.startsWith(".gsd/activity/")) continue;
255
+ if (shouldSkipPath(filePath)) continue;
243
256
 
244
257
  switch (status) {
245
258
  case "A": added.push(filePath); break;
@@ -256,6 +269,68 @@ export function diffWorktreeGSD(basePath: string, name: string): WorktreeDiffSum
256
269
  return { added, modified, removed };
257
270
  }
258
271
 
272
+ /**
273
+ * Diff the .gsd/ directory between the worktree branch and main branch.
274
+ * Returns a summary of added, modified, and removed GSD artifacts.
275
+ */
276
+ export function diffWorktreeGSD(basePath: string, name: string): WorktreeDiffSummary {
277
+ const branch = worktreeBranchName(name);
278
+ const mainBranch = getMainBranch(basePath);
279
+
280
+ const diffOutput = runGit(basePath, [
281
+ "diff", "--name-status", `${mainBranch}...${branch}`, "--", ".gsd/",
282
+ ], { allowFailure: true });
283
+
284
+ return parseDiffNameStatus(diffOutput);
285
+ }
286
+
287
+ /**
288
+ * Diff ALL files between the worktree branch and main branch.
289
+ * Returns a summary of added, modified, and removed files across the entire repo.
290
+ */
291
+ /**
292
+ * Diff ALL files between the worktree branch and main branch.
293
+ * Uses direct diff (no merge-base) to show what will actually change
294
+ * on main when the merge is applied. If both branches have identical
295
+ * content, this correctly returns an empty diff.
296
+ */
297
+ export function diffWorktreeAll(basePath: string, name: string): WorktreeDiffSummary {
298
+ const branch = worktreeBranchName(name);
299
+ const mainBranch = getMainBranch(basePath);
300
+
301
+ const diffOutput = runGit(basePath, [
302
+ "diff", "--name-status", mainBranch, branch,
303
+ ], { allowFailure: true });
304
+
305
+ return parseDiffNameStatus(diffOutput);
306
+ }
307
+
308
+ /**
309
+ * Get per-file line addition/deletion stats for what will change on main.
310
+ * Uses direct diff (not merge-base) so the preview matches the actual merge outcome.
311
+ */
312
+ export function diffWorktreeNumstat(basePath: string, name: string): FileLineStat[] {
313
+ const branch = worktreeBranchName(name);
314
+ const mainBranch = getMainBranch(basePath);
315
+
316
+ const raw = runGit(basePath, [
317
+ "diff", "--numstat", mainBranch, branch,
318
+ ], { allowFailure: true });
319
+
320
+ if (!raw.trim()) return [];
321
+
322
+ const stats: FileLineStat[] = [];
323
+ for (const line of raw.split("\n").filter(Boolean)) {
324
+ const [a, r, ...pathParts] = line.split("\t");
325
+ const file = pathParts.join("\t");
326
+ if (shouldSkipPath(file)) continue;
327
+ const added = a === "-" ? 0 : parseInt(a ?? "0", 10);
328
+ const removed = r === "-" ? 0 : parseInt(r ?? "0", 10);
329
+ stats.push({ file, added, removed });
330
+ }
331
+ return stats;
332
+ }
333
+
259
334
  /**
260
335
  * Get the full diff content for .gsd/ between the worktree branch and main.
261
336
  * Returns the raw unified diff for LLM consumption.
@@ -269,6 +344,21 @@ export function getWorktreeGSDDiff(basePath: string, name: string): string {
269
344
  ], { allowFailure: true });
270
345
  }
271
346
 
347
+ /**
348
+ * Get the full diff content for non-.gsd/ files between the worktree branch and main.
349
+ * Returns the raw unified diff for LLM consumption.
350
+ */
351
+ export function getWorktreeCodeDiff(basePath: string, name: string): string {
352
+ const branch = worktreeBranchName(name);
353
+ const mainBranch = getMainBranch(basePath);
354
+
355
+ // Get full diff, then exclude .gsd/ paths
356
+ // We use pathspec magic to exclude .gsd/
357
+ return runGit(basePath, [
358
+ "diff", `${mainBranch}...${branch}`, "--", ".", ":(exclude).gsd/",
359
+ ], { allowFailure: true });
360
+ }
361
+
272
362
  /**
273
363
  * Get commit log for the worktree branch since it diverged from main.
274
364
  */