gsd-pi 2.13.0 → 2.14.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 (99) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.js +1 -0
  3. package/dist/loader.js +50 -6
  4. package/dist/resource-loader.d.ts +7 -6
  5. package/dist/resource-loader.js +15 -8
  6. package/dist/resources/extensions/gsd/auto-worktree.ts +29 -183
  7. package/dist/resources/extensions/gsd/auto.ts +252 -370
  8. package/dist/resources/extensions/gsd/commands.ts +118 -34
  9. package/dist/resources/extensions/gsd/doctor.ts +29 -4
  10. package/dist/resources/extensions/gsd/git-self-heal.ts +0 -71
  11. package/dist/resources/extensions/gsd/git-service.ts +8 -431
  12. package/dist/resources/extensions/gsd/gitignore.ts +11 -4
  13. package/dist/resources/extensions/gsd/guided-flow.ts +141 -5
  14. package/dist/resources/extensions/gsd/preferences.ts +18 -17
  15. package/dist/resources/extensions/gsd/prompts/discuss.md +35 -0
  16. package/dist/resources/extensions/gsd/prompts/queue.md +7 -1
  17. package/dist/resources/extensions/gsd/state.ts +26 -8
  18. package/dist/resources/extensions/gsd/templates/state.md +0 -1
  19. package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +3 -2
  20. package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +1 -1
  21. package/dist/resources/extensions/gsd/tests/derive-state.test.ts +35 -0
  22. package/dist/resources/extensions/gsd/tests/doctor-git.test.ts +22 -4
  23. package/dist/resources/extensions/gsd/tests/draft-promotion.test.ts +2 -1
  24. package/dist/resources/extensions/gsd/tests/git-self-heal.test.ts +8 -111
  25. package/dist/resources/extensions/gsd/tests/git-service.test.ts +11 -770
  26. package/dist/resources/extensions/gsd/tests/idle-recovery.test.ts +21 -113
  27. package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +16 -82
  28. package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +29 -50
  29. package/dist/resources/extensions/gsd/tests/worktree-e2e.test.ts +17 -91
  30. package/dist/resources/extensions/gsd/tests/worktree-integration.test.ts +28 -55
  31. package/dist/resources/extensions/gsd/tests/worktree.test.ts +1 -426
  32. package/dist/resources/extensions/gsd/types.ts +0 -1
  33. package/dist/resources/extensions/gsd/worktree-manager.ts +7 -3
  34. package/dist/resources/extensions/gsd/worktree.ts +7 -65
  35. package/dist/resources/extensions/search-the-web/command-search-provider.ts +3 -1
  36. package/package.json +1 -1
  37. package/packages/pi-ai/dist/providers/google.d.ts.map +1 -1
  38. package/packages/pi-ai/dist/providers/google.js +12 -4
  39. package/packages/pi-ai/dist/providers/google.js.map +1 -1
  40. package/packages/pi-ai/dist/providers/mistral.d.ts.map +1 -1
  41. package/packages/pi-ai/dist/providers/mistral.js +10 -2
  42. package/packages/pi-ai/dist/providers/mistral.js.map +1 -1
  43. package/packages/pi-ai/src/providers/google.ts +20 -8
  44. package/packages/pi-ai/src/providers/mistral.ts +14 -2
  45. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts +3 -0
  46. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  47. package/packages/pi-coding-agent/dist/core/extensions/loader.js +10 -7
  48. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  49. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts +1 -1
  50. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts.map +1 -1
  51. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js +4 -1
  52. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js.map +1 -1
  53. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  54. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +12 -3
  55. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  56. package/packages/pi-coding-agent/src/core/extensions/loader.ts +13 -9
  57. package/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts +4 -1
  58. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +14 -3
  59. package/packages/pi-tui/dist/components/input.d.ts +1 -0
  60. package/packages/pi-tui/dist/components/input.d.ts.map +1 -1
  61. package/packages/pi-tui/dist/components/input.js +10 -0
  62. package/packages/pi-tui/dist/components/input.js.map +1 -1
  63. package/packages/pi-tui/src/components/input.ts +11 -0
  64. package/src/resources/extensions/gsd/auto-worktree.ts +29 -183
  65. package/src/resources/extensions/gsd/auto.ts +252 -370
  66. package/src/resources/extensions/gsd/commands.ts +118 -34
  67. package/src/resources/extensions/gsd/doctor.ts +29 -4
  68. package/src/resources/extensions/gsd/git-self-heal.ts +0 -71
  69. package/src/resources/extensions/gsd/git-service.ts +8 -431
  70. package/src/resources/extensions/gsd/gitignore.ts +11 -4
  71. package/src/resources/extensions/gsd/guided-flow.ts +141 -5
  72. package/src/resources/extensions/gsd/preferences.ts +18 -17
  73. package/src/resources/extensions/gsd/prompts/discuss.md +35 -0
  74. package/src/resources/extensions/gsd/prompts/queue.md +7 -1
  75. package/src/resources/extensions/gsd/state.ts +26 -8
  76. package/src/resources/extensions/gsd/templates/state.md +0 -1
  77. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +3 -2
  78. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +1 -1
  79. package/src/resources/extensions/gsd/tests/derive-state.test.ts +35 -0
  80. package/src/resources/extensions/gsd/tests/doctor-git.test.ts +22 -4
  81. package/src/resources/extensions/gsd/tests/draft-promotion.test.ts +2 -1
  82. package/src/resources/extensions/gsd/tests/git-self-heal.test.ts +8 -111
  83. package/src/resources/extensions/gsd/tests/git-service.test.ts +11 -770
  84. package/src/resources/extensions/gsd/tests/idle-recovery.test.ts +21 -113
  85. package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +16 -82
  86. package/src/resources/extensions/gsd/tests/preferences-git.test.ts +29 -50
  87. package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +17 -91
  88. package/src/resources/extensions/gsd/tests/worktree-integration.test.ts +28 -55
  89. package/src/resources/extensions/gsd/tests/worktree.test.ts +1 -426
  90. package/src/resources/extensions/gsd/types.ts +0 -1
  91. package/src/resources/extensions/gsd/worktree-manager.ts +7 -3
  92. package/src/resources/extensions/gsd/worktree.ts +7 -65
  93. package/src/resources/extensions/search-the-web/command-search-provider.ts +3 -1
  94. package/dist/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +0 -282
  95. package/dist/resources/extensions/gsd/tests/isolation-resolver.test.ts +0 -107
  96. package/dist/resources/extensions/gsd/tests/orphaned-branch.test.ts +0 -353
  97. package/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +0 -282
  98. package/src/resources/extensions/gsd/tests/isolation-resolver.test.ts +0 -107
  99. package/src/resources/extensions/gsd/tests/orphaned-branch.test.ts +0 -353
@@ -5,7 +5,8 @@
5
5
  */
6
6
 
7
7
  import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent";
8
- import { existsSync, readFileSync } from "node:fs";
8
+ import { AuthStorage } from "@gsd/pi-coding-agent";
9
+ import { existsSync, readFileSync, mkdirSync } from "node:fs";
9
10
  import { join, dirname } from "node:path";
10
11
  import { fileURLToPath } from "node:url";
11
12
  import { deriveState } from "./state.js";
@@ -53,10 +54,10 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT
53
54
 
54
55
  export function registerGSDCommand(pi: ExtensionAPI): void {
55
56
  pi.registerCommand("gsd", {
56
- description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|hooks|doctor|migrate|remote",
57
+ description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|config|hooks|doctor|migrate|remote",
57
58
 
58
59
  getArgumentCompletions: (prefix: string) => {
59
- const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "hooks", "doctor", "migrate", "remote"];
60
+ const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "config", "hooks", "doctor", "migrate", "remote"];
60
61
  const parts = prefix.trim().split(/\s+/);
61
62
 
62
63
  if (parts.length <= 1) {
@@ -151,6 +152,11 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
151
152
  return;
152
153
  }
153
154
 
155
+ if (trimmed === "config") {
156
+ await handleConfig(ctx);
157
+ return;
158
+ }
159
+
154
160
  if (trimmed === "hooks") {
155
161
  const { formatHookStatus } = await import("./post-unit-hooks.js");
156
162
  ctx.ui.notify(formatHookStatus(), "info");
@@ -174,7 +180,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
174
180
  }
175
181
 
176
182
  ctx.ui.notify(
177
- `Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status|wizard|setup], /gsd hooks, /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate <path>, or /gsd remote [slack|discord|status|disconnect].`,
183
+ `Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs, /gsd config, /gsd hooks, /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate <path>, or /gsd remote [slack|discord|status|disconnect].`,
178
184
  "warning",
179
185
  );
180
186
  },
@@ -215,20 +221,16 @@ export async function fireStatusViaCommand(
215
221
  async function handlePrefs(args: string, ctx: ExtensionCommandContext): Promise<void> {
216
222
  const trimmed = args.trim();
217
223
 
218
- if (trimmed === "" || trimmed === "global") {
224
+ if (trimmed === "" || trimmed === "global" || trimmed === "wizard" || trimmed === "setup"
225
+ || trimmed === "wizard global" || trimmed === "setup global") {
219
226
  await ensurePreferencesFile(getGlobalGSDPreferencesPath(), ctx, "global");
227
+ await handlePrefsWizard(ctx, "global");
220
228
  return;
221
229
  }
222
230
 
223
- if (trimmed === "project") {
231
+ if (trimmed === "project" || trimmed === "wizard project" || trimmed === "setup project") {
224
232
  await ensurePreferencesFile(getProjectGSDPreferencesPath(), ctx, "project");
225
- return;
226
- }
227
-
228
- if (trimmed === "wizard" || trimmed === "setup" || trimmed === "wizard global" || trimmed === "setup global"
229
- || trimmed === "wizard project" || trimmed === "setup project") {
230
- const scope = trimmed.includes("project") ? "project" : "global";
231
- await handlePrefsWizard(ctx, scope);
233
+ await handlePrefsWizard(ctx, "project");
232
234
  return;
233
235
  }
234
236
 
@@ -319,22 +321,41 @@ async function handlePrefsWizard(
319
321
  const modelPhases = ["research", "planning", "execution", "completion"] as const;
320
322
  const models: Record<string, string> = (prefs.models as Record<string, string>) ?? {};
321
323
 
322
- for (const phase of modelPhases) {
323
- const current = models[phase] ?? "";
324
- const input = await ctx.ui.input(
325
- `Model for ${phase} phase${current ? ` (current: ${current})` : ""}:`,
326
- current || "e.g. claude-sonnet-4-20250514",
327
- );
328
- if (input !== null && input !== undefined) {
329
- const val = input.trim();
330
- if (val) {
331
- models[phase] = val;
332
- } else if (current) {
333
- // User cleared it — remove
334
- delete models[phase];
324
+ const availableModels = ctx.modelRegistry.getAvailable();
325
+ if (availableModels.length > 0) {
326
+ const modelOptions = availableModels.map(m => `${m.id} · ${m.provider}`);
327
+ modelOptions.push("(keep current)", "(clear)");
328
+
329
+ for (const phase of modelPhases) {
330
+ const current = models[phase] ?? "";
331
+ const title = `Model for ${phase} phase${current ? ` (current: ${current})` : ""}:`;
332
+ const choice = await ctx.ui.select(title, modelOptions);
333
+
334
+ if (choice && choice !== "(keep current)") {
335
+ if (choice === "(clear)") {
336
+ delete models[phase];
337
+ } else {
338
+ models[phase] = choice.split(" · ")[0];
339
+ }
340
+ }
341
+ }
342
+ } else {
343
+ // No authenticated models available — fall back to text input
344
+ for (const phase of modelPhases) {
345
+ const current = models[phase] ?? "";
346
+ const input = await ctx.ui.input(
347
+ `Model for ${phase} phase${current ? ` (current: ${current})` : ""}:`,
348
+ current || "e.g. claude-sonnet-4-20250514",
349
+ );
350
+ if (input !== null && input !== undefined) {
351
+ const val = input.trim();
352
+ if (val) {
353
+ models[phase] = val;
354
+ } else if (current) {
355
+ delete models[phase];
356
+ }
335
357
  }
336
358
  }
337
- // null/undefined = Escape/skip — keep existing value
338
359
  }
339
360
  if (Object.keys(models).length > 0) {
340
361
  prefs.models = models;
@@ -452,8 +473,7 @@ function serializePreferencesToFrontmatter(prefs: Record<string, unknown>): stri
452
473
 
453
474
  if (Array.isArray(value)) {
454
475
  if (value.length === 0) {
455
- lines.push(`${prefix}${key}: []`);
456
- return;
476
+ return; // Omit empty arrays — avoids parse/serialize cycle bug with "[]" strings
457
477
  }
458
478
  lines.push(`${prefix}${key}:`);
459
479
  for (const item of value) {
@@ -484,8 +504,7 @@ function serializePreferencesToFrontmatter(prefs: Record<string, unknown>): stri
484
504
  if (typeof value === "object") {
485
505
  const entries = Object.entries(value as Record<string, unknown>);
486
506
  if (entries.length === 0) {
487
- lines.push(`${prefix}${key}: {}`);
488
- return;
507
+ return; // Omit empty objects — avoids parse/serialize cycle bug with "{}" strings
489
508
  }
490
509
  lines.push(`${prefix}${key}:`);
491
510
  for (const [k, v] of entries) {
@@ -521,6 +540,74 @@ function serializePreferencesToFrontmatter(prefs: Record<string, unknown>): stri
521
540
  return lines.join("\n") + "\n";
522
541
  }
523
542
 
543
+ // ─── Tool Config Wizard ───────────────────────────────────────────────────────
544
+
545
+ const TOOL_KEYS = [
546
+ { id: "tavily", env: "TAVILY_API_KEY", label: "Tavily Search", hint: "tavily.com/app/api-keys" },
547
+ { id: "brave", env: "BRAVE_API_KEY", label: "Brave Search", hint: "brave.com/search/api" },
548
+ { id: "context7", env: "CONTEXT7_API_KEY", label: "Context7 Docs", hint: "context7.com/dashboard" },
549
+ { id: "jina", env: "JINA_API_KEY", label: "Jina Page Extract", hint: "jina.ai/api" },
550
+ { id: "groq", env: "GROQ_API_KEY", label: "Groq Voice", hint: "console.groq.com" },
551
+ ] as const;
552
+
553
+ function getConfigAuthStorage(): InstanceType<typeof AuthStorage> {
554
+ const authPath = join(process.env.HOME ?? "", ".gsd", "agent", "auth.json");
555
+ mkdirSync(dirname(authPath), { recursive: true });
556
+ return AuthStorage.create(authPath);
557
+ }
558
+
559
+ async function handleConfig(ctx: ExtensionCommandContext): Promise<void> {
560
+ const auth = getConfigAuthStorage();
561
+
562
+ // Show current status
563
+ const statusLines = ["GSD Tool Configuration\n"];
564
+ for (const tool of TOOL_KEYS) {
565
+ const hasKey = !!process.env[tool.env] || !!(auth.get(tool.id) as { key?: string })?.key;
566
+ statusLines.push(` ${hasKey ? "✓" : "✗"} ${tool.label}${hasKey ? "" : ` — get key at ${tool.hint}`}`);
567
+ }
568
+ ctx.ui.notify(statusLines.join("\n"), "info");
569
+
570
+ // Ask which tools to configure
571
+ const options = TOOL_KEYS.map(t => {
572
+ const hasKey = !!process.env[t.env] || !!(auth.get(t.id) as { key?: string })?.key;
573
+ return `${t.label} ${hasKey ? "(configured ✓)" : "(not set)"}`;
574
+ });
575
+ options.push("(done)");
576
+
577
+ let changed = false;
578
+ while (true) {
579
+ const choice = await ctx.ui.select("Configure which tool? Press Escape when done.", options);
580
+ if (!choice || choice === "(done)") break;
581
+
582
+ const toolIdx = TOOL_KEYS.findIndex(t => choice.startsWith(t.label));
583
+ if (toolIdx === -1) break;
584
+
585
+ const tool = TOOL_KEYS[toolIdx];
586
+ const input = await ctx.ui.input(
587
+ `API key for ${tool.label} (${tool.hint}):`,
588
+ "paste your key here",
589
+ );
590
+
591
+ if (input !== null && input !== undefined) {
592
+ const key = input.trim();
593
+ if (key) {
594
+ auth.set(tool.id, { type: "api_key", key });
595
+ process.env[tool.env] = key;
596
+ ctx.ui.notify(`${tool.label} key saved and activated.`, "info");
597
+ // Update option label
598
+ options[toolIdx] = `${tool.label} (configured ✓)`;
599
+ changed = true;
600
+ }
601
+ }
602
+ }
603
+
604
+ if (changed) {
605
+ await ctx.waitForIdle();
606
+ await ctx.reload();
607
+ ctx.ui.notify("Configuration saved. Extensions reloaded with new keys.", "info");
608
+ }
609
+ }
610
+
524
611
  async function ensurePreferencesFile(
525
612
  path: string,
526
613
  ctx: ExtensionCommandContext,
@@ -538,7 +625,4 @@ async function ensurePreferencesFile(
538
625
  ctx.ui.notify(`Using existing ${scope} GSD skill preferences at ${path}`, "info");
539
626
  }
540
627
 
541
- await ctx.waitForIdle();
542
- await ctx.reload();
543
- ctx.ui.notify(`Edit ${path} to update ${scope} GSD skill preferences.`, "info");
544
628
  }
@@ -1,6 +1,6 @@
1
1
  import { execSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync } from "node:fs";
3
- import { join } from "node:path";
3
+ import { join, sep } from "node:path";
4
4
 
5
5
  import { loadFile, parsePlan, parseRoadmap, parseSummary, saveFile, parseTaskPlanMustHaves, countMustHavesMentionedInSummary } from "./files.js";
6
6
  import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTaskFiles, resolveTasksDir, milestonesDir, gsdRoot, relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relGsdRootFile, resolveGsdRootFile } from "./paths.js";
@@ -31,7 +31,8 @@ export type DoctorIssueCode =
31
31
  | "orphaned_auto_worktree"
32
32
  | "stale_milestone_branch"
33
33
  | "corrupt_merge_state"
34
- | "tracked_runtime_files";
34
+ | "tracked_runtime_files"
35
+ | "legacy_slice_branches";
35
36
 
36
37
  export interface DoctorIssue {
37
38
  severity: DoctorSeverity;
@@ -511,7 +512,7 @@ async function checkGitHealth(
511
512
  if (shouldFix("orphaned_auto_worktree")) {
512
513
  // Never remove a worktree matching current working directory
513
514
  const cwd = process.cwd();
514
- if (wt.path === cwd || cwd.startsWith(wt.path + "/")) {
515
+ if (wt.path === cwd || cwd.startsWith(wt.path + sep)) {
515
516
  fixesApplied.push(`skipped removing worktree at ${wt.path} (is cwd)`);
516
517
  } else {
517
518
  try {
@@ -527,7 +528,9 @@ async function checkGitHealth(
527
528
 
528
529
  // ── Stale milestone branches ─────────────────────────────────────────
529
530
  try {
530
- const branchOutput = execSync("git branch --list 'milestone/*'", { cwd: basePath, stdio: "pipe" }).toString().trim();
531
+ // Use unquoted glob single quotes are not interpreted by cmd.exe on Windows,
532
+ // causing the pattern to match literally instead of as a glob.
533
+ const branchOutput = execSync("git branch --list milestone/*", { cwd: basePath, stdio: "pipe" }).toString().trim();
531
534
  if (branchOutput) {
532
535
  const branches = branchOutput.split("\n").map(b => b.trim().replace(/^\*\s*/, "")).filter(Boolean);
533
536
  const worktreeBranches = new Set(milestoneWorktrees.map(wt => wt.branch));
@@ -640,6 +643,28 @@ async function checkGitHealth(
640
643
  } catch {
641
644
  // git ls-files failed — skip
642
645
  }
646
+
647
+ // ── Legacy slice branches ──────────────────────────────────────────────
648
+ try {
649
+ const sliceBranches = execSync('git branch --format="%(refname:short)" --list "gsd/*/*"', {
650
+ cwd: basePath,
651
+ stdio: ["ignore", "pipe", "pipe"],
652
+ encoding: "utf-8",
653
+ }).trim();
654
+ if (sliceBranches) {
655
+ const branchList = sliceBranches.split("\n").map(b => b.trim()).filter(Boolean);
656
+ issues.push({
657
+ severity: "info",
658
+ code: "legacy_slice_branches",
659
+ scope: "project",
660
+ unitId: "project",
661
+ message: `${branchList.length} legacy slice branch(es) found: ${branchList.slice(0, 3).join(", ")}${branchList.length > 3 ? "..." : ""}. These are no longer used (branchless architecture). Delete with: git branch -D ${branchList.join(" ")}`,
662
+ fixable: false,
663
+ });
664
+ }
665
+ } catch {
666
+ // git branch list failed — skip
667
+ }
643
668
  }
644
669
 
645
670
  export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; scope?: string; fixLevel?: "task" | "all" }): Promise<DoctorReport> {
@@ -83,77 +83,6 @@ export function abortAndReset(cwd: string): AbortAndResetResult {
83
83
  return { cleaned };
84
84
  }
85
85
 
86
- /**
87
- * Wrap a merge operation with self-healing retry logic.
88
- *
89
- * Calls `mergeFn()`. On failure:
90
- * - If conflicted files exist (via `git diff --diff-filter=U`), re-throws
91
- * as MergeConflictError immediately — no retry for real code conflicts.
92
- * - Otherwise, runs `abortAndReset(cwd)`, retries `mergeFn()` once.
93
- * - On second failure, throws the error.
94
- *
95
- * @param cwd - Working directory for git operations
96
- * @param mergeFn - Synchronous function that performs the merge
97
- * @returns The return value of `mergeFn()`
98
- */
99
- export function withMergeHeal<T>(cwd: string, mergeFn: () => T): T {
100
- try {
101
- return mergeFn();
102
- } catch (firstError) {
103
- // Check for real code conflicts — escalate immediately, no retry
104
- try {
105
- const conflictOutput = execSync("git diff --name-only --diff-filter=U", {
106
- cwd,
107
- encoding: "utf-8",
108
- stdio: ["pipe", "pipe", "pipe"],
109
- }).trim();
110
-
111
- if (conflictOutput.length > 0) {
112
- const conflictedFiles = conflictOutput.split("\n").filter(Boolean);
113
- // If the original error is already a MergeConflictError, re-throw as-is
114
- if (firstError instanceof MergeConflictError) {
115
- throw firstError;
116
- }
117
- throw new MergeConflictError(
118
- conflictedFiles,
119
- "merge",
120
- "unknown",
121
- "unknown",
122
- );
123
- }
124
- } catch (diffErr) {
125
- // If diffErr is a MergeConflictError we just created/re-threw, propagate it
126
- if (diffErr instanceof MergeConflictError) throw diffErr;
127
- // Otherwise git diff itself failed — proceed with retry
128
- }
129
-
130
- // No real conflict detected — try abort+reset+retry once
131
- abortAndReset(cwd);
132
-
133
- // Retry
134
- return mergeFn();
135
- }
136
- }
137
-
138
- /**
139
- * Recover a failed checkout by resetting first, then checking out.
140
- *
141
- * Performs `git reset --hard HEAD` then `git checkout <targetBranch>`.
142
- * If checkout still fails after reset, throws with context.
143
- */
144
- export function recoverCheckout(cwd: string, targetBranch: string): void {
145
- execSync("git reset --hard HEAD", { cwd, stdio: "pipe" });
146
-
147
- try {
148
- execSync(`git checkout ${targetBranch}`, { cwd, stdio: "pipe" });
149
- } catch (err) {
150
- const msg = err instanceof Error ? err.message : String(err);
151
- throw new Error(
152
- `recoverCheckout failed: could not checkout '${targetBranch}' after reset. ${msg}`,
153
- );
154
- }
155
- }
156
-
157
86
  /** Known git error patterns mapped to user-friendly messages. */
158
87
  const ERROR_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
159
88
  {