gsd-pi 2.12.0 → 2.13.0

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 (62) hide show
  1. package/dist/cli.js +18 -1
  2. package/dist/resource-loader.d.ts +2 -0
  3. package/dist/resource-loader.js +36 -1
  4. package/dist/resources/extensions/gsd/auto-worktree.ts +509 -0
  5. package/dist/resources/extensions/gsd/auto.ts +222 -11
  6. package/dist/resources/extensions/gsd/doctor.ts +195 -1
  7. package/dist/resources/extensions/gsd/git-self-heal.ts +198 -0
  8. package/dist/resources/extensions/gsd/git-service.ts +11 -0
  9. package/dist/resources/extensions/gsd/preferences.ts +17 -1
  10. package/dist/resources/extensions/gsd/prompts/system.md +32 -29
  11. package/dist/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
  12. package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
  13. package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
  14. package/dist/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
  15. package/dist/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
  16. package/dist/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
  17. package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
  18. package/dist/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
  19. package/dist/resources/extensions/gsd/worktree-manager.ts +6 -4
  20. package/dist/resources/extensions/search-the-web/native-search.ts +15 -10
  21. package/package.json +1 -1
  22. package/packages/pi-coding-agent/dist/cli/args.js +1 -1
  23. package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
  24. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +4 -1
  25. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  26. package/packages/pi-coding-agent/dist/core/extensions/runner.js +2 -1
  27. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  28. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +5 -0
  29. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  30. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  31. package/packages/pi-coding-agent/dist/core/sdk.js +3 -3
  32. package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
  33. package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
  34. package/packages/pi-coding-agent/dist/core/system-prompt.js +6 -0
  35. package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
  36. package/packages/pi-coding-agent/src/cli/args.ts +1 -1
  37. package/packages/pi-coding-agent/src/core/extensions/runner.ts +2 -1
  38. package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -0
  39. package/packages/pi-coding-agent/src/core/sdk.ts +3 -3
  40. package/packages/pi-coding-agent/src/core/system-prompt.ts +9 -0
  41. package/packages/pi-tui/dist/components/editor.d.ts +11 -0
  42. package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
  43. package/packages/pi-tui/dist/components/editor.js +64 -6
  44. package/packages/pi-tui/dist/components/editor.js.map +1 -1
  45. package/packages/pi-tui/src/components/editor.ts +71 -6
  46. package/src/resources/extensions/gsd/auto-worktree.ts +509 -0
  47. package/src/resources/extensions/gsd/auto.ts +222 -11
  48. package/src/resources/extensions/gsd/doctor.ts +195 -1
  49. package/src/resources/extensions/gsd/git-self-heal.ts +198 -0
  50. package/src/resources/extensions/gsd/git-service.ts +11 -0
  51. package/src/resources/extensions/gsd/preferences.ts +17 -1
  52. package/src/resources/extensions/gsd/prompts/system.md +32 -29
  53. package/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
  54. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
  55. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
  56. package/src/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
  57. package/src/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
  58. package/src/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
  59. package/src/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
  60. package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
  61. package/src/resources/extensions/gsd/worktree-manager.ts +6 -4
  62. package/src/resources/extensions/search-the-web/native-search.ts +15 -10
@@ -150,6 +150,12 @@ export class Editor implements Component, Focusable {
150
150
  private autocompletePrefix: string = "";
151
151
  private autocompleteMaxVisible: number = 5;
152
152
 
153
+ // Debounce for @ file autocomplete to prevent blocking the event loop
154
+ // with synchronous fuzzyFind calls on every keystroke
155
+ private autocompleteDebounceTimer: ReturnType<typeof setTimeout> | null = null;
156
+ private lastAutocompleteLookupPrefix: string | null = null;
157
+ private static readonly AUTOCOMPLETE_DEBOUNCE_MS = 150;
158
+
153
159
  // Paste tracking for large pastes
154
160
  private pastes: Map<number, string> = new Map();
155
161
  private pasteCounter: number = 0;
@@ -965,9 +971,10 @@ export class Editor implements Component, Focusable {
965
971
  if (this.isInSlashCommandContext(textBeforeCursor)) {
966
972
  this.tryTriggerAutocomplete();
967
973
  }
968
- // Check if we're in an @ file reference context
974
+ // Check if we're in an @ file reference context (debounce to avoid
975
+ // blocking the event loop with synchronous fuzzyFind on every keystroke)
969
976
  else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
970
- this.tryTriggerAutocomplete();
977
+ this.debouncedTriggerAutocomplete();
971
978
  }
972
979
  }
973
980
  } else {
@@ -975,6 +982,23 @@ export class Editor implements Component, Focusable {
975
982
  }
976
983
  }
977
984
 
985
+ /**
986
+ * Debounced version of tryTriggerAutocomplete for @ file reference context.
987
+ * Prevents synchronous fuzzyFind calls from blocking the event loop on every keystroke.
988
+ */
989
+ private debouncedTriggerAutocomplete(): void {
990
+ if (this.autocompleteDebounceTimer) {
991
+ clearTimeout(this.autocompleteDebounceTimer);
992
+ this.autocompleteDebounceTimer = null;
993
+ }
994
+
995
+ this.autocompleteDebounceTimer = setTimeout(() => {
996
+ this.autocompleteDebounceTimer = null;
997
+ this.tryTriggerAutocomplete();
998
+ this.tui.requestRender();
999
+ }, Editor.AUTOCOMPLETE_DEBOUNCE_MS);
1000
+ }
1001
+
978
1002
  private handlePaste(pastedText: string): void {
979
1003
  this.historyIndex = -1; // Exit history browsing mode
980
1004
  this.lastAction = null;
@@ -1133,9 +1157,9 @@ export class Editor implements Component, Focusable {
1133
1157
  if (this.isInSlashCommandContext(textBeforeCursor)) {
1134
1158
  this.tryTriggerAutocomplete();
1135
1159
  }
1136
- // @ file reference context
1160
+ // @ file reference context (debounced to avoid blocking event loop)
1137
1161
  else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
1138
- this.tryTriggerAutocomplete();
1162
+ this.debouncedTriggerAutocomplete();
1139
1163
  }
1140
1164
  }
1141
1165
  }
@@ -1440,9 +1464,9 @@ export class Editor implements Component, Focusable {
1440
1464
  if (this.isInSlashCommandContext(textBeforeCursor)) {
1441
1465
  this.tryTriggerAutocomplete();
1442
1466
  }
1443
- // @ file reference context
1467
+ // @ file reference context (debounced to avoid blocking event loop)
1444
1468
  else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
1445
- this.tryTriggerAutocomplete();
1469
+ this.debouncedTriggerAutocomplete();
1446
1470
  }
1447
1471
  }
1448
1472
  }
@@ -2020,6 +2044,15 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
2020
2044
  this.autocompleteState = null;
2021
2045
  this.autocompleteList = undefined;
2022
2046
  this.autocompletePrefix = "";
2047
+ this.clearAutocompleteDebounce();
2048
+ }
2049
+
2050
+ private clearAutocompleteDebounce(): void {
2051
+ if (this.autocompleteDebounceTimer) {
2052
+ clearTimeout(this.autocompleteDebounceTimer);
2053
+ this.autocompleteDebounceTimer = null;
2054
+ }
2055
+ this.lastAutocompleteLookupPrefix = null;
2023
2056
  }
2024
2057
 
2025
2058
  public isShowingAutocomplete(): boolean {
@@ -2034,6 +2067,38 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
2034
2067
  return;
2035
2068
  }
2036
2069
 
2070
+ // Check if we're in an @ file reference context — these trigger expensive
2071
+ // synchronous fuzzyFind calls that block the event loop. Debounce them so
2072
+ // rapid typing doesn't cascade into dozens of blocking searches.
2073
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
2074
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
2075
+ if (this.autocompletePrefix.startsWith("@") || textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
2076
+ this.debouncedUpdateAutocompleteSuggestions();
2077
+ return;
2078
+ }
2079
+
2080
+ this.applyAutocompleteSuggestions();
2081
+ }
2082
+
2083
+ private debouncedUpdateAutocompleteSuggestions(): void {
2084
+ // Clear any pending debounce
2085
+ if (this.autocompleteDebounceTimer) {
2086
+ clearTimeout(this.autocompleteDebounceTimer);
2087
+ this.autocompleteDebounceTimer = null;
2088
+ }
2089
+
2090
+ this.autocompleteDebounceTimer = setTimeout(() => {
2091
+ this.autocompleteDebounceTimer = null;
2092
+ // Guard: autocomplete may have been cancelled during debounce wait
2093
+ if (!this.autocompleteState || !this.autocompleteProvider) return;
2094
+ this.applyAutocompleteSuggestions();
2095
+ this.tui.requestRender();
2096
+ }, Editor.AUTOCOMPLETE_DEBOUNCE_MS);
2097
+ }
2098
+
2099
+ private applyAutocompleteSuggestions(): void {
2100
+ if (!this.autocompleteProvider) return;
2101
+
2037
2102
  const suggestions = this.autocompleteProvider.getSuggestions(
2038
2103
  this.state.lines,
2039
2104
  this.state.cursorLine,
@@ -0,0 +1,509 @@
1
+ /**
2
+ * GSD Auto-Worktree -- lifecycle management for auto-mode worktrees.
3
+ *
4
+ * Auto-mode creates worktrees with `milestone/<MID>` branches (distinct from
5
+ * manual `/worktree` which uses `worktree/<name>` branches). This module
6
+ * manages create, enter, detect, and teardown for auto-mode worktrees.
7
+ */
8
+
9
+ import { existsSync, readFileSync, realpathSync, utimesSync } from "node:fs";
10
+ import { join, resolve } from "node:path";
11
+ import { execSync } from "node:child_process";
12
+ import {
13
+ createWorktree,
14
+ removeWorktree,
15
+ worktreePath,
16
+ } from "./worktree-manager.js";
17
+ import {
18
+ detectWorktreeName,
19
+ getSliceBranchName,
20
+ } from "./worktree.js";
21
+ import {
22
+ MergeConflictError,
23
+ inferCommitType,
24
+ } from "./git-service.js";
25
+ import type { MergeSliceResult } from "./git-service.js";
26
+ import { recoverCheckout, withMergeHeal } from "./git-self-heal.js";
27
+ import {
28
+ nativeBranchExists,
29
+ nativeCommitCountBetween,
30
+ } from "./native-git-bridge.js";
31
+ import { parseRoadmap } from "./files.js";
32
+ import { loadEffectiveGSDPreferences } from "./preferences.js";
33
+
34
+ // ─── Module State ──────────────────────────────────────────────────────────
35
+
36
+ /** Original project root before chdir into auto-worktree. */
37
+ let originalBase: string | null = null;
38
+
39
+ // ─── Isolation Resolver ────────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Determine whether auto-mode should use worktree isolation.
43
+ *
44
+ * Resolution order:
45
+ * 1. Explicit git.isolation preference -> return (isolation === "worktree")
46
+ * 2. Legacy detection: if gsd branches exist -> return false (branch mode)
47
+ * 3. Default: return true (worktree mode for new projects)
48
+ */
49
+ export function shouldUseWorktreeIsolation(basePath: string, overridePrefs?: { isolation?: string }): boolean {
50
+ const prefs = overridePrefs ?? loadEffectiveGSDPreferences()?.preferences?.git;
51
+ if (prefs?.isolation) {
52
+ return prefs.isolation === "worktree";
53
+ }
54
+
55
+ // Legacy detection: check for existing gsd/*/* branches (branch-per-slice pattern)
56
+ try {
57
+ const output = execSync("git branch --list 'gsd/*/*'", {
58
+ cwd: basePath,
59
+ stdio: ["ignore", "pipe", "pipe"],
60
+ encoding: "utf-8",
61
+ }).trim();
62
+ if (output) return false; // Legacy branch-per-slice project
63
+ } catch {
64
+ // If git command fails, default to worktree
65
+ }
66
+
67
+ return true; // New project default
68
+ }
69
+
70
+ /**
71
+ * Resolve the merge_to_main preference value.
72
+ * Returns "milestone" (default) or "slice".
73
+ */
74
+ export function getMergeToMainMode(): "milestone" | "slice" {
75
+ const prefs = loadEffectiveGSDPreferences()?.preferences?.git;
76
+ return prefs?.merge_to_main ?? "milestone";
77
+ }
78
+
79
+ // ─── Git Helpers (local, mirrors worktree-command.ts pattern) ──────────────
80
+
81
+ function resolveGitHeadPath(dir: string): string | null {
82
+ const gitPath = join(dir, ".git");
83
+ if (!existsSync(gitPath)) return null;
84
+ try {
85
+ const content = readFileSync(gitPath, "utf8").trim();
86
+ if (content.startsWith("gitdir: ")) {
87
+ const gitDir = resolve(dir, content.slice(8));
88
+ const headPath = join(gitDir, "HEAD");
89
+ return existsSync(headPath) ? headPath : null;
90
+ }
91
+ const headPath = join(dir, ".git", "HEAD");
92
+ return existsSync(headPath) ? headPath : null;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Nudge pi's FooterDataProvider to re-read the git branch after chdir.
100
+ * Touches HEAD in both old and new cwd to fire the fs watcher.
101
+ */
102
+ function nudgeGitBranchCache(previousCwd: string): void {
103
+ const now = new Date();
104
+ for (const dir of [previousCwd, process.cwd()]) {
105
+ try {
106
+ const headPath = resolveGitHeadPath(dir);
107
+ if (headPath) utimesSync(headPath, now, now);
108
+ } catch {
109
+ // Best-effort
110
+ }
111
+ }
112
+ }
113
+
114
+ function getCurrentBranch(cwd: string): string {
115
+ try {
116
+ return execSync("git branch --show-current", {
117
+ cwd,
118
+ stdio: ["ignore", "pipe", "pipe"],
119
+ encoding: "utf-8",
120
+ }).trim();
121
+ } catch {
122
+ return "";
123
+ }
124
+ }
125
+
126
+ // ─── Auto-Worktree Branch Naming ───────────────────────────────────────────
127
+
128
+ export function autoWorktreeBranch(milestoneId: string): string {
129
+ return `milestone/${milestoneId}`;
130
+ }
131
+
132
+ // ─── Public API ────────────────────────────────────────────────────────────
133
+
134
+ /**
135
+ * Create a new auto-worktree for a milestone, chdir into it, and store
136
+ * the original base path for later teardown.
137
+ *
138
+ * Atomic: chdir + originalBase update happen in the same try block
139
+ * to prevent split-brain.
140
+ */
141
+ export function createAutoWorktree(basePath: string, milestoneId: string): string {
142
+ const branch = autoWorktreeBranch(milestoneId);
143
+ const info = createWorktree(basePath, milestoneId, { branch });
144
+ const previousCwd = process.cwd();
145
+
146
+ try {
147
+ process.chdir(info.path);
148
+ originalBase = basePath;
149
+ } catch (err) {
150
+ // If chdir fails, the worktree was created but we couldn't enter it.
151
+ // Don't store originalBase -- caller can retry or clean up.
152
+ throw new Error(
153
+ `Auto-worktree created at ${info.path} but chdir failed: ${err instanceof Error ? err.message : String(err)}`,
154
+ );
155
+ }
156
+
157
+ nudgeGitBranchCache(previousCwd);
158
+ return info.path;
159
+ }
160
+
161
+ /**
162
+ * Teardown an auto-worktree: chdir back to original base, then remove
163
+ * the worktree and its branch.
164
+ */
165
+ export function teardownAutoWorktree(originalBasePath: string, milestoneId: string): void {
166
+ const branch = autoWorktreeBranch(milestoneId);
167
+ const previousCwd = process.cwd();
168
+
169
+ try {
170
+ process.chdir(originalBasePath);
171
+ originalBase = null;
172
+ } catch (err) {
173
+ throw new Error(
174
+ `Failed to chdir back to ${originalBasePath} during teardown: ${err instanceof Error ? err.message : String(err)}`,
175
+ );
176
+ }
177
+
178
+ nudgeGitBranchCache(previousCwd);
179
+ removeWorktree(originalBasePath, milestoneId, { branch });
180
+ }
181
+
182
+ /**
183
+ * Detect if the process is currently inside an auto-worktree.
184
+ * Checks both module state and git branch prefix.
185
+ */
186
+ export function isInAutoWorktree(basePath: string): boolean {
187
+ if (!originalBase) return false;
188
+ const cwd = process.cwd();
189
+ const resolvedBase = existsSync(basePath) ? realpathSync(basePath) : basePath;
190
+ const wtDir = join(resolvedBase, ".gsd", "worktrees");
191
+ if (!cwd.startsWith(wtDir)) return false;
192
+ const branch = getCurrentBranch(cwd);
193
+ return branch.startsWith("milestone/");
194
+ }
195
+
196
+ /**
197
+ * Get the filesystem path for an auto-worktree, or null if it doesn't exist.
198
+ */
199
+ export function getAutoWorktreePath(basePath: string, milestoneId: string): string | null {
200
+ const p = worktreePath(basePath, milestoneId);
201
+ return existsSync(p) ? p : null;
202
+ }
203
+
204
+ /**
205
+ * Enter an existing auto-worktree (chdir into it, store originalBase).
206
+ * Use for resume -- the worktree already exists from a prior create.
207
+ *
208
+ * Atomic: chdir + originalBase update in same try block.
209
+ */
210
+ export function enterAutoWorktree(basePath: string, milestoneId: string): string {
211
+ const p = worktreePath(basePath, milestoneId);
212
+ if (!existsSync(p)) {
213
+ throw new Error(`Auto-worktree for ${milestoneId} does not exist at ${p}`);
214
+ }
215
+
216
+ const previousCwd = process.cwd();
217
+
218
+ try {
219
+ process.chdir(p);
220
+ originalBase = basePath;
221
+ } catch (err) {
222
+ throw new Error(
223
+ `Failed to enter auto-worktree at ${p}: ${err instanceof Error ? err.message : String(err)}`,
224
+ );
225
+ }
226
+
227
+ nudgeGitBranchCache(previousCwd);
228
+ return p;
229
+ }
230
+
231
+ /**
232
+ * Get the original project root stored when entering an auto-worktree.
233
+ * Returns null if not currently in an auto-worktree.
234
+ */
235
+ export function getAutoWorktreeOriginalBase(): string | null {
236
+ return originalBase;
237
+ }
238
+
239
+ // ─── Merge Slice -> Milestone ───────────────────────────────────────────────
240
+
241
+ /**
242
+ * Merge a completed slice branch into the milestone branch via `--no-ff`.
243
+ *
244
+ * Worktree-mode merge: `.gsd/` is local to the worktree (not tracked in
245
+ * git), so there are zero `.gsd/` conflict resolution concerns. No runtime
246
+ * exclusion untracking, no `--theirs` checkout, no snapshot creation.
247
+ *
248
+ * On conflict: throws MergeConflictError with conflicted file list.
249
+ * On success: deletes the slice branch and returns MergeSliceResult.
250
+ */
251
+ export function mergeSliceToMilestone(
252
+ basePath: string,
253
+ milestoneId: string,
254
+ sliceId: string,
255
+ sliceTitle: string,
256
+ ): MergeSliceResult {
257
+ if (!isInAutoWorktree(basePath)) {
258
+ throw new Error("mergeSliceToMilestone called outside auto-worktree");
259
+ }
260
+
261
+ const cwd = process.cwd();
262
+ const milestoneBranch = autoWorktreeBranch(milestoneId);
263
+ const worktreeName = detectWorktreeName(cwd);
264
+ const sliceBranch = getSliceBranchName(milestoneId, sliceId, worktreeName);
265
+
266
+ // Verify slice branch exists
267
+ if (!nativeBranchExists(cwd, sliceBranch)) {
268
+ throw new Error(`Slice branch "${sliceBranch}" does not exist`);
269
+ }
270
+
271
+ // Verify slice has commits ahead of milestone branch
272
+ const commitCount = nativeCommitCountBetween(cwd, milestoneBranch, sliceBranch);
273
+ if (commitCount === 0) {
274
+ throw new Error(
275
+ `Slice branch "${sliceBranch}" has no commits ahead of "${milestoneBranch}"`,
276
+ );
277
+ }
278
+
279
+ // Checkout milestone branch (with self-healing reset)
280
+ recoverCheckout(cwd, milestoneBranch);
281
+
282
+ // Build rich commit message (replicates GitServiceImpl.buildRichCommitMessage format)
283
+ const commitType = inferCommitType(sliceTitle);
284
+ const subject = `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`;
285
+
286
+ let message = subject;
287
+ try {
288
+ const logOutput = execSync(
289
+ `git log --oneline --format=%s ${milestoneBranch}..${sliceBranch}`,
290
+ { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" },
291
+ ).trim();
292
+
293
+ if (logOutput) {
294
+ const subjects = logOutput.split("\n").filter(Boolean);
295
+ const MAX_ENTRIES = 20;
296
+ const truncated = subjects.length > MAX_ENTRIES;
297
+ const displayed = truncated ? subjects.slice(0, MAX_ENTRIES) : subjects;
298
+ const taskLines = displayed.map(s => `- ${s}`).join("\n");
299
+ const truncationLine = truncated
300
+ ? `\n- ... and ${subjects.length - MAX_ENTRIES} more`
301
+ : "";
302
+ message = `${subject}\n\nTasks:\n${taskLines}${truncationLine}\n\nBranch: ${sliceBranch}`;
303
+ }
304
+ } catch {
305
+ // Fall back to subject-only message
306
+ }
307
+
308
+ // Merge --no-ff (with self-healing retry for transient failures)
309
+ try {
310
+ withMergeHeal(cwd, () => {
311
+ execSync(`git merge --no-ff -m "${message.replace(/"/g, '\\"')}" ${sliceBranch}`, {
312
+ cwd,
313
+ stdio: ["ignore", "pipe", "pipe"],
314
+ encoding: "utf-8",
315
+ });
316
+ });
317
+ } catch (err) {
318
+ if (err instanceof MergeConflictError) {
319
+ // Re-throw with correct branch context
320
+ throw new MergeConflictError(
321
+ err.conflictedFiles,
322
+ err.strategy,
323
+ sliceBranch,
324
+ milestoneBranch,
325
+ );
326
+ }
327
+ throw err;
328
+ }
329
+
330
+ // Delete slice branch
331
+ let deletedBranch = false;
332
+ try {
333
+ execSync(`git branch -d ${sliceBranch}`, {
334
+ cwd,
335
+ stdio: ["ignore", "pipe", "pipe"],
336
+ encoding: "utf-8",
337
+ });
338
+ deletedBranch = true;
339
+ } catch {
340
+ // Branch deletion is best-effort
341
+ }
342
+
343
+ return {
344
+ branch: sliceBranch,
345
+ mergedCommitMessage: message,
346
+ deletedBranch,
347
+ };
348
+ }
349
+
350
+ // ─── Merge Milestone -> Main ───────────────────────────────────────────────
351
+
352
+ /**
353
+ * Auto-commit any dirty (uncommitted) state in the given directory.
354
+ * Returns true if a commit was made, false if working tree was clean.
355
+ */
356
+ function autoCommitDirtyState(cwd: string): boolean {
357
+ try {
358
+ const status = execSync("git status --porcelain", {
359
+ cwd,
360
+ stdio: ["ignore", "pipe", "pipe"],
361
+ encoding: "utf-8",
362
+ }).trim();
363
+ if (!status) return false;
364
+ execSync('git add -A && git commit -m "chore: auto-commit before milestone merge"', {
365
+ cwd,
366
+ stdio: ["ignore", "pipe", "pipe"],
367
+ encoding: "utf-8",
368
+ });
369
+ return true;
370
+ } catch {
371
+ return false;
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Squash-merge the milestone branch into main with a rich commit message
377
+ * listing all completed slices, then tear down the worktree.
378
+ *
379
+ * Sequence:
380
+ * 1. Auto-commit dirty worktree state
381
+ * 2. chdir to originalBasePath
382
+ * 3. git checkout main
383
+ * 4. git merge --squash milestone/<MID>
384
+ * 5. git commit with rich message
385
+ * 6. Auto-push if enabled
386
+ * 7. Delete milestone branch
387
+ * 8. Remove worktree directory
388
+ * 9. Clear originalBase
389
+ *
390
+ * On merge conflict: throws MergeConflictError.
391
+ * On "nothing to commit" after squash: handles gracefully (no error).
392
+ */
393
+ export function mergeMilestoneToMain(
394
+ originalBasePath_: string,
395
+ milestoneId: string,
396
+ roadmapContent: string,
397
+ ): { commitMessage: string; pushed: boolean } {
398
+ const worktreeCwd = process.cwd();
399
+ const milestoneBranch = autoWorktreeBranch(milestoneId);
400
+
401
+ // 1. Auto-commit dirty state in worktree before leaving
402
+ autoCommitDirtyState(worktreeCwd);
403
+
404
+ // 2. Parse roadmap for slice listing
405
+ const roadmap = parseRoadmap(roadmapContent);
406
+ const completedSlices = roadmap.slices.filter(s => s.done);
407
+
408
+ // 3. chdir to original base
409
+ const previousCwd = process.cwd();
410
+ process.chdir(originalBasePath_);
411
+
412
+ // 4. Resolve main branch from preferences
413
+ const prefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {};
414
+ const mainBranch = prefs.main_branch || "main";
415
+
416
+ // 5. Checkout main (with self-healing reset)
417
+ recoverCheckout(originalBasePath_, mainBranch);
418
+
419
+ // 6. Build rich commit message
420
+ const milestoneTitle = roadmap.title.replace(/^M\d+:\s*/, "").trim() || milestoneId;
421
+ const subject = `feat(${milestoneId}): ${milestoneTitle}`;
422
+ let body = "";
423
+ if (completedSlices.length > 0) {
424
+ const sliceLines = completedSlices.map(s => `- ${s.id}: ${s.title}`).join("\n");
425
+ body = `\n\nCompleted slices:\n${sliceLines}\n\nBranch: ${milestoneBranch}`;
426
+ }
427
+ const commitMessage = subject + body;
428
+
429
+ // 7. Squash merge (with self-healing retry for transient failures)
430
+ try {
431
+ withMergeHeal(originalBasePath_, () => {
432
+ execSync(`git merge --squash ${milestoneBranch}`, {
433
+ cwd: originalBasePath_,
434
+ stdio: ["ignore", "pipe", "pipe"],
435
+ encoding: "utf-8",
436
+ });
437
+ });
438
+ } catch (err) {
439
+ if (err instanceof MergeConflictError) {
440
+ // Re-throw with correct branch context
441
+ throw new MergeConflictError(
442
+ err.conflictedFiles,
443
+ err.strategy,
444
+ milestoneBranch,
445
+ mainBranch,
446
+ );
447
+ }
448
+ // Possibly "already up to date" -- fall through to commit which will handle nothing-to-commit
449
+ }
450
+
451
+ // 8. Commit (handle nothing-to-commit gracefully)
452
+ let nothingToCommit = false;
453
+ try {
454
+ execSync(`git commit -m ${JSON.stringify(commitMessage)}`, {
455
+ cwd: originalBasePath_,
456
+ stdio: ["ignore", "pipe", "pipe"],
457
+ encoding: "utf-8",
458
+ });
459
+ } catch (err: unknown) {
460
+ // execSync errors have stdout/stderr as properties -- check those for git's message
461
+ const errObj = err as { stdout?: string; stderr?: string; message?: string };
462
+ const combined = [errObj.stdout, errObj.stderr, errObj.message].filter(Boolean).join(" ");
463
+ if (combined.includes("nothing to commit") || combined.includes("nothing added to commit") || combined.includes("no changes added")) {
464
+ nothingToCommit = true;
465
+ } else {
466
+ throw err;
467
+ }
468
+ }
469
+
470
+ // 9. Auto-push if enabled
471
+ let pushed = false;
472
+ if (prefs.auto_push === true && !nothingToCommit) {
473
+ const remote = prefs.remote ?? "origin";
474
+ try {
475
+ execSync(`git push ${remote} ${mainBranch}`, {
476
+ cwd: originalBasePath_,
477
+ stdio: ["ignore", "pipe", "pipe"],
478
+ encoding: "utf-8",
479
+ });
480
+ pushed = true;
481
+ } catch {
482
+ // Push failure is non-fatal
483
+ }
484
+ }
485
+
486
+ // 10. Remove worktree directory first (must happen before branch deletion)
487
+ try {
488
+ removeWorktree(originalBasePath_, milestoneId, { branch: null as unknown as string, deleteBranch: false });
489
+ } catch {
490
+ // Best-effort -- worktree dir may already be gone
491
+ }
492
+
493
+ // 11. Delete milestone branch (after worktree removal so ref is unlocked)
494
+ try {
495
+ execSync(`git branch -D ${milestoneBranch}`, {
496
+ cwd: originalBasePath_,
497
+ stdio: ["ignore", "pipe", "pipe"],
498
+ encoding: "utf-8",
499
+ });
500
+ } catch {
501
+ // Best-effort
502
+ }
503
+
504
+ // 12. Clear module state
505
+ originalBase = null;
506
+ nudgeGitBranchCache(previousCwd);
507
+
508
+ return { commitMessage, pushed };
509
+ }