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
@@ -1,353 +0,0 @@
1
- /**
2
- * Tests for orphaned completed slice branch detection.
3
- *
4
- * Verifies the git operations and detection logic that mergeOrphanedSliceBranches
5
- * in auto.ts relies on — without importing auto.ts (which requires @gsd/pi-coding-agent).
6
- * Uses execSync directly and roadmap-slices.ts (no pi-coding-agent dep) to replicate
7
- * the detection logic.
8
- */
9
-
10
- import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
11
- import { execSync, execFileSync } from "node:child_process";
12
- import { join } from "node:path";
13
- import { tmpdir } from "node:os";
14
-
15
- import { relMilestoneFile } from "../paths.ts";
16
- import { parseRoadmapSlices } from "../roadmap-slices.ts";
17
-
18
- // Inline SLICE_BRANCH_RE and parseSliceBranch to avoid importing worktree.ts,
19
- // which transitively imports preferences.ts → @gsd/pi-coding-agent (not available in tests).
20
- const SLICE_BRANCH_RE = /^gsd\/(?:([a-zA-Z0-9_-]+)\/)?(M\d+)\/(S\d+)$/;
21
-
22
- function parseSliceBranch(
23
- branchName: string,
24
- ): { worktreeName: string | null; milestoneId: string; sliceId: string } | null {
25
- const match = branchName.match(SLICE_BRANCH_RE);
26
- if (!match) return null;
27
- return { worktreeName: match[1] ?? null, milestoneId: match[2]!, sliceId: match[3]! };
28
- }
29
-
30
- let passed = 0;
31
- let failed = 0;
32
-
33
- function assert(condition: boolean, message: string): void {
34
- if (condition) {
35
- passed++;
36
- } else {
37
- failed++;
38
- console.error(` FAIL: ${message}`);
39
- }
40
- }
41
-
42
- function assertEq<T>(actual: T, expected: T, message: string): void {
43
- if (JSON.stringify(actual) === JSON.stringify(expected)) {
44
- passed++;
45
- } else {
46
- failed++;
47
- console.error(
48
- ` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
49
- );
50
- }
51
- }
52
-
53
- function run(command: string, cwd: string): string {
54
- return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
55
- }
56
-
57
- function git(base: string, args: string[]): string {
58
- try {
59
- return execFileSync("git", args, {
60
- cwd: base,
61
- stdio: ["ignore", "pipe", "pipe"],
62
- encoding: "utf-8",
63
- }).trim();
64
- } catch {
65
- return "";
66
- }
67
- }
68
-
69
- /**
70
- * Replicate the core orphan-detection logic from mergeOrphanedSliceBranches
71
- * in auto.ts — using only paths.ts + roadmap-slices.ts + execSync (no pi-coding-agent deps).
72
- * Returns a list of orphaned branch descriptors.
73
- */
74
- function detectOrphanedSliceBranches(base: string): Array<{
75
- branch: string;
76
- milestoneId: string;
77
- sliceId: string;
78
- sliceTitle: string;
79
- }> {
80
- const orphans: Array<{
81
- branch: string;
82
- milestoneId: string;
83
- sliceId: string;
84
- sliceTitle: string;
85
- }> = [];
86
-
87
- const branchListRaw = git(base, ["branch", "--list", "gsd/*/*", "--format=%(refname:short)"]);
88
- if (!branchListRaw) return orphans;
89
-
90
- const branches = branchListRaw.split("\n").map(b => b.trim()).filter(Boolean);
91
- for (const branch of branches) {
92
- const parsed = parseSliceBranch(branch);
93
- // Skip worktree-namespaced branches
94
- if (!parsed || parsed.worktreeName) continue;
95
-
96
- const { milestoneId, sliceId } = parsed;
97
-
98
- // Skip if already merged (no commits ahead of main)
99
- const aheadCount = git(base, ["rev-list", "--count", `main..${branch}`]);
100
- if (!aheadCount || aheadCount === "0") continue;
101
-
102
- // Read roadmap from the slice branch
103
- const roadmapRelPath = relMilestoneFile(base, milestoneId, "ROADMAP");
104
- const roadmapContent = git(base, ["show", `${branch}:${roadmapRelPath}`]);
105
- if (!roadmapContent) continue;
106
-
107
- const slices = parseRoadmapSlices(roadmapContent);
108
- const sliceEntry = slices.find(s => s.id === sliceId);
109
- if (!sliceEntry?.done) continue;
110
-
111
- orphans.push({
112
- branch,
113
- milestoneId,
114
- sliceId,
115
- sliceTitle: sliceEntry.title || sliceId,
116
- });
117
- }
118
-
119
- return orphans;
120
- }
121
-
122
- // ─── Setup helpers ─────────────────────────────────────────────────────────
123
-
124
- function initRepo(): string {
125
- const repo = mkdtempSync(join(tmpdir(), "gsd-orphan-test-"));
126
- run("git init -b main", repo);
127
- run("git config user.email test@example.com", repo);
128
- run("git config user.name Test", repo);
129
- return repo;
130
- }
131
-
132
- function writeBaseArtifacts(repo: string): void {
133
- mkdirSync(join(repo, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), {
134
- recursive: true,
135
- });
136
-
137
- writeFileSync(
138
- join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"),
139
- [
140
- "# M001: Demo",
141
- "",
142
- "## Slices",
143
- "- [ ] **S01: First Slice** `risk:low` `depends:[]`",
144
- " > After this: feature works",
145
- "",
146
- ].join("\n"),
147
- );
148
-
149
- writeFileSync(
150
- join(repo, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"),
151
- "# S01: First Slice\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Must-Haves\n- done\n\n## Tasks\n- [x] **T01: Task** `est:5m`\n do it\n",
152
- );
153
- run("git add .", repo);
154
- run('git commit -m "chore: milestone base"', repo);
155
- }
156
-
157
- function writeCompletedArtifactsOnBranch(repo: string): void {
158
- writeFileSync(
159
- join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"),
160
- [
161
- "# M001: Demo",
162
- "",
163
- "## Slices",
164
- "- [x] **S01: First Slice** `risk:low` `depends:[]`",
165
- " > After this: feature works",
166
- "",
167
- ].join("\n"),
168
- );
169
- writeFileSync(
170
- join(repo, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"),
171
- "# S01: First Slice\n\nDone.\n",
172
- );
173
- writeFileSync(
174
- join(repo, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT.md"),
175
- "# UAT\n\nPassed.\n",
176
- );
177
- run("git add .", repo);
178
- run('git commit -m "feat(M001/S01): complete-slice"', repo);
179
- }
180
-
181
- // ─── Tests ────────────────────────────────────────────────────────────────────
182
-
183
- console.log("\n=== parseSliceBranch: plain branch ===");
184
- {
185
- const parsed = parseSliceBranch("gsd/M001/S01");
186
- assert(parsed !== null, "plain branch parsed");
187
- assertEq(parsed?.milestoneId, "M001", "milestone ID extracted");
188
- assertEq(parsed?.sliceId, "S01", "slice ID extracted");
189
- assertEq(parsed?.worktreeName, null, "no worktree name for plain branch");
190
- }
191
-
192
- console.log("\n=== parseSliceBranch: worktree-namespaced branch ===");
193
- {
194
- const parsed = parseSliceBranch("gsd/wt1/M001/S01");
195
- assert(parsed !== null, "worktree branch parsed");
196
- assertEq(parsed?.milestoneId, "M001", "milestone ID extracted from worktree branch");
197
- assertEq(parsed?.sliceId, "S01", "slice ID extracted from worktree branch");
198
- assertEq(parsed?.worktreeName, "wt1", "worktree name extracted");
199
- }
200
-
201
- console.log("\n=== parseSliceBranch: non-slice branch not matched ===");
202
- {
203
- assert(parseSliceBranch("main") === null, "main branch not matched");
204
- assert(parseSliceBranch("gsd/M001") === null, "bare milestone branch not matched");
205
- assert(!SLICE_BRANCH_RE.test("gsd/M001"), "bare milestone branch not matched by regex");
206
- assert(SLICE_BRANCH_RE.test("gsd/M001/S01"), "standard slice branch matched by regex");
207
- }
208
-
209
- console.log("\n=== orphan detection: no slice branches ===");
210
- {
211
- const repo = initRepo();
212
- writeBaseArtifacts(repo);
213
-
214
- const orphans = detectOrphanedSliceBranches(repo);
215
- assertEq(orphans.length, 0, "no orphans when no slice branches exist");
216
-
217
- rmSync(repo, { recursive: true, force: true });
218
- }
219
-
220
- console.log("\n=== orphan detection: slice branch not done ===");
221
- {
222
- const repo = initRepo();
223
- writeBaseArtifacts(repo);
224
-
225
- run("git checkout -b gsd/M001/S01", repo);
226
- writeFileSync(
227
- join(repo, ".gsd", "milestones", "M001", "slices", "S01", "S01-RESEARCH.md"),
228
- "# Research\n",
229
- );
230
- run("git add .", repo);
231
- run('git commit -m "feat: research"', repo);
232
- run("git checkout main", repo);
233
-
234
- const orphans = detectOrphanedSliceBranches(repo);
235
- assertEq(orphans.length, 0, "incomplete slice branch is not reported as orphan");
236
-
237
- rmSync(repo, { recursive: true, force: true });
238
- }
239
-
240
- console.log("\n=== orphan detection: completed slice branch (orphaned) ===");
241
- {
242
- const repo = initRepo();
243
- writeBaseArtifacts(repo);
244
-
245
- run("git checkout -b gsd/M001/S01", repo);
246
- writeCompletedArtifactsOnBranch(repo);
247
- // Return to main without merging — this is the orphaned branch scenario
248
- run("git checkout main", repo);
249
-
250
- const orphans = detectOrphanedSliceBranches(repo);
251
- assertEq(orphans.length, 1, "completed but unmerged branch detected as orphan");
252
- assertEq(orphans[0]?.branch, "gsd/M001/S01", "correct branch name reported");
253
- assertEq(orphans[0]?.milestoneId, "M001", "correct milestone ID");
254
- assertEq(orphans[0]?.sliceId, "S01", "correct slice ID");
255
- assertEq(orphans[0]?.sliceTitle, "First Slice", "correct slice title");
256
-
257
- rmSync(repo, { recursive: true, force: true });
258
- }
259
-
260
- console.log("\n=== orphan detection: already merged branch is not orphan ===");
261
- {
262
- const repo = initRepo();
263
- writeBaseArtifacts(repo);
264
-
265
- run("git checkout -b gsd/M001/S01", repo);
266
- writeCompletedArtifactsOnBranch(repo);
267
- run("git checkout main", repo);
268
- run("git merge --squash gsd/M001/S01", repo);
269
- run('git commit -m "feat(M001/S01): merge"', repo);
270
- run("git branch -D gsd/M001/S01", repo);
271
-
272
- const orphans = detectOrphanedSliceBranches(repo);
273
- assertEq(orphans.length, 0, "already-merged branch is not detected as orphan");
274
-
275
- rmSync(repo, { recursive: true, force: true });
276
- }
277
-
278
- console.log("\n=== orphan detection: worktree-namespaced branch is skipped ===");
279
- {
280
- const repo = initRepo();
281
- writeBaseArtifacts(repo);
282
-
283
- // gsd/wt1/M001/S01 — worktree-namespaced branches are managed by the worktree
284
- // manager and must not be merged by the main-tree orphan check.
285
- run("git checkout -b gsd/wt1/M001/S01", repo);
286
- writeCompletedArtifactsOnBranch(repo);
287
- run("git checkout main", repo);
288
-
289
- const orphans = detectOrphanedSliceBranches(repo);
290
- assertEq(orphans.length, 0, "worktree-namespaced branch not detected by main-tree orphan check");
291
-
292
- rmSync(repo, { recursive: true, force: true });
293
- }
294
-
295
- console.log("\n=== orphan detection: relMilestoneFile resolves roadmap path for git show ===");
296
- {
297
- const repo = initRepo();
298
- writeBaseArtifacts(repo);
299
-
300
- run("git checkout -b gsd/M001/S01", repo);
301
- writeCompletedArtifactsOnBranch(repo);
302
- run("git checkout main", repo);
303
-
304
- // Simulate what mergeOrphanedSliceBranches does: read roadmap from branch
305
- const roadmapRelPath = relMilestoneFile(repo, "M001", "ROADMAP");
306
- const roadmapOnBranch = git(repo, ["show", `gsd/M001/S01:${roadmapRelPath}`]);
307
- assert(roadmapOnBranch.length > 0, "roadmap readable from orphaned branch via git show");
308
-
309
- const slices = parseRoadmapSlices(roadmapOnBranch);
310
- const s01 = slices.find(s => s.id === "S01");
311
- assert(s01?.done === true, "slice marked done on orphaned branch");
312
-
313
- rmSync(repo, { recursive: true, force: true });
314
- }
315
-
316
- console.log("\n=== orphan merge: squash-merge resolves orphan, artifacts appear on main ===");
317
- {
318
- const repo = initRepo();
319
- writeBaseArtifacts(repo);
320
-
321
- run("git checkout -b gsd/M001/S01", repo);
322
- writeCompletedArtifactsOnBranch(repo);
323
- run("git checkout main", repo);
324
-
325
- const orphansBefore = detectOrphanedSliceBranches(repo);
326
- assertEq(orphansBefore.length, 1, "orphan detected before merge");
327
-
328
- // Perform squash-merge (as mergeOrphanedSliceBranches does via mergeSliceToMain)
329
- run("git merge --squash gsd/M001/S01", repo);
330
- run('git commit -m "feat(M001/S01): recover orphaned branch"', repo);
331
- run("git branch -D gsd/M001/S01", repo);
332
-
333
- // Verify artifacts are now on main
334
- assert(
335
- existsSync(
336
- join(repo, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"),
337
- ),
338
- "SUMMARY merged to main after orphan recovery",
339
- );
340
- assert(
341
- existsSync(join(repo, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT.md")),
342
- "UAT merged to main after orphan recovery",
343
- );
344
-
345
- // Orphan no longer detected after merge + branch delete
346
- const orphansAfter = detectOrphanedSliceBranches(repo);
347
- assertEq(orphansAfter.length, 0, "no orphans after merge and branch deletion");
348
-
349
- rmSync(repo, { recursive: true, force: true });
350
- }
351
-
352
- console.log(`\nResults: ${passed} passed, ${failed} failed`);
353
- if (failed > 0) process.exit(1);
@@ -1,282 +0,0 @@
1
- /**
2
- * auto-worktree-merge.test.ts — Integration tests for mergeSliceToMilestone.
3
- *
4
- * Covers: --no-ff merge topology, rich commit messages, slice branch deletion,
5
- * zero-commit error, real code conflicts, .gsd/ non-conflict in worktree mode.
6
- * All tests use real git operations in temp repos.
7
- */
8
-
9
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync } from "node:fs";
10
- import { join } from "node:path";
11
- import { tmpdir } from "node:os";
12
- import { execSync } from "node:child_process";
13
-
14
- import {
15
- createAutoWorktree,
16
- teardownAutoWorktree,
17
- mergeSliceToMilestone,
18
- } from "../auto-worktree.ts";
19
- import { MergeConflictError } from "../git-service.ts";
20
- import { getSliceBranchName } from "../worktree.ts";
21
-
22
- import { createTestContext } from "./test-helpers.ts";
23
-
24
- const { assertEq, assertTrue, assertMatch, report } = createTestContext();
25
-
26
- function run(cmd: string, cwd: string): string {
27
- return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
28
- }
29
-
30
- function createTempRepo(): string {
31
- const dir = realpathSync(mkdtempSync(join(tmpdir(), "wt-merge-test-")));
32
- run("git init", dir);
33
- run("git config user.email test@test.com", dir);
34
- run("git config user.name Test", dir);
35
- writeFileSync(join(dir, "README.md"), "# test\n");
36
- mkdirSync(join(dir, ".gsd"), { recursive: true });
37
- writeFileSync(join(dir, ".gsd", "STATE.md"), "# State\n");
38
- run("git add .", dir);
39
- run("git commit -m init", dir);
40
- run("git branch -M main", dir);
41
- return dir;
42
- }
43
-
44
- /** Create a slice branch in the worktree, add commits, return branch name. */
45
- function setupSliceBranch(
46
- wtPath: string,
47
- milestoneId: string,
48
- sliceId: string,
49
- commits: Array<{ file: string; content: string; message: string }>,
50
- ): string {
51
- // Detect worktree name for branch naming
52
- const normalizedPath = wtPath.replaceAll("\\", "/");
53
- const marker = "/.gsd/worktrees/";
54
- const idx = normalizedPath.indexOf(marker);
55
- const worktreeName = idx !== -1 ? normalizedPath.slice(idx + marker.length).split("/")[0] : null;
56
- const sliceBranch = getSliceBranchName(milestoneId, sliceId, worktreeName);
57
-
58
- run(`git checkout -b ${sliceBranch}`, wtPath);
59
- for (const c of commits) {
60
- writeFileSync(join(wtPath, c.file), c.content);
61
- run("git add .", wtPath);
62
- run(`git commit -m "${c.message}"`, wtPath);
63
- }
64
- return sliceBranch;
65
- }
66
-
67
- async function main(): Promise<void> {
68
- const savedCwd = process.cwd();
69
- const tempDirs: string[] = [];
70
-
71
- function freshRepo(): string {
72
- const d = createTempRepo();
73
- tempDirs.push(d);
74
- return d;
75
- }
76
-
77
- try {
78
- // ─── Test 1: Single slice --no-ff merge ────────────────────────────
79
- console.log("\n=== single slice --no-ff merge ===");
80
- {
81
- const repo = freshRepo();
82
- const wtPath = createAutoWorktree(repo, "M003");
83
-
84
- const sliceBranch = setupSliceBranch(wtPath, "M003", "S01", [
85
- { file: "a.ts", content: "const a = 1;\n", message: "add a.ts" },
86
- { file: "b.ts", content: "const b = 2;\n", message: "add b.ts" },
87
- { file: "c.ts", content: "const c = 3;\n", message: "add c.ts" },
88
- ]);
89
- run("git checkout milestone/M003", wtPath);
90
-
91
- const result = mergeSliceToMilestone(repo, "M003", "S01", "Add core files");
92
-
93
- // Verify we're back on milestone branch
94
- const branch = run("git branch --show-current", wtPath);
95
- assertEq(branch, "milestone/M003", "back on milestone branch after merge");
96
-
97
- // Verify merge topology via git log --graph
98
- const log = run("git log --oneline --graph", wtPath);
99
- assertTrue(log.includes("* "), "merge commit visible in graph (asterisk with two parents)");
100
- assertTrue(log.includes("add a.ts"), "slice commit 'add a.ts' visible");
101
- assertTrue(log.includes("add b.ts"), "slice commit 'add b.ts' visible");
102
- assertTrue(log.includes("add c.ts"), "slice commit 'add c.ts' visible");
103
-
104
- // Verify commit message format
105
- assertMatch(result.mergedCommitMessage, /feat\(M003\/S01\)/, "commit message has conventional format");
106
- assertTrue(result.mergedCommitMessage.includes("Add core files"), "commit message includes slice title");
107
-
108
- // Verify slice branch deleted
109
- assertTrue(result.deletedBranch, "slice branch deleted");
110
- const branches = run("git branch", wtPath);
111
- assertTrue(!branches.includes(sliceBranch), "slice branch no longer in git branch list");
112
-
113
- teardownAutoWorktree(repo, "M003");
114
- }
115
-
116
- // ─── Test 2: Two sequential slices ─────────────────────────────────
117
- console.log("\n=== two sequential slices ===");
118
- {
119
- const repo = freshRepo();
120
- const wtPath = createAutoWorktree(repo, "M003");
121
-
122
- // Slice S01
123
- setupSliceBranch(wtPath, "M003", "S01", [
124
- { file: "s1.ts", content: "export const s1 = 1;\n", message: "s1 work" },
125
- ]);
126
- run("git checkout milestone/M003", wtPath);
127
- mergeSliceToMilestone(repo, "M003", "S01", "First slice");
128
-
129
- // Slice S02
130
- setupSliceBranch(wtPath, "M003", "S02", [
131
- { file: "s2.ts", content: "export const s2 = 2;\n", message: "s2 work" },
132
- ]);
133
- run("git checkout milestone/M003", wtPath);
134
- mergeSliceToMilestone(repo, "M003", "S02", "Second slice");
135
-
136
- // Verify two merge boundaries
137
- const log = run("git log --oneline --graph", wtPath);
138
- const mergeLines = log.split("\n").filter(l => l.includes("* "));
139
- assertTrue(mergeLines.length >= 2, "two distinct merge commits in graph");
140
- assertTrue(log.includes("s1 work"), "S01 commit visible");
141
- assertTrue(log.includes("s2 work"), "S02 commit visible");
142
-
143
- teardownAutoWorktree(repo, "M003");
144
- }
145
-
146
- // ─── Test 3: Zero commits throws ───────────────────────────────────
147
- console.log("\n=== zero commits throws ===");
148
- {
149
- const repo = freshRepo();
150
- const wtPath = createAutoWorktree(repo, "M003");
151
-
152
- // Create slice branch with no commits ahead
153
- const normalizedPath = wtPath.replaceAll("\\", "/");
154
- const marker = "/.gsd/worktrees/";
155
- const idx = normalizedPath.indexOf(marker);
156
- const worktreeName = idx !== -1 ? normalizedPath.slice(idx + marker.length).split("/")[0] : null;
157
- const sliceBranch = getSliceBranchName("M003", "S01", worktreeName);
158
- run(`git checkout -b ${sliceBranch}`, wtPath);
159
- // No commits — immediately try to merge
160
- run(`git checkout milestone/M003`, wtPath);
161
-
162
- let threw = false;
163
- try {
164
- mergeSliceToMilestone(repo, "M003", "S01", "Empty slice");
165
- } catch (err) {
166
- threw = true;
167
- assertTrue(
168
- err instanceof Error && err.message.includes("no commits ahead"),
169
- "error message mentions no commits ahead",
170
- );
171
- }
172
- assertTrue(threw, "mergeSliceToMilestone throws on zero commits");
173
-
174
- teardownAutoWorktree(repo, "M003");
175
- }
176
-
177
- // ─── Test 4: Real code conflict throws MergeConflictError ──────────
178
- console.log("\n=== real code conflict throws MergeConflictError ===");
179
- {
180
- const repo = freshRepo();
181
- const wtPath = createAutoWorktree(repo, "M003");
182
-
183
- // Add a file on milestone branch
184
- writeFileSync(join(wtPath, "shared.ts"), "// version 1\n");
185
- run("git add .", wtPath);
186
- run('git commit -m "add shared.ts"', wtPath);
187
-
188
- // Create slice branch, modify same file differently
189
- const normalizedPath = wtPath.replaceAll("\\", "/");
190
- const marker = "/.gsd/worktrees/";
191
- const idx = normalizedPath.indexOf(marker);
192
- const worktreeName = idx !== -1 ? normalizedPath.slice(idx + marker.length).split("/")[0] : null;
193
- const sliceBranch = getSliceBranchName("M003", "S01", worktreeName);
194
- run(`git checkout -b ${sliceBranch}`, wtPath);
195
- writeFileSync(join(wtPath, "shared.ts"), "// slice version\nexport const x = 1;\n");
196
- run("git add .", wtPath);
197
- run('git commit -m "slice edit shared.ts"', wtPath);
198
-
199
- // Modify same file on milestone branch
200
- run("git checkout milestone/M003", wtPath);
201
- writeFileSync(join(wtPath, "shared.ts"), "// milestone version\nexport const y = 2;\n");
202
- run("git add .", wtPath);
203
- run('git commit -m "milestone edit shared.ts"', wtPath);
204
-
205
- // Go back to milestone branch for merge call
206
- run("git checkout milestone/M003", wtPath);
207
-
208
- let caught: MergeConflictError | null = null;
209
- try {
210
- mergeSliceToMilestone(repo, "M003", "S01", "Conflicting slice");
211
- } catch (err) {
212
- if (err instanceof MergeConflictError) {
213
- caught = err;
214
- } else {
215
- throw err;
216
- }
217
- }
218
-
219
- assertTrue(caught !== null, "MergeConflictError thrown on conflict");
220
- if (caught) {
221
- assertTrue(caught.conflictedFiles.includes("shared.ts"), "conflictedFiles includes shared.ts");
222
- assertEq(caught.strategy, "merge", "strategy is merge");
223
- assertTrue(caught.branch.includes("S01"), "branch includes S01");
224
- }
225
-
226
- // Clean up conflict state before teardown
227
- run("git merge --abort || true", wtPath);
228
- run("git checkout milestone/M003", wtPath);
229
- teardownAutoWorktree(repo, "M003");
230
- }
231
-
232
- // ─── Test 5: .gsd/ changes don't conflict ─────────────────────────
233
- console.log("\n=== .gsd/ changes don't conflict ===");
234
- {
235
- const repo = freshRepo();
236
- const wtPath = createAutoWorktree(repo, "M003");
237
-
238
- // The .gsd/ directory in worktrees is local — it's not shared via git
239
- // between the main repo and the worktree. So modifications to .gsd/
240
- // files in both branches shouldn't cause conflicts because .gsd/ is
241
- // in the main repo's tree but the worktree has its own working copy.
242
- //
243
- // In the worktree, .gsd/ IS tracked (inherited from main). But since
244
- // slice branches diverge from milestone branch, .gsd/ changes on both
245
- // can conflict. The key insight: in real auto-mode, .gsd/ changes only
246
- // happen on the milestone branch (planning artifacts), not on slice
247
- // branches (which only have code changes). So we test that code-only
248
- // slice commits merge cleanly even when milestone has .gsd/ changes.
249
-
250
- // Add a .gsd/ change on milestone branch
251
- writeFileSync(join(wtPath, ".gsd", "STATE.md"), "# Updated State\nactive: M003\n");
252
- run("git add .", wtPath);
253
- run('git commit -m "update .gsd/STATE.md on milestone"', wtPath);
254
-
255
- // Create slice branch with code-only changes
256
- setupSliceBranch(wtPath, "M003", "S01", [
257
- { file: "feature.ts", content: "export const feature = true;\n", message: "add feature" },
258
- ]);
259
- run("git checkout milestone/M003", wtPath);
260
-
261
- // Merge should succeed — no .gsd/ conflict since slice didn't touch .gsd/
262
- const result = mergeSliceToMilestone(repo, "M003", "S01", "Feature slice");
263
- assertTrue(result.branch.includes("S01"), ".gsd/ no-conflict merge succeeded");
264
- assertTrue(result.deletedBranch, "slice branch deleted after .gsd/-safe merge");
265
-
266
- // Verify feature file exists after merge
267
- assertTrue(existsSync(join(wtPath, "feature.ts")), "feature.ts present after merge");
268
-
269
- teardownAutoWorktree(repo, "M003");
270
- }
271
-
272
- } finally {
273
- process.chdir(savedCwd);
274
- for (const d of tempDirs) {
275
- if (existsSync(d)) rmSync(d, { recursive: true, force: true });
276
- }
277
- }
278
-
279
- report();
280
- }
281
-
282
- main();