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.
- package/README.md +3 -3
- package/dist/cli.js +1 -0
- package/dist/loader.js +50 -6
- package/dist/resource-loader.d.ts +7 -6
- package/dist/resource-loader.js +15 -8
- package/dist/resources/extensions/gsd/auto-worktree.ts +29 -183
- package/dist/resources/extensions/gsd/auto.ts +252 -370
- package/dist/resources/extensions/gsd/commands.ts +118 -34
- package/dist/resources/extensions/gsd/doctor.ts +29 -4
- package/dist/resources/extensions/gsd/git-self-heal.ts +0 -71
- package/dist/resources/extensions/gsd/git-service.ts +8 -431
- package/dist/resources/extensions/gsd/gitignore.ts +11 -4
- package/dist/resources/extensions/gsd/guided-flow.ts +141 -5
- package/dist/resources/extensions/gsd/preferences.ts +18 -17
- package/dist/resources/extensions/gsd/prompts/discuss.md +35 -0
- package/dist/resources/extensions/gsd/prompts/queue.md +7 -1
- package/dist/resources/extensions/gsd/state.ts +26 -8
- package/dist/resources/extensions/gsd/templates/state.md +0 -1
- package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +3 -2
- package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +1 -1
- package/dist/resources/extensions/gsd/tests/derive-state.test.ts +35 -0
- package/dist/resources/extensions/gsd/tests/doctor-git.test.ts +22 -4
- package/dist/resources/extensions/gsd/tests/draft-promotion.test.ts +2 -1
- package/dist/resources/extensions/gsd/tests/git-self-heal.test.ts +8 -111
- package/dist/resources/extensions/gsd/tests/git-service.test.ts +11 -770
- package/dist/resources/extensions/gsd/tests/idle-recovery.test.ts +21 -113
- package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +16 -82
- package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +29 -50
- package/dist/resources/extensions/gsd/tests/worktree-e2e.test.ts +17 -91
- package/dist/resources/extensions/gsd/tests/worktree-integration.test.ts +28 -55
- package/dist/resources/extensions/gsd/tests/worktree.test.ts +1 -426
- package/dist/resources/extensions/gsd/types.ts +0 -1
- package/dist/resources/extensions/gsd/worktree-manager.ts +7 -3
- package/dist/resources/extensions/gsd/worktree.ts +7 -65
- package/dist/resources/extensions/search-the-web/command-search-provider.ts +3 -1
- package/package.json +1 -1
- package/packages/pi-ai/dist/providers/google.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/google.js +12 -4
- package/packages/pi-ai/dist/providers/google.js.map +1 -1
- package/packages/pi-ai/dist/providers/mistral.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/mistral.js +10 -2
- package/packages/pi-ai/dist/providers/mistral.js.map +1 -1
- package/packages/pi-ai/src/providers/google.ts +20 -8
- package/packages/pi-ai/src/providers/mistral.ts +14 -2
- package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts +3 -0
- package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/loader.js +10 -7
- package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js +4 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +12 -3
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/packages/pi-coding-agent/src/core/extensions/loader.ts +13 -9
- package/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts +4 -1
- package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +14 -3
- package/packages/pi-tui/dist/components/input.d.ts +1 -0
- package/packages/pi-tui/dist/components/input.d.ts.map +1 -1
- package/packages/pi-tui/dist/components/input.js +10 -0
- package/packages/pi-tui/dist/components/input.js.map +1 -1
- package/packages/pi-tui/src/components/input.ts +11 -0
- package/src/resources/extensions/gsd/auto-worktree.ts +29 -183
- package/src/resources/extensions/gsd/auto.ts +252 -370
- package/src/resources/extensions/gsd/commands.ts +118 -34
- package/src/resources/extensions/gsd/doctor.ts +29 -4
- package/src/resources/extensions/gsd/git-self-heal.ts +0 -71
- package/src/resources/extensions/gsd/git-service.ts +8 -431
- package/src/resources/extensions/gsd/gitignore.ts +11 -4
- package/src/resources/extensions/gsd/guided-flow.ts +141 -5
- package/src/resources/extensions/gsd/preferences.ts +18 -17
- package/src/resources/extensions/gsd/prompts/discuss.md +35 -0
- package/src/resources/extensions/gsd/prompts/queue.md +7 -1
- package/src/resources/extensions/gsd/state.ts +26 -8
- package/src/resources/extensions/gsd/templates/state.md +0 -1
- package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +3 -2
- package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/derive-state.test.ts +35 -0
- package/src/resources/extensions/gsd/tests/doctor-git.test.ts +22 -4
- package/src/resources/extensions/gsd/tests/draft-promotion.test.ts +2 -1
- package/src/resources/extensions/gsd/tests/git-self-heal.test.ts +8 -111
- package/src/resources/extensions/gsd/tests/git-service.test.ts +11 -770
- package/src/resources/extensions/gsd/tests/idle-recovery.test.ts +21 -113
- package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +16 -82
- package/src/resources/extensions/gsd/tests/preferences-git.test.ts +29 -50
- package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +17 -91
- package/src/resources/extensions/gsd/tests/worktree-integration.test.ts +28 -55
- package/src/resources/extensions/gsd/tests/worktree.test.ts +1 -426
- package/src/resources/extensions/gsd/types.ts +0 -1
- package/src/resources/extensions/gsd/worktree-manager.ts +7 -3
- package/src/resources/extensions/gsd/worktree.ts +7 -65
- package/src/resources/extensions/search-the-web/command-search-provider.ts +3 -1
- package/dist/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +0 -282
- package/dist/resources/extensions/gsd/tests/isolation-resolver.test.ts +0 -107
- package/dist/resources/extensions/gsd/tests/orphaned-branch.test.ts +0 -353
- package/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +0 -282
- package/src/resources/extensions/gsd/tests/isolation-resolver.test.ts +0 -107
- 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();
|