gsd-pi 2.12.0 → 2.13.1

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 +512 -0
  5. package/dist/resources/extensions/gsd/auto.ts +222 -11
  6. package/dist/resources/extensions/gsd/doctor.ts +198 -2
  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 +264 -0
  15. package/dist/resources/extensions/gsd/tests/git-self-heal.test.ts +235 -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 +321 -0
  19. package/dist/resources/extensions/gsd/worktree-manager.ts +13 -7
  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 +512 -0
  47. package/src/resources/extensions/gsd/auto.ts +222 -11
  48. package/src/resources/extensions/gsd/doctor.ts +198 -2
  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 +264 -0
  57. package/src/resources/extensions/gsd/tests/git-self-heal.test.ts +235 -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 +321 -0
  61. package/src/resources/extensions/gsd/worktree-manager.ts +13 -7
  62. package/src/resources/extensions/search-the-web/native-search.ts +15 -10
package/dist/cli.js CHANGED
@@ -2,12 +2,27 @@ import { AuthStorage, DefaultResourceLoader, ModelRegistry, SettingsManager, Ses
2
2
  import { existsSync, readdirSync, renameSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { agentDir, sessionsDir, authFilePath } from './app-paths.js';
5
- import { initResources, buildResourceLoader } from './resource-loader.js';
5
+ import { initResources, buildResourceLoader, getNewerManagedResourceVersion } from './resource-loader.js';
6
6
  import { ensureManagedTools } from './tool-bootstrap.js';
7
7
  import { loadStoredEnvKeys } from './wizard.js';
8
8
  import { getPiDefaultModelAndProvider, migratePiCredentials } from './pi-migration.js';
9
9
  import { shouldRunOnboarding, runOnboarding } from './onboarding.js';
10
10
  import { checkForUpdates } from './update-check.js';
11
+ function exitIfManagedResourcesAreNewer(currentAgentDir) {
12
+ const currentVersion = process.env.GSD_VERSION || '0.0.0';
13
+ const managedVersion = getNewerManagedResourceVersion(currentAgentDir, currentVersion);
14
+ if (!managedVersion) {
15
+ return;
16
+ }
17
+ const yellow = '\x1b[33m';
18
+ const dim = '\x1b[2m';
19
+ const reset = '\x1b[0m';
20
+ const bold = '\x1b[1m';
21
+ process.stderr.write(`[gsd] ${yellow}Version mismatch detected${reset}\n` +
22
+ `[gsd] Synced resources are from ${bold}v${managedVersion}${reset}, but this \`gsd\` binary is ${dim}v${currentVersion}${reset}.\n` +
23
+ `[gsd] Run ${bold}npm install -g gsd-pi@latest${reset} or ${bold}gsd update${reset}, then try again.\n`);
24
+ process.exit(1);
25
+ }
11
26
  function parseCliArgs(argv) {
12
27
  const flags = { extensions: [], messages: [] };
13
28
  const args = argv.slice(2); // skip node + script
@@ -199,6 +214,7 @@ if (isPrintMode) {
199
214
  appendSystemPrompt = cliFlags.appendSystemPrompt;
200
215
  }
201
216
  }
217
+ exitIfManagedResourcesAreNewer(agentDir);
202
218
  initResources(agentDir);
203
219
  const resourceLoader = new DefaultResourceLoader({
204
220
  agentDir,
@@ -272,6 +288,7 @@ if (existsSync(sessionsDir)) {
272
288
  const sessionManager = cliFlags.continue
273
289
  ? SessionManager.continueRecent(cwd, projectSessionsDir)
274
290
  : SessionManager.create(cwd, projectSessionsDir);
291
+ exitIfManagedResourcesAreNewer(agentDir);
275
292
  initResources(agentDir);
276
293
  const resourceLoader = buildResourceLoader(agentDir);
277
294
  await resourceLoader.reload();
@@ -1,5 +1,7 @@
1
1
  import { DefaultResourceLoader } from '@gsd/pi-coding-agent';
2
2
  export declare function discoverExtensionEntryPaths(extensionsDir: string): string[];
3
+ export declare function readManagedResourceVersion(agentDir: string): string | null;
4
+ export declare function getNewerManagedResourceVersion(agentDir: string, currentVersion: string): string | null;
3
5
  /**
4
6
  * Syncs all bundled resources to agentDir (~/.gsd/agent/) on every launch.
5
7
  *
@@ -1,8 +1,9 @@
1
1
  import { DefaultResourceLoader } from '@gsd/pi-coding-agent';
2
2
  import { homedir } from 'node:os';
3
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
3
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
4
4
  import { dirname, join, relative, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { compareSemver } from './update-check.js';
6
7
  // Resolve resources directory — prefer dist/resources/ (stable, set at build time)
7
8
  // over src/resources/ (live working tree, changes with git branch).
8
9
  //
@@ -16,6 +17,7 @@ const distResources = join(packageRoot, 'dist', 'resources');
16
17
  const srcResources = join(packageRoot, 'src', 'resources');
17
18
  const resourcesDir = existsSync(distResources) ? distResources : srcResources;
18
19
  const bundledExtensionsDir = join(resourcesDir, 'extensions');
20
+ const resourceVersionManifestName = 'managed-resources.json';
19
21
  function isExtensionFile(name) {
20
22
  return name.endsWith('.ts') || name.endsWith('.js');
21
23
  }
@@ -70,6 +72,38 @@ function getExtensionKey(entryPath, extensionsDir) {
70
72
  const relPath = relative(extensionsDir, entryPath);
71
73
  return relPath.split(/[\\/]/)[0];
72
74
  }
75
+ function getManagedResourceManifestPath(agentDir) {
76
+ return join(agentDir, resourceVersionManifestName);
77
+ }
78
+ function getBundledGsdVersion() {
79
+ try {
80
+ const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf-8'));
81
+ return typeof pkg?.version === 'string' ? pkg.version : '0.0.0';
82
+ }
83
+ catch {
84
+ return process.env.GSD_VERSION || '0.0.0';
85
+ }
86
+ }
87
+ function writeManagedResourceManifest(agentDir) {
88
+ const manifest = { gsdVersion: getBundledGsdVersion() };
89
+ writeFileSync(getManagedResourceManifestPath(agentDir), JSON.stringify(manifest));
90
+ }
91
+ export function readManagedResourceVersion(agentDir) {
92
+ try {
93
+ const manifest = JSON.parse(readFileSync(getManagedResourceManifestPath(agentDir), 'utf-8'));
94
+ return typeof manifest?.gsdVersion === 'string' ? manifest.gsdVersion : null;
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ export function getNewerManagedResourceVersion(agentDir, currentVersion) {
101
+ const managedVersion = readManagedResourceVersion(agentDir);
102
+ if (!managedVersion) {
103
+ return null;
104
+ }
105
+ return compareSemver(managedVersion, currentVersion) > 0 ? managedVersion : null;
106
+ }
73
107
  /**
74
108
  * Syncs all bundled resources to agentDir (~/.gsd/agent/) on every launch.
75
109
  *
@@ -101,6 +135,7 @@ export function initResources(agentDir) {
101
135
  if (existsSync(srcSkills)) {
102
136
  cpSync(srcSkills, destSkills, { recursive: true, force: true });
103
137
  }
138
+ writeManagedResourceManifest(agentDir);
104
139
  }
105
140
  /**
106
141
  * Constructs a DefaultResourceLoader that loads extensions from both
@@ -0,0 +1,512 @@
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, execFileSync } 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
+ // Use unquoted glob pattern — single quotes are not interpreted by cmd.exe on Windows,
58
+ // causing the pattern to match literally instead of as a glob.
59
+ const output = execSync("git branch --list gsd/*/*", {
60
+ cwd: basePath,
61
+ stdio: ["ignore", "pipe", "pipe"],
62
+ encoding: "utf-8",
63
+ }).trim();
64
+ if (output) return false; // Legacy branch-per-slice project
65
+ } catch {
66
+ // If git command fails, default to worktree
67
+ }
68
+
69
+ return true; // New project default
70
+ }
71
+
72
+ /**
73
+ * Resolve the merge_to_main preference value.
74
+ * Returns "milestone" (default) or "slice".
75
+ */
76
+ export function getMergeToMainMode(): "milestone" | "slice" {
77
+ const prefs = loadEffectiveGSDPreferences()?.preferences?.git;
78
+ return prefs?.merge_to_main ?? "milestone";
79
+ }
80
+
81
+ // ─── Git Helpers (local, mirrors worktree-command.ts pattern) ──────────────
82
+
83
+ function resolveGitHeadPath(dir: string): string | null {
84
+ const gitPath = join(dir, ".git");
85
+ if (!existsSync(gitPath)) return null;
86
+ try {
87
+ const content = readFileSync(gitPath, "utf8").trim();
88
+ if (content.startsWith("gitdir: ")) {
89
+ const gitDir = resolve(dir, content.slice(8));
90
+ const headPath = join(gitDir, "HEAD");
91
+ return existsSync(headPath) ? headPath : null;
92
+ }
93
+ const headPath = join(dir, ".git", "HEAD");
94
+ return existsSync(headPath) ? headPath : null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Nudge pi's FooterDataProvider to re-read the git branch after chdir.
102
+ * Touches HEAD in both old and new cwd to fire the fs watcher.
103
+ */
104
+ function nudgeGitBranchCache(previousCwd: string): void {
105
+ const now = new Date();
106
+ for (const dir of [previousCwd, process.cwd()]) {
107
+ try {
108
+ const headPath = resolveGitHeadPath(dir);
109
+ if (headPath) utimesSync(headPath, now, now);
110
+ } catch {
111
+ // Best-effort
112
+ }
113
+ }
114
+ }
115
+
116
+ function getCurrentBranch(cwd: string): string {
117
+ try {
118
+ return execSync("git branch --show-current", {
119
+ cwd,
120
+ stdio: ["ignore", "pipe", "pipe"],
121
+ encoding: "utf-8",
122
+ }).trim();
123
+ } catch {
124
+ return "";
125
+ }
126
+ }
127
+
128
+ // ─── Auto-Worktree Branch Naming ───────────────────────────────────────────
129
+
130
+ export function autoWorktreeBranch(milestoneId: string): string {
131
+ return `milestone/${milestoneId}`;
132
+ }
133
+
134
+ // ─── Public API ────────────────────────────────────────────────────────────
135
+
136
+ /**
137
+ * Create a new auto-worktree for a milestone, chdir into it, and store
138
+ * the original base path for later teardown.
139
+ *
140
+ * Atomic: chdir + originalBase update happen in the same try block
141
+ * to prevent split-brain.
142
+ */
143
+ export function createAutoWorktree(basePath: string, milestoneId: string): string {
144
+ const branch = autoWorktreeBranch(milestoneId);
145
+ const info = createWorktree(basePath, milestoneId, { branch });
146
+ const previousCwd = process.cwd();
147
+
148
+ try {
149
+ process.chdir(info.path);
150
+ originalBase = basePath;
151
+ } catch (err) {
152
+ // If chdir fails, the worktree was created but we couldn't enter it.
153
+ // Don't store originalBase -- caller can retry or clean up.
154
+ throw new Error(
155
+ `Auto-worktree created at ${info.path} but chdir failed: ${err instanceof Error ? err.message : String(err)}`,
156
+ );
157
+ }
158
+
159
+ nudgeGitBranchCache(previousCwd);
160
+ return info.path;
161
+ }
162
+
163
+ /**
164
+ * Teardown an auto-worktree: chdir back to original base, then remove
165
+ * the worktree and its branch.
166
+ */
167
+ export function teardownAutoWorktree(originalBasePath: string, milestoneId: string): void {
168
+ const branch = autoWorktreeBranch(milestoneId);
169
+ const previousCwd = process.cwd();
170
+
171
+ try {
172
+ process.chdir(originalBasePath);
173
+ originalBase = null;
174
+ } catch (err) {
175
+ throw new Error(
176
+ `Failed to chdir back to ${originalBasePath} during teardown: ${err instanceof Error ? err.message : String(err)}`,
177
+ );
178
+ }
179
+
180
+ nudgeGitBranchCache(previousCwd);
181
+ removeWorktree(originalBasePath, milestoneId, { branch });
182
+ }
183
+
184
+ /**
185
+ * Detect if the process is currently inside an auto-worktree.
186
+ * Checks both module state and git branch prefix.
187
+ */
188
+ export function isInAutoWorktree(basePath: string): boolean {
189
+ if (!originalBase) return false;
190
+ const cwd = process.cwd();
191
+ const resolvedBase = existsSync(basePath) ? realpathSync(basePath) : basePath;
192
+ const wtDir = join(resolvedBase, ".gsd", "worktrees");
193
+ if (!cwd.startsWith(wtDir)) return false;
194
+ const branch = getCurrentBranch(cwd);
195
+ return branch.startsWith("milestone/");
196
+ }
197
+
198
+ /**
199
+ * Get the filesystem path for an auto-worktree, or null if it doesn't exist.
200
+ */
201
+ export function getAutoWorktreePath(basePath: string, milestoneId: string): string | null {
202
+ const p = worktreePath(basePath, milestoneId);
203
+ return existsSync(p) ? p : null;
204
+ }
205
+
206
+ /**
207
+ * Enter an existing auto-worktree (chdir into it, store originalBase).
208
+ * Use for resume -- the worktree already exists from a prior create.
209
+ *
210
+ * Atomic: chdir + originalBase update in same try block.
211
+ */
212
+ export function enterAutoWorktree(basePath: string, milestoneId: string): string {
213
+ const p = worktreePath(basePath, milestoneId);
214
+ if (!existsSync(p)) {
215
+ throw new Error(`Auto-worktree for ${milestoneId} does not exist at ${p}`);
216
+ }
217
+
218
+ const previousCwd = process.cwd();
219
+
220
+ try {
221
+ process.chdir(p);
222
+ originalBase = basePath;
223
+ } catch (err) {
224
+ throw new Error(
225
+ `Failed to enter auto-worktree at ${p}: ${err instanceof Error ? err.message : String(err)}`,
226
+ );
227
+ }
228
+
229
+ nudgeGitBranchCache(previousCwd);
230
+ return p;
231
+ }
232
+
233
+ /**
234
+ * Get the original project root stored when entering an auto-worktree.
235
+ * Returns null if not currently in an auto-worktree.
236
+ */
237
+ export function getAutoWorktreeOriginalBase(): string | null {
238
+ return originalBase;
239
+ }
240
+
241
+ // ─── Merge Slice -> Milestone ───────────────────────────────────────────────
242
+
243
+ /**
244
+ * Merge a completed slice branch into the milestone branch via `--no-ff`.
245
+ *
246
+ * Worktree-mode merge: `.gsd/` is local to the worktree (not tracked in
247
+ * git), so there are zero `.gsd/` conflict resolution concerns. No runtime
248
+ * exclusion untracking, no `--theirs` checkout, no snapshot creation.
249
+ *
250
+ * On conflict: throws MergeConflictError with conflicted file list.
251
+ * On success: deletes the slice branch and returns MergeSliceResult.
252
+ */
253
+ export function mergeSliceToMilestone(
254
+ basePath: string,
255
+ milestoneId: string,
256
+ sliceId: string,
257
+ sliceTitle: string,
258
+ ): MergeSliceResult {
259
+ if (!isInAutoWorktree(basePath)) {
260
+ throw new Error("mergeSliceToMilestone called outside auto-worktree");
261
+ }
262
+
263
+ const cwd = process.cwd();
264
+ const milestoneBranch = autoWorktreeBranch(milestoneId);
265
+ const worktreeName = detectWorktreeName(cwd);
266
+ const sliceBranch = getSliceBranchName(milestoneId, sliceId, worktreeName);
267
+
268
+ // Verify slice branch exists
269
+ if (!nativeBranchExists(cwd, sliceBranch)) {
270
+ throw new Error(`Slice branch "${sliceBranch}" does not exist`);
271
+ }
272
+
273
+ // Verify slice has commits ahead of milestone branch
274
+ const commitCount = nativeCommitCountBetween(cwd, milestoneBranch, sliceBranch);
275
+ if (commitCount === 0) {
276
+ throw new Error(
277
+ `Slice branch "${sliceBranch}" has no commits ahead of "${milestoneBranch}"`,
278
+ );
279
+ }
280
+
281
+ // Checkout milestone branch (with self-healing reset)
282
+ recoverCheckout(cwd, milestoneBranch);
283
+
284
+ // Build rich commit message (replicates GitServiceImpl.buildRichCommitMessage format)
285
+ const commitType = inferCommitType(sliceTitle);
286
+ const subject = `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`;
287
+
288
+ let message = subject;
289
+ try {
290
+ const logOutput = execSync(
291
+ `git log --oneline --format=%s ${milestoneBranch}..${sliceBranch}`,
292
+ { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" },
293
+ ).trim();
294
+
295
+ if (logOutput) {
296
+ const subjects = logOutput.split("\n").filter(Boolean);
297
+ const MAX_ENTRIES = 20;
298
+ const truncated = subjects.length > MAX_ENTRIES;
299
+ const displayed = truncated ? subjects.slice(0, MAX_ENTRIES) : subjects;
300
+ const taskLines = displayed.map(s => `- ${s}`).join("\n");
301
+ const truncationLine = truncated
302
+ ? `\n- ... and ${subjects.length - MAX_ENTRIES} more`
303
+ : "";
304
+ message = `${subject}\n\nTasks:\n${taskLines}${truncationLine}\n\nBranch: ${sliceBranch}`;
305
+ }
306
+ } catch {
307
+ // Fall back to subject-only message
308
+ }
309
+
310
+ // Merge --no-ff (with self-healing retry for transient failures)
311
+ try {
312
+ withMergeHeal(cwd, () => {
313
+ execFileSync("git", ["merge", "--no-ff", "-m", message, sliceBranch], {
314
+ cwd,
315
+ stdio: ["ignore", "pipe", "pipe"],
316
+ encoding: "utf-8",
317
+ });
318
+ });
319
+ } catch (err) {
320
+ if (err instanceof MergeConflictError) {
321
+ // Re-throw with correct branch context
322
+ throw new MergeConflictError(
323
+ err.conflictedFiles,
324
+ err.strategy,
325
+ sliceBranch,
326
+ milestoneBranch,
327
+ );
328
+ }
329
+ throw err;
330
+ }
331
+
332
+ // Delete slice branch
333
+ let deletedBranch = false;
334
+ try {
335
+ execSync(`git branch -d ${sliceBranch}`, {
336
+ cwd,
337
+ stdio: ["ignore", "pipe", "pipe"],
338
+ encoding: "utf-8",
339
+ });
340
+ deletedBranch = true;
341
+ } catch {
342
+ // Branch deletion is best-effort
343
+ }
344
+
345
+ return {
346
+ branch: sliceBranch,
347
+ mergedCommitMessage: message,
348
+ deletedBranch,
349
+ };
350
+ }
351
+
352
+ // ─── Merge Milestone -> Main ───────────────────────────────────────────────
353
+
354
+ /**
355
+ * Auto-commit any dirty (uncommitted) state in the given directory.
356
+ * Returns true if a commit was made, false if working tree was clean.
357
+ */
358
+ function autoCommitDirtyState(cwd: string): boolean {
359
+ try {
360
+ const status = execSync("git status --porcelain", {
361
+ cwd,
362
+ stdio: ["ignore", "pipe", "pipe"],
363
+ encoding: "utf-8",
364
+ }).trim();
365
+ if (!status) return false;
366
+ execFileSync("git", ["add", "-A"], { cwd, stdio: "pipe" });
367
+ execFileSync("git", ["commit", "-m", "chore: auto-commit before milestone merge"], {
368
+ cwd,
369
+ stdio: ["ignore", "pipe", "pipe"],
370
+ encoding: "utf-8",
371
+ });
372
+ return true;
373
+ } catch {
374
+ return false;
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Squash-merge the milestone branch into main with a rich commit message
380
+ * listing all completed slices, then tear down the worktree.
381
+ *
382
+ * Sequence:
383
+ * 1. Auto-commit dirty worktree state
384
+ * 2. chdir to originalBasePath
385
+ * 3. git checkout main
386
+ * 4. git merge --squash milestone/<MID>
387
+ * 5. git commit with rich message
388
+ * 6. Auto-push if enabled
389
+ * 7. Delete milestone branch
390
+ * 8. Remove worktree directory
391
+ * 9. Clear originalBase
392
+ *
393
+ * On merge conflict: throws MergeConflictError.
394
+ * On "nothing to commit" after squash: handles gracefully (no error).
395
+ */
396
+ export function mergeMilestoneToMain(
397
+ originalBasePath_: string,
398
+ milestoneId: string,
399
+ roadmapContent: string,
400
+ ): { commitMessage: string; pushed: boolean } {
401
+ const worktreeCwd = process.cwd();
402
+ const milestoneBranch = autoWorktreeBranch(milestoneId);
403
+
404
+ // 1. Auto-commit dirty state in worktree before leaving
405
+ autoCommitDirtyState(worktreeCwd);
406
+
407
+ // 2. Parse roadmap for slice listing
408
+ const roadmap = parseRoadmap(roadmapContent);
409
+ const completedSlices = roadmap.slices.filter(s => s.done);
410
+
411
+ // 3. chdir to original base
412
+ const previousCwd = process.cwd();
413
+ process.chdir(originalBasePath_);
414
+
415
+ // 4. Resolve main branch from preferences
416
+ const prefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {};
417
+ const mainBranch = prefs.main_branch || "main";
418
+
419
+ // 5. Checkout main (with self-healing reset)
420
+ recoverCheckout(originalBasePath_, mainBranch);
421
+
422
+ // 6. Build rich commit message
423
+ const milestoneTitle = roadmap.title.replace(/^M\d+:\s*/, "").trim() || milestoneId;
424
+ const subject = `feat(${milestoneId}): ${milestoneTitle}`;
425
+ let body = "";
426
+ if (completedSlices.length > 0) {
427
+ const sliceLines = completedSlices.map(s => `- ${s.id}: ${s.title}`).join("\n");
428
+ body = `\n\nCompleted slices:\n${sliceLines}\n\nBranch: ${milestoneBranch}`;
429
+ }
430
+ const commitMessage = subject + body;
431
+
432
+ // 7. Squash merge (with self-healing retry for transient failures)
433
+ try {
434
+ withMergeHeal(originalBasePath_, () => {
435
+ execSync(`git merge --squash ${milestoneBranch}`, {
436
+ cwd: originalBasePath_,
437
+ stdio: ["ignore", "pipe", "pipe"],
438
+ encoding: "utf-8",
439
+ });
440
+ });
441
+ } catch (err) {
442
+ if (err instanceof MergeConflictError) {
443
+ // Re-throw with correct branch context
444
+ throw new MergeConflictError(
445
+ err.conflictedFiles,
446
+ err.strategy,
447
+ milestoneBranch,
448
+ mainBranch,
449
+ );
450
+ }
451
+ // Possibly "already up to date" -- fall through to commit which will handle nothing-to-commit
452
+ }
453
+
454
+ // 8. Commit (handle nothing-to-commit gracefully)
455
+ let nothingToCommit = false;
456
+ try {
457
+ execFileSync("git", ["commit", "-m", commitMessage], {
458
+ cwd: originalBasePath_,
459
+ stdio: ["ignore", "pipe", "pipe"],
460
+ encoding: "utf-8",
461
+ });
462
+ } catch (err: unknown) {
463
+ // execSync errors have stdout/stderr as properties -- check those for git's message
464
+ const errObj = err as { stdout?: string; stderr?: string; message?: string };
465
+ const combined = [errObj.stdout, errObj.stderr, errObj.message].filter(Boolean).join(" ");
466
+ if (combined.includes("nothing to commit") || combined.includes("nothing added to commit") || combined.includes("no changes added")) {
467
+ nothingToCommit = true;
468
+ } else {
469
+ throw err;
470
+ }
471
+ }
472
+
473
+ // 9. Auto-push if enabled
474
+ let pushed = false;
475
+ if (prefs.auto_push === true && !nothingToCommit) {
476
+ const remote = prefs.remote ?? "origin";
477
+ try {
478
+ execSync(`git push ${remote} ${mainBranch}`, {
479
+ cwd: originalBasePath_,
480
+ stdio: ["ignore", "pipe", "pipe"],
481
+ encoding: "utf-8",
482
+ });
483
+ pushed = true;
484
+ } catch {
485
+ // Push failure is non-fatal
486
+ }
487
+ }
488
+
489
+ // 10. Remove worktree directory first (must happen before branch deletion)
490
+ try {
491
+ removeWorktree(originalBasePath_, milestoneId, { branch: null as unknown as string, deleteBranch: false });
492
+ } catch {
493
+ // Best-effort -- worktree dir may already be gone
494
+ }
495
+
496
+ // 11. Delete milestone branch (after worktree removal so ref is unlocked)
497
+ try {
498
+ execSync(`git branch -D ${milestoneBranch}`, {
499
+ cwd: originalBasePath_,
500
+ stdio: ["ignore", "pipe", "pipe"],
501
+ encoding: "utf-8",
502
+ });
503
+ } catch {
504
+ // Best-effort
505
+ }
506
+
507
+ // 12. Clear module state
508
+ originalBase = null;
509
+ nudgeGitBranchCache(previousCwd);
510
+
511
+ return { commitMessage, pushed };
512
+ }