gsd-pi 2.3.8 → 2.3.10
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 +5 -2
- package/dist/cli.js +32 -2
- package/dist/logo.d.ts +16 -0
- package/dist/logo.js +25 -0
- package/dist/onboarding.d.ts +43 -0
- package/dist/onboarding.js +425 -0
- package/dist/wizard.js +8 -0
- package/package.json +3 -1
- package/scripts/postinstall.js +100 -28
- package/src/resources/GSD-WORKFLOW.md +2 -2
- package/src/resources/extensions/bg-shell/index.ts +2 -1
- package/src/resources/extensions/google-search/index.ts +1 -1
- package/src/resources/extensions/gsd/auto.ts +353 -144
- package/src/resources/extensions/gsd/files.ts +9 -7
- package/src/resources/extensions/gsd/index.ts +2 -1
- package/src/resources/extensions/gsd/metrics.ts +7 -5
- package/src/resources/extensions/gsd/migrate/command.ts +4 -1
- package/src/resources/extensions/gsd/migrate/validator.ts +5 -3
- package/src/resources/extensions/gsd/prompts/system.md +1 -1
- package/src/resources/extensions/gsd/tests/migrate-parser.test.ts +5 -5
- package/src/resources/extensions/gsd/tests/migrate-validator-parsers.test.ts +3 -3
- package/src/resources/extensions/gsd/tests/parsers.test.ts +94 -0
- package/src/resources/extensions/gsd/tests/resolve-ts-hooks.mjs +23 -6
- package/src/resources/extensions/gsd/tests/worktree-integration.test.ts +253 -0
- package/src/resources/extensions/gsd/tests/worktree.test.ts +116 -1
- package/src/resources/extensions/gsd/unit-runtime.ts +22 -1
- package/src/resources/extensions/gsd/workspace-index.ts +2 -2
- package/src/resources/extensions/gsd/worktree-command.ts +147 -41
- package/src/resources/extensions/gsd/worktree.ts +105 -8
- package/src/resources/extensions/mcporter/index.ts +21 -2
- package/src/resources/extensions/search-the-web/command-search-provider.ts +95 -0
- package/src/resources/extensions/search-the-web/http.ts +1 -1
- package/src/resources/extensions/search-the-web/index.ts +9 -3
- package/src/resources/extensions/search-the-web/provider.ts +118 -0
- package/src/resources/extensions/search-the-web/tavily.ts +116 -0
- package/src/resources/extensions/search-the-web/tool-llm-context.ts +265 -108
- package/src/resources/extensions/search-the-web/tool-search.ts +161 -88
- package/src/resources/extensions/shared/terminal.ts +23 -0
- package/src/resources/extensions/subagent/index.ts +1 -1
- package/src/resources/extensions/voice/index.ts +2 -1
|
@@ -347,21 +347,23 @@ export function parseSummary(content: string): Summary {
|
|
|
347
347
|
const [fmLines, body] = splitFrontmatter(content);
|
|
348
348
|
|
|
349
349
|
const fm = fmLines ? parseFrontmatterMap(fmLines) : {};
|
|
350
|
+
const asStringArray = (v: unknown): string[] =>
|
|
351
|
+
Array.isArray(v) ? v : (typeof v === 'string' && v ? [v] : []);
|
|
350
352
|
const frontmatter: SummaryFrontmatter = {
|
|
351
353
|
id: (fm.id as string) || '',
|
|
352
354
|
parent: (fm.parent as string) || '',
|
|
353
355
|
milestone: (fm.milestone as string) || '',
|
|
354
|
-
provides: (fm.provides
|
|
356
|
+
provides: asStringArray(fm.provides),
|
|
355
357
|
requires: ((fm.requires as Array<Record<string, string>>) || []).map(r => ({
|
|
356
358
|
slice: r.slice || '',
|
|
357
359
|
provides: r.provides || '',
|
|
358
360
|
})),
|
|
359
|
-
affects: (fm.affects
|
|
360
|
-
key_files: (fm.key_files
|
|
361
|
-
key_decisions: (fm.key_decisions
|
|
362
|
-
patterns_established: (fm.patterns_established
|
|
363
|
-
drill_down_paths: (fm.drill_down_paths
|
|
364
|
-
observability_surfaces: (fm.observability_surfaces
|
|
361
|
+
affects: asStringArray(fm.affects),
|
|
362
|
+
key_files: asStringArray(fm.key_files),
|
|
363
|
+
key_decisions: asStringArray(fm.key_decisions),
|
|
364
|
+
patterns_established: asStringArray(fm.patterns_established),
|
|
365
|
+
drill_down_paths: asStringArray(fm.drill_down_paths),
|
|
366
|
+
observability_surfaces: asStringArray(fm.observability_surfaces),
|
|
365
367
|
duration: (fm.duration as string) || '',
|
|
366
368
|
verification_result: (fm.verification_result as string) || 'untested',
|
|
367
369
|
completed_at: (fm.completed_at as string) || '',
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
import { Key } from "@mariozechner/pi-tui";
|
|
48
48
|
import { join } from "node:path";
|
|
49
49
|
import { existsSync } from "node:fs";
|
|
50
|
+
import { shortcutDesc } from "../shared/terminal.js";
|
|
50
51
|
import { Text } from "@mariozechner/pi-tui";
|
|
51
52
|
|
|
52
53
|
// ── ASCII logo ────────────────────────────────────────────────────────────
|
|
@@ -185,7 +186,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
185
186
|
|
|
186
187
|
// ── Ctrl+Alt+G shortcut — GSD dashboard overlay ────────────────────────
|
|
187
188
|
pi.registerShortcut(Key.ctrlAlt("g"), {
|
|
188
|
-
description: "Open GSD dashboard",
|
|
189
|
+
description: shortcutDesc("Open GSD dashboard", "/gsd status"),
|
|
189
190
|
handler: async (ctx) => {
|
|
190
191
|
// Only show if .gsd/ exists
|
|
191
192
|
if (!existsSync(join(process.cwd(), ".gsd"))) {
|
|
@@ -129,8 +129,9 @@ export function snapshotUnitMetrics(
|
|
|
129
129
|
tokens.cacheRead += msg.usage.cacheRead ?? 0;
|
|
130
130
|
tokens.cacheWrite += msg.usage.cacheWrite ?? 0;
|
|
131
131
|
tokens.total += msg.usage.totalTokens ?? 0;
|
|
132
|
-
if (msg.usage.cost) {
|
|
133
|
-
|
|
132
|
+
if (msg.usage.cost != null) {
|
|
133
|
+
const c = msg.usage.cost;
|
|
134
|
+
cost += typeof c === "number" ? c : (c.total ?? 0);
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
// Count tool calls in this message
|
|
@@ -296,9 +297,10 @@ export function getProjectTotals(units: UnitMetrics[]): ProjectTotals {
|
|
|
296
297
|
// ─── Formatting helpers ───────────────────────────────────────────────────────
|
|
297
298
|
|
|
298
299
|
export function formatCost(cost: number): string {
|
|
299
|
-
|
|
300
|
-
if (
|
|
301
|
-
return `$${
|
|
300
|
+
const n = Number(cost) || 0;
|
|
301
|
+
if (n < 0.01) return `$${n.toFixed(4)}`;
|
|
302
|
+
if (n < 1) return `$${n.toFixed(3)}`;
|
|
303
|
+
return `$${n.toFixed(2)}`;
|
|
302
304
|
}
|
|
303
305
|
|
|
304
306
|
/**
|
|
@@ -96,7 +96,10 @@ export async function handleMigrate(
|
|
|
96
96
|
|
|
97
97
|
if (!existsSync(sourcePath)) {
|
|
98
98
|
ctx.ui.notify(
|
|
99
|
-
`Directory not found: ${sourcePath}\n\
|
|
99
|
+
`Directory not found: ${sourcePath}\n\n` +
|
|
100
|
+
'Migration converts a .planning/ directory (from older GSD versions) into .gsd/ format.\n' +
|
|
101
|
+
'If you are starting a new project, use /gsd:new-project instead.\n' +
|
|
102
|
+
'If migrating, ensure the path contains a .planning/ directory.',
|
|
100
103
|
"error",
|
|
101
104
|
);
|
|
102
105
|
return;
|
|
@@ -14,7 +14,7 @@ function issue(file: string, severity: ValidationSeverity, message: string): Val
|
|
|
14
14
|
/**
|
|
15
15
|
* Validate that a .planning directory has the minimum required structure.
|
|
16
16
|
* Returns structured issues with severity levels:
|
|
17
|
-
* - fatal: directory doesn't exist
|
|
17
|
+
* - fatal: directory doesn't exist (migration cannot proceed)
|
|
18
18
|
* - warning: optional files missing (migration can proceed with reduced data)
|
|
19
19
|
*/
|
|
20
20
|
export async function validatePlanningDirectory(path: string): Promise<ValidationResult> {
|
|
@@ -26,9 +26,11 @@ export async function validatePlanningDirectory(path: string): Promise<Validatio
|
|
|
26
26
|
return { valid: false, issues };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
// ROADMAP.md
|
|
29
|
+
// ROADMAP.md — warn if missing (transformer falls back to filesystem phases)
|
|
30
30
|
if (!existsSync(join(path, 'ROADMAP.md'))) {
|
|
31
|
-
issues.push(issue('ROADMAP.md', '
|
|
31
|
+
issues.push(issue('ROADMAP.md', 'warning',
|
|
32
|
+
'ROADMAP.md not found — milestone structure will be inferred from phases/ directory',
|
|
33
|
+
));
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
// Optional files — warn if missing
|
|
@@ -76,7 +76,7 @@ Titles live inside file content (headings, frontmatter), not in file or director
|
|
|
76
76
|
- **Slices** are demoable vertical increments (S01, S02, ...) ordered by risk. After each slice completes, the roadmap is reassessed before the next slice begins.
|
|
77
77
|
- **Tasks** are single-context-window units of work (T01, T02, ...)
|
|
78
78
|
- Checkboxes in roadmap and plan files track completion (`[ ]` → `[x]`)
|
|
79
|
-
- Each slice gets its own git branch: `gsd/M001/S01`
|
|
79
|
+
- Each slice gets its own git branch: `gsd/M001/S01` (or `gsd/<worktree>/M001/S01` when inside a worktree)
|
|
80
80
|
- Slices are squash-merged to main when complete
|
|
81
81
|
- Summaries compress prior work — read them instead of re-reading all task details
|
|
82
82
|
- `STATE.md` is the quick-glance status file — keep it updated after changes
|
|
@@ -725,8 +725,8 @@ Another orphan.
|
|
|
725
725
|
}
|
|
726
726
|
}
|
|
727
727
|
|
|
728
|
-
// ─── Test 12: Validation — missing ROADMAP.md → fatal
|
|
729
|
-
console.log('\n=== Validation: missing ROADMAP.md → fatal ===');
|
|
728
|
+
// ─── Test 12: Validation — missing ROADMAP.md → warning (not fatal) ───
|
|
729
|
+
console.log('\n=== Validation: missing ROADMAP.md → warning (not fatal) ===');
|
|
730
730
|
{
|
|
731
731
|
const base = createFixtureBase();
|
|
732
732
|
try {
|
|
@@ -736,10 +736,10 @@ Another orphan.
|
|
|
736
736
|
|
|
737
737
|
const result = await validatePlanningDirectory(planning);
|
|
738
738
|
|
|
739
|
-
assertEq(result.valid,
|
|
739
|
+
assertEq(result.valid, true, 'no roadmap: validation still passes');
|
|
740
740
|
assert(
|
|
741
|
-
result.issues.some(i => i.severity === '
|
|
742
|
-
'no roadmap:
|
|
741
|
+
result.issues.some(i => i.severity === 'warning' && i.file.includes('ROADMAP')),
|
|
742
|
+
'no roadmap: warning issue mentions ROADMAP'
|
|
743
743
|
);
|
|
744
744
|
} finally {
|
|
745
745
|
cleanup(base);
|
|
@@ -211,15 +211,15 @@ async function main(): Promise<void> {
|
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
-
console.log('\n=== Validator: missing ROADMAP.md → fatal ===');
|
|
214
|
+
console.log('\n=== Validator: missing ROADMAP.md → warning (not fatal) ===');
|
|
215
215
|
{
|
|
216
216
|
const base = createFixtureBase();
|
|
217
217
|
try {
|
|
218
218
|
const planning = createPlanningDir(base);
|
|
219
219
|
writeFileSync(join(planning, 'PROJECT.md'), SAMPLE_PROJECT);
|
|
220
220
|
const result = await validatePlanningDirectory(planning);
|
|
221
|
-
assertEq(result.valid,
|
|
222
|
-
assert(result.issues.some(i => i.severity === '
|
|
221
|
+
assertEq(result.valid, true, 'no roadmap: validation still passes');
|
|
222
|
+
assert(result.issues.some(i => i.severity === 'warning' && i.file.includes('ROADMAP')), 'no roadmap: warning issue mentions ROADMAP');
|
|
223
223
|
} finally {
|
|
224
224
|
cleanup(base);
|
|
225
225
|
}
|
|
@@ -1248,6 +1248,100 @@ console.log('\n=== parseRequirementCounts: total is sum of all section counts ==
|
|
|
1248
1248
|
assertEq(counts.total, counts.active + counts.validated + counts.deferred + counts.outOfScope, 'total is exact sum');
|
|
1249
1249
|
}
|
|
1250
1250
|
|
|
1251
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1252
|
+
// parseSummary: bare scalar frontmatter fields (regression test for #91)
|
|
1253
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1254
|
+
|
|
1255
|
+
console.log('\n=== parseSummary: bare scalar "none" coerced to string array (#91) ===');
|
|
1256
|
+
{
|
|
1257
|
+
const content = `---
|
|
1258
|
+
id: T04
|
|
1259
|
+
parent: S03
|
|
1260
|
+
milestone: M001
|
|
1261
|
+
provides:
|
|
1262
|
+
- iOS rules
|
|
1263
|
+
key_files:
|
|
1264
|
+
- .claude/rules/swift-style.md
|
|
1265
|
+
key_decisions: none
|
|
1266
|
+
patterns_established: none
|
|
1267
|
+
drill_down_paths: none
|
|
1268
|
+
observability_surfaces: none — static reference files
|
|
1269
|
+
affects: single-value
|
|
1270
|
+
---
|
|
1271
|
+
|
|
1272
|
+
# T04: iOS Rules
|
|
1273
|
+
|
|
1274
|
+
**Created iOS-specific rules.**
|
|
1275
|
+
|
|
1276
|
+
## What Happened
|
|
1277
|
+
|
|
1278
|
+
Added rules.
|
|
1279
|
+
|
|
1280
|
+
## Deviations
|
|
1281
|
+
|
|
1282
|
+
None.
|
|
1283
|
+
`;
|
|
1284
|
+
|
|
1285
|
+
const s = parseSummary(content);
|
|
1286
|
+
|
|
1287
|
+
// Array fields should remain arrays
|
|
1288
|
+
assertEq(s.frontmatter.provides.length, 1, '#91: provides array preserved');
|
|
1289
|
+
assertEq(s.frontmatter.provides[0], 'iOS rules', '#91: provides value');
|
|
1290
|
+
assertEq(s.frontmatter.key_files.length, 1, '#91: key_files array preserved');
|
|
1291
|
+
|
|
1292
|
+
// Bare scalar "none" must be coerced to ["none"], not crash
|
|
1293
|
+
assertEq(Array.isArray(s.frontmatter.key_decisions), true, '#91: key_decisions is array');
|
|
1294
|
+
assertEq(s.frontmatter.key_decisions.length, 1, '#91: key_decisions has 1 element');
|
|
1295
|
+
assertEq(s.frontmatter.key_decisions[0], 'none', '#91: key_decisions[0] is "none"');
|
|
1296
|
+
|
|
1297
|
+
assertEq(Array.isArray(s.frontmatter.patterns_established), true, '#91: patterns_established is array');
|
|
1298
|
+
assertEq(s.frontmatter.patterns_established.length, 1, '#91: patterns_established coerced');
|
|
1299
|
+
|
|
1300
|
+
assertEq(Array.isArray(s.frontmatter.drill_down_paths), true, '#91: drill_down_paths is array');
|
|
1301
|
+
assertEq(s.frontmatter.drill_down_paths.length, 1, '#91: drill_down_paths coerced');
|
|
1302
|
+
|
|
1303
|
+
// Scalar with spaces: "none — static reference files"
|
|
1304
|
+
assertEq(Array.isArray(s.frontmatter.observability_surfaces), true, '#91: observability_surfaces is array');
|
|
1305
|
+
assertEq(s.frontmatter.observability_surfaces.length, 1, '#91: observability_surfaces coerced');
|
|
1306
|
+
assertEq(s.frontmatter.observability_surfaces[0], 'none — static reference files', '#91: full scalar preserved');
|
|
1307
|
+
|
|
1308
|
+
// Single value (not "none") also coerced
|
|
1309
|
+
assertEq(Array.isArray(s.frontmatter.affects), true, '#91: affects is array');
|
|
1310
|
+
assertEq(s.frontmatter.affects.length, 1, '#91: affects single value coerced');
|
|
1311
|
+
assertEq(s.frontmatter.affects[0], 'single-value', '#91: affects value');
|
|
1312
|
+
|
|
1313
|
+
// .slice().join() must not crash (the original bug)
|
|
1314
|
+
const decisions = s.frontmatter.key_decisions.slice(0, 2).join('; ');
|
|
1315
|
+
assertEq(decisions, 'none', '#91: .slice().join() works on coerced array');
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
console.log('\n=== parseSummary: missing/empty frontmatter fields yield empty arrays ===');
|
|
1319
|
+
{
|
|
1320
|
+
const content = `---
|
|
1321
|
+
id: T05
|
|
1322
|
+
parent: S04
|
|
1323
|
+
milestone: M001
|
|
1324
|
+
---
|
|
1325
|
+
|
|
1326
|
+
# T05: Minimal Summary
|
|
1327
|
+
|
|
1328
|
+
**Minimal.**
|
|
1329
|
+
|
|
1330
|
+
## What Happened
|
|
1331
|
+
|
|
1332
|
+
Nothing.
|
|
1333
|
+
`;
|
|
1334
|
+
|
|
1335
|
+
const s = parseSummary(content);
|
|
1336
|
+
assertEq(s.frontmatter.provides.length, 0, 'missing provides = empty array');
|
|
1337
|
+
assertEq(s.frontmatter.key_decisions.length, 0, 'missing key_decisions = empty array');
|
|
1338
|
+
assertEq(s.frontmatter.affects.length, 0, 'missing affects = empty array');
|
|
1339
|
+
assertEq(s.frontmatter.key_files.length, 0, 'missing key_files = empty array');
|
|
1340
|
+
assertEq(s.frontmatter.patterns_established.length, 0, 'missing patterns_established = empty array');
|
|
1341
|
+
assertEq(s.frontmatter.drill_down_paths.length, 0, 'missing drill_down_paths = empty array');
|
|
1342
|
+
assertEq(s.frontmatter.observability_surfaces.length, 0, 'missing observability_surfaces = empty array');
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1251
1345
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1252
1346
|
// Results
|
|
1253
1347
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -1,17 +1,34 @@
|
|
|
1
1
|
// ESM resolve hook: .js → .ts rewriting for test environments.
|
|
2
2
|
// Only rewrites relative imports from our own source files — not from node_modules.
|
|
3
|
+
//
|
|
4
|
+
// Handles two patterns:
|
|
5
|
+
// 1. .js → .ts (pi bundler convention: source files use .js specifiers)
|
|
6
|
+
// 2. extensionless → .ts (some source files omit extensions in relative imports)
|
|
3
7
|
|
|
4
8
|
export function resolve(specifier, context, nextResolve) {
|
|
5
9
|
const parentURL = context.parentURL || '';
|
|
6
10
|
const isFromNodeModules = parentURL.includes('/node_modules/');
|
|
7
11
|
|
|
8
|
-
if (
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
if (!isFromNodeModules && !specifier.startsWith('node:')) {
|
|
13
|
+
// Rewrite .js → .ts
|
|
14
|
+
if (specifier.endsWith('.js')) {
|
|
15
|
+
const tsSpecifier = specifier.replace(/\.js$/, '.ts');
|
|
16
|
+
try {
|
|
17
|
+
return nextResolve(tsSpecifier, context);
|
|
18
|
+
} catch {
|
|
19
|
+
// fall through to default resolution
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Try adding .ts to extensionless relative imports
|
|
24
|
+
if (specifier.startsWith('.') && !/\.[a-z]+$/i.test(specifier)) {
|
|
25
|
+
try {
|
|
26
|
+
return nextResolve(specifier + '.ts', context);
|
|
27
|
+
} catch {
|
|
28
|
+
// fall through to default resolution
|
|
29
|
+
}
|
|
14
30
|
}
|
|
15
31
|
}
|
|
32
|
+
|
|
16
33
|
return nextResolve(specifier, context);
|
|
17
34
|
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worktree Integration Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests the full lifecycle of GSD operations inside a worktree:
|
|
5
|
+
* - Branch namespacing (gsd/<wt>/<M>/<S> instead of gsd/<M>/<S>)
|
|
6
|
+
* - getMainBranch returns worktree/<name> inside a worktree
|
|
7
|
+
* - switchToMain goes to worktree/<name>, not main
|
|
8
|
+
* - mergeSliceToMain merges into worktree/<name>
|
|
9
|
+
* - Parallel worktrees don't conflict on branch names
|
|
10
|
+
* - State derivation works correctly inside worktrees
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { execSync } from "node:child_process";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
createWorktree,
|
|
20
|
+
listWorktrees,
|
|
21
|
+
removeWorktree,
|
|
22
|
+
worktreePath,
|
|
23
|
+
worktreeBranchName,
|
|
24
|
+
} from "../worktree-manager.ts";
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
detectWorktreeName,
|
|
28
|
+
ensureSliceBranch,
|
|
29
|
+
getActiveSliceBranch,
|
|
30
|
+
getCurrentBranch,
|
|
31
|
+
getMainBranch,
|
|
32
|
+
getSliceBranchName,
|
|
33
|
+
isOnSliceBranch,
|
|
34
|
+
mergeSliceToMain,
|
|
35
|
+
switchToMain,
|
|
36
|
+
autoCommitCurrentBranch,
|
|
37
|
+
} from "../worktree.ts";
|
|
38
|
+
|
|
39
|
+
import { deriveState } from "../state.ts";
|
|
40
|
+
|
|
41
|
+
let passed = 0;
|
|
42
|
+
let failed = 0;
|
|
43
|
+
|
|
44
|
+
function assert(condition: boolean, message: string): void {
|
|
45
|
+
if (condition) passed++;
|
|
46
|
+
else {
|
|
47
|
+
failed++;
|
|
48
|
+
console.error(` FAIL: ${message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function assertEq<T>(actual: T, expected: T, message: string): void {
|
|
53
|
+
if (JSON.stringify(actual) === JSON.stringify(expected)) passed++;
|
|
54
|
+
else {
|
|
55
|
+
failed++;
|
|
56
|
+
console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function run(command: string, cwd: string): string {
|
|
61
|
+
return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Test repo setup ──────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-wt-integration-"));
|
|
67
|
+
run("git init -b main", base);
|
|
68
|
+
run("git config user.name 'Pi Test'", base);
|
|
69
|
+
run("git config user.email 'pi@example.com'", base);
|
|
70
|
+
|
|
71
|
+
// Create a project with one milestone and two slices
|
|
72
|
+
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
|
|
73
|
+
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S02", "tasks"), { recursive: true });
|
|
74
|
+
writeFileSync(join(base, "README.md"), "# Test Project\n", "utf-8");
|
|
75
|
+
writeFileSync(
|
|
76
|
+
join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"),
|
|
77
|
+
[
|
|
78
|
+
"# M001: Demo",
|
|
79
|
+
"",
|
|
80
|
+
"## Slices",
|
|
81
|
+
"- [ ] **S01: First** `risk:low` `depends:[]`",
|
|
82
|
+
" > After this: part one works",
|
|
83
|
+
"- [ ] **S02: Second** `risk:low` `depends:[]`",
|
|
84
|
+
" > After this: part two works",
|
|
85
|
+
].join("\n") + "\n",
|
|
86
|
+
"utf-8",
|
|
87
|
+
);
|
|
88
|
+
writeFileSync(
|
|
89
|
+
join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"),
|
|
90
|
+
"# S01: First\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Must-Haves\n- done\n\n## Tasks\n- [ ] **T01: Implement** `est:10m`\n do it\n",
|
|
91
|
+
"utf-8",
|
|
92
|
+
);
|
|
93
|
+
writeFileSync(
|
|
94
|
+
join(base, ".gsd", "milestones", "M001", "slices", "S02", "S02-PLAN.md"),
|
|
95
|
+
"# S02: Second\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Must-Haves\n- done\n\n## Tasks\n- [ ] **T01: Implement** `est:10m`\n do it\n",
|
|
96
|
+
"utf-8",
|
|
97
|
+
);
|
|
98
|
+
run("git add .", base);
|
|
99
|
+
run("git commit -m 'chore: init'", base);
|
|
100
|
+
|
|
101
|
+
async function main(): Promise<void> {
|
|
102
|
+
// ── Verify main tree baseline ──────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
console.log("\n=== Main tree baseline ===");
|
|
105
|
+
assertEq(getMainBranch(base), "main", "main tree getMainBranch returns main");
|
|
106
|
+
assertEq(detectWorktreeName(base), null, "main tree not detected as worktree");
|
|
107
|
+
|
|
108
|
+
// ── Create worktree and verify detection ───────────────────────────────────
|
|
109
|
+
|
|
110
|
+
console.log("\n=== Create worktree ===");
|
|
111
|
+
const wt = createWorktree(base, "alpha");
|
|
112
|
+
assert(existsSync(wt.path), "worktree created on disk");
|
|
113
|
+
assertEq(wt.branch, "worktree/alpha", "worktree branch name");
|
|
114
|
+
|
|
115
|
+
console.log("\n=== Worktree detection ===");
|
|
116
|
+
assertEq(detectWorktreeName(wt.path), "alpha", "detectWorktreeName inside worktree");
|
|
117
|
+
assertEq(getMainBranch(wt.path), "worktree/alpha", "getMainBranch returns worktree branch inside worktree");
|
|
118
|
+
|
|
119
|
+
// ── Verify current branch inside worktree ──────────────────────────────────
|
|
120
|
+
|
|
121
|
+
console.log("\n=== Worktree initial branch ===");
|
|
122
|
+
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "worktree starts on its own branch");
|
|
123
|
+
|
|
124
|
+
// ── ensureSliceBranch inside worktree ──────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
console.log("\n=== ensureSliceBranch in worktree ===");
|
|
127
|
+
const created = ensureSliceBranch(wt.path, "M001", "S01");
|
|
128
|
+
assert(created, "slice branch created");
|
|
129
|
+
assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S01", "worktree-namespaced slice branch");
|
|
130
|
+
assert(isOnSliceBranch(wt.path), "isOnSliceBranch returns true");
|
|
131
|
+
assertEq(getActiveSliceBranch(wt.path), "gsd/alpha/M001/S01", "getActiveSliceBranch returns namespaced branch");
|
|
132
|
+
|
|
133
|
+
// ── Verify branch name helper ──────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
console.log("\n=== getSliceBranchName with worktree ===");
|
|
136
|
+
assertEq(getSliceBranchName("M001", "S01", "alpha"), "gsd/alpha/M001/S01", "explicit worktree param");
|
|
137
|
+
assertEq(getSliceBranchName("M001", "S01"), "gsd/M001/S01", "no worktree param = plain branch");
|
|
138
|
+
|
|
139
|
+
// ── Do work on slice branch, then merge to worktree branch ─────────────────
|
|
140
|
+
|
|
141
|
+
console.log("\n=== Work and merge slice in worktree ===");
|
|
142
|
+
writeFileSync(join(wt.path, "feature.txt"), "new feature\n", "utf-8");
|
|
143
|
+
run("git add .", wt.path);
|
|
144
|
+
run("git commit -m 'feat: add feature'", wt.path);
|
|
145
|
+
|
|
146
|
+
// switchToMain should go to worktree/alpha, NOT main
|
|
147
|
+
switchToMain(wt.path);
|
|
148
|
+
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "switchToMain goes to worktree branch, not main");
|
|
149
|
+
|
|
150
|
+
// mergeSliceToMain should merge into worktree/alpha
|
|
151
|
+
const merge = mergeSliceToMain(wt.path, "M001", "S01", "First");
|
|
152
|
+
assertEq(merge.branch, "gsd/alpha/M001/S01", "merged the namespaced branch");
|
|
153
|
+
assert(merge.deletedBranch, "slice branch deleted after merge");
|
|
154
|
+
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "still on worktree branch after merge");
|
|
155
|
+
assert(readFileSync(join(wt.path, "feature.txt"), "utf-8").includes("new feature"), "merge brought feature to worktree branch");
|
|
156
|
+
|
|
157
|
+
// Verify slice branch is gone
|
|
158
|
+
const branches = run("git branch", base);
|
|
159
|
+
assert(!branches.includes("gsd/alpha/M001/S01"), "slice branch cleaned up");
|
|
160
|
+
|
|
161
|
+
// ── Second slice in same worktree ──────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
console.log("\n=== Second slice in worktree ===");
|
|
164
|
+
const created2 = ensureSliceBranch(wt.path, "M001", "S02");
|
|
165
|
+
assert(created2, "S02 branch created");
|
|
166
|
+
assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S02", "on S02 namespaced branch");
|
|
167
|
+
|
|
168
|
+
writeFileSync(join(wt.path, "feature2.txt"), "second feature\n", "utf-8");
|
|
169
|
+
run("git add .", wt.path);
|
|
170
|
+
run("git commit -m 'feat: add feature 2'", wt.path);
|
|
171
|
+
|
|
172
|
+
switchToMain(wt.path);
|
|
173
|
+
const merge2 = mergeSliceToMain(wt.path, "M001", "S02", "Second");
|
|
174
|
+
assertEq(merge2.branch, "gsd/alpha/M001/S02", "S02 merge correct");
|
|
175
|
+
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "back on worktree branch");
|
|
176
|
+
|
|
177
|
+
// ── Main tree can still do its own slice work independently ────────────────
|
|
178
|
+
|
|
179
|
+
console.log("\n=== Main tree independent slice work ===");
|
|
180
|
+
assertEq(getCurrentBranch(base), "main", "main tree still on main");
|
|
181
|
+
const mainCreated = ensureSliceBranch(base, "M001", "S01");
|
|
182
|
+
assert(mainCreated, "main tree can create S01 branch (no conflict with worktree)");
|
|
183
|
+
assertEq(getCurrentBranch(base), "gsd/M001/S01", "main tree on plain branch name");
|
|
184
|
+
|
|
185
|
+
writeFileSync(join(base, "main-feature.txt"), "main work\n", "utf-8");
|
|
186
|
+
run("git add .", base);
|
|
187
|
+
run("git commit -m 'feat: main work'", base);
|
|
188
|
+
|
|
189
|
+
switchToMain(base);
|
|
190
|
+
assertEq(getCurrentBranch(base), "main", "main tree switchToMain goes to main");
|
|
191
|
+
const mainMerge = mergeSliceToMain(base, "M001", "S01", "First");
|
|
192
|
+
assertEq(mainMerge.branch, "gsd/M001/S01", "main tree merge uses plain branch");
|
|
193
|
+
|
|
194
|
+
// ── Parallel worktrees don't conflict ──────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
console.log("\n=== Parallel worktrees ===");
|
|
197
|
+
const wt2 = createWorktree(base, "beta");
|
|
198
|
+
assertEq(getMainBranch(wt2.path), "worktree/beta", "second worktree has its own base branch");
|
|
199
|
+
|
|
200
|
+
// Both worktrees can create S01 branches without conflict
|
|
201
|
+
const betaCreated = ensureSliceBranch(wt2.path, "M001", "S01");
|
|
202
|
+
assert(betaCreated, "beta worktree can create S01");
|
|
203
|
+
assertEq(getCurrentBranch(wt2.path), "gsd/beta/M001/S01", "beta has its own namespaced branch");
|
|
204
|
+
|
|
205
|
+
// Alpha worktree can re-create S01 too (it was already merged+deleted earlier)
|
|
206
|
+
const alphaReCreated = ensureSliceBranch(wt.path, "M001", "S01");
|
|
207
|
+
assert(alphaReCreated, "alpha worktree can re-create S01");
|
|
208
|
+
assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S01", "alpha re-created S01");
|
|
209
|
+
|
|
210
|
+
// Both exist simultaneously
|
|
211
|
+
const allBranches = run("git branch", base);
|
|
212
|
+
assert(allBranches.includes("gsd/alpha/M001/S01"), "alpha S01 branch exists");
|
|
213
|
+
assert(allBranches.includes("gsd/beta/M001/S01"), "beta S01 branch exists");
|
|
214
|
+
|
|
215
|
+
// ── State derivation in worktree ───────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
console.log("\n=== State derivation in worktree ===");
|
|
218
|
+
// Switch alpha back to its base so deriveState sees milestone files
|
|
219
|
+
switchToMain(wt.path);
|
|
220
|
+
const state = await deriveState(wt.path);
|
|
221
|
+
assert(state.activeMilestone !== null, "worktree has active milestone");
|
|
222
|
+
assertEq(state.activeMilestone?.id, "M001", "correct milestone");
|
|
223
|
+
|
|
224
|
+
// ── autoCommitCurrentBranch in worktree ────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
console.log("\n=== autoCommitCurrentBranch in worktree ===");
|
|
227
|
+
ensureSliceBranch(wt2.path, "M001", "S01"); // re-checkout if needed
|
|
228
|
+
writeFileSync(join(wt2.path, "dirty.txt"), "uncommitted\n", "utf-8");
|
|
229
|
+
const commitMsg = autoCommitCurrentBranch(wt2.path, "execute-task", "M001/S01/T01");
|
|
230
|
+
assert(commitMsg !== null, "auto-commit works in worktree");
|
|
231
|
+
assertEq(run("git status --short", wt2.path), "", "worktree clean after auto-commit");
|
|
232
|
+
|
|
233
|
+
// ── Cleanup ────────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
console.log("\n=== Cleanup ===");
|
|
236
|
+
// Switch worktrees back to their base branches before removal
|
|
237
|
+
switchToMain(wt.path);
|
|
238
|
+
switchToMain(wt2.path);
|
|
239
|
+
removeWorktree(base, "alpha", { deleteBranch: true });
|
|
240
|
+
removeWorktree(base, "beta", { deleteBranch: true });
|
|
241
|
+
assertEq(listWorktrees(base).length, 0, "all worktrees removed");
|
|
242
|
+
|
|
243
|
+
rmSync(base, { recursive: true, force: true });
|
|
244
|
+
|
|
245
|
+
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
246
|
+
if (failed > 0) process.exit(1);
|
|
247
|
+
console.log("All tests passed ✓");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
main().catch((error) => {
|
|
251
|
+
console.error(error);
|
|
252
|
+
process.exit(1);
|
|
253
|
+
});
|