gsd-pi 2.33.0 → 2.33.1-dev.29a8268
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 +13 -18
- package/dist/resources/extensions/gsd/auto-supervisor.ts +10 -5
- package/dist/resources/extensions/gsd/auto-worktree.ts +135 -1
- package/dist/resources/extensions/gsd/commands.ts +14 -2
- package/dist/resources/extensions/gsd/git-service.ts +17 -11
- package/dist/resources/extensions/gsd/index.ts +13 -2
- package/dist/resources/extensions/gsd/session-lock.ts +80 -16
- package/dist/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
- package/dist/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
- package/dist/resources/extensions/gsd/tests/git-service.test.ts +16 -7
- package/dist/resources/extensions/gsd/tests/loop-regression.test.ts +39 -1
- package/dist/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
- package/dist/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
- package/dist/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
- package/dist/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
- package/package.json +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/auto-supervisor.ts +10 -5
- package/src/resources/extensions/gsd/auto-worktree.ts +135 -1
- package/src/resources/extensions/gsd/commands.ts +14 -2
- package/src/resources/extensions/gsd/git-service.ts +17 -11
- package/src/resources/extensions/gsd/index.ts +13 -2
- package/src/resources/extensions/gsd/session-lock.ts +80 -16
- package/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
- package/src/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
- package/src/resources/extensions/gsd/tests/git-service.test.ts +16 -7
- package/src/resources/extensions/gsd/tests/loop-regression.test.ts +39 -1
- package/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
- package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
- package/src/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
- package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
- package/dist/resources/extensions/mcporter/extension-manifest.json +0 -12
- package/src/resources/extensions/mcporter/extension-manifest.json +0 -12
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
readSessionLockData,
|
|
13
13
|
isSessionLockHeld,
|
|
14
14
|
isSessionLockProcessAlive,
|
|
15
|
+
cleanupStrayLockFiles,
|
|
15
16
|
} from "../session-lock.ts";
|
|
16
17
|
|
|
17
18
|
// ─── acquireSessionLock ──────────────────────────────────────────────────
|
|
@@ -313,3 +314,121 @@ test("acquireSessionLock creates .gsd/ if it does not exist", () => {
|
|
|
313
314
|
releaseSessionLock(dir);
|
|
314
315
|
rmSync(dir, { recursive: true, force: true });
|
|
315
316
|
});
|
|
317
|
+
|
|
318
|
+
// ─── cleanupStrayLockFiles (#1315) ──────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
test("cleanupStrayLockFiles removes numbered lock variants but preserves auto.lock", () => {
|
|
321
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
|
|
322
|
+
const gsdDir = join(dir, ".gsd");
|
|
323
|
+
mkdirSync(gsdDir, { recursive: true });
|
|
324
|
+
|
|
325
|
+
// Create canonical lock file + numbered variants
|
|
326
|
+
writeFileSync(join(gsdDir, "auto.lock"), '{"pid":1}');
|
|
327
|
+
writeFileSync(join(gsdDir, "auto 2.lock"), '{"pid":2}');
|
|
328
|
+
writeFileSync(join(gsdDir, "auto 3.lock"), '{"pid":3}');
|
|
329
|
+
writeFileSync(join(gsdDir, "auto 4.lock"), '{"pid":4}');
|
|
330
|
+
|
|
331
|
+
cleanupStrayLockFiles(dir);
|
|
332
|
+
|
|
333
|
+
assert.ok(existsSync(join(gsdDir, "auto.lock")), "canonical auto.lock should be preserved");
|
|
334
|
+
assert.ok(!existsSync(join(gsdDir, "auto 2.lock")), "auto 2.lock should be removed");
|
|
335
|
+
assert.ok(!existsSync(join(gsdDir, "auto 3.lock")), "auto 3.lock should be removed");
|
|
336
|
+
assert.ok(!existsSync(join(gsdDir, "auto 4.lock")), "auto 4.lock should be removed");
|
|
337
|
+
|
|
338
|
+
rmSync(dir, { recursive: true, force: true });
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("cleanupStrayLockFiles handles parenthesized variants", () => {
|
|
342
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
|
|
343
|
+
const gsdDir = join(dir, ".gsd");
|
|
344
|
+
mkdirSync(gsdDir, { recursive: true });
|
|
345
|
+
|
|
346
|
+
// macOS sometimes uses parenthesized format: "auto (2).lock"
|
|
347
|
+
writeFileSync(join(gsdDir, "auto.lock"), '{"pid":1}');
|
|
348
|
+
writeFileSync(join(gsdDir, "auto (2).lock"), '{"pid":2}');
|
|
349
|
+
|
|
350
|
+
cleanupStrayLockFiles(dir);
|
|
351
|
+
|
|
352
|
+
assert.ok(existsSync(join(gsdDir, "auto.lock")), "canonical auto.lock should be preserved");
|
|
353
|
+
assert.ok(!existsSync(join(gsdDir, "auto (2).lock")), "auto (2).lock should be removed");
|
|
354
|
+
|
|
355
|
+
rmSync(dir, { recursive: true, force: true });
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("cleanupStrayLockFiles does not remove unrelated files", () => {
|
|
359
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
|
|
360
|
+
const gsdDir = join(dir, ".gsd");
|
|
361
|
+
mkdirSync(gsdDir, { recursive: true });
|
|
362
|
+
|
|
363
|
+
// Create unrelated files that should NOT be removed
|
|
364
|
+
writeFileSync(join(gsdDir, "auto.lock"), '{"pid":1}');
|
|
365
|
+
writeFileSync(join(gsdDir, "config.json"), '{}');
|
|
366
|
+
writeFileSync(join(gsdDir, "other.lock"), '{}');
|
|
367
|
+
|
|
368
|
+
cleanupStrayLockFiles(dir);
|
|
369
|
+
|
|
370
|
+
assert.ok(existsSync(join(gsdDir, "auto.lock")), "auto.lock should be preserved");
|
|
371
|
+
assert.ok(existsSync(join(gsdDir, "config.json")), "config.json should be preserved");
|
|
372
|
+
assert.ok(existsSync(join(gsdDir, "other.lock")), "other.lock should be preserved");
|
|
373
|
+
|
|
374
|
+
rmSync(dir, { recursive: true, force: true });
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("cleanupStrayLockFiles is safe on empty directory", () => {
|
|
378
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
|
|
379
|
+
const gsdDir = join(dir, ".gsd");
|
|
380
|
+
mkdirSync(gsdDir, { recursive: true });
|
|
381
|
+
|
|
382
|
+
// Should not throw
|
|
383
|
+
cleanupStrayLockFiles(dir);
|
|
384
|
+
|
|
385
|
+
rmSync(dir, { recursive: true, force: true });
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("cleanupStrayLockFiles is safe when .gsd/ does not exist", () => {
|
|
389
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
|
|
390
|
+
|
|
391
|
+
// Should not throw even without .gsd/
|
|
392
|
+
cleanupStrayLockFiles(dir);
|
|
393
|
+
|
|
394
|
+
rmSync(dir, { recursive: true, force: true });
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("acquireSessionLock cleans stray lock files before acquiring", () => {
|
|
398
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
|
|
399
|
+
const gsdDir = join(dir, ".gsd");
|
|
400
|
+
mkdirSync(gsdDir, { recursive: true });
|
|
401
|
+
|
|
402
|
+
// Plant stray lock files before acquire
|
|
403
|
+
writeFileSync(join(gsdDir, "auto 2.lock"), '{"pid":9999999}');
|
|
404
|
+
writeFileSync(join(gsdDir, "auto 3.lock"), '{"pid":9999998}');
|
|
405
|
+
|
|
406
|
+
const result = acquireSessionLock(dir);
|
|
407
|
+
assert.equal(result.acquired, true, "should acquire lock");
|
|
408
|
+
|
|
409
|
+
// Stray files should be cleaned up
|
|
410
|
+
assert.ok(!existsSync(join(gsdDir, "auto 2.lock")), "auto 2.lock should be removed during acquire");
|
|
411
|
+
assert.ok(!existsSync(join(gsdDir, "auto 3.lock")), "auto 3.lock should be removed during acquire");
|
|
412
|
+
|
|
413
|
+
releaseSessionLock(dir);
|
|
414
|
+
rmSync(dir, { recursive: true, force: true });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("releaseSessionLock cleans stray lock files after releasing", () => {
|
|
418
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
|
|
419
|
+
const gsdDir = join(dir, ".gsd");
|
|
420
|
+
mkdirSync(gsdDir, { recursive: true });
|
|
421
|
+
|
|
422
|
+
const result = acquireSessionLock(dir);
|
|
423
|
+
assert.equal(result.acquired, true);
|
|
424
|
+
|
|
425
|
+
// Plant stray lock files (simulating cloud sync creating them during session)
|
|
426
|
+
writeFileSync(join(gsdDir, "auto 2.lock"), '{"pid":9999999}');
|
|
427
|
+
|
|
428
|
+
releaseSessionLock(dir);
|
|
429
|
+
|
|
430
|
+
assert.ok(!existsSync(join(gsdDir, "auto 2.lock")), "auto 2.lock should be removed during release");
|
|
431
|
+
assert.ok(!existsSync(join(gsdDir, "auto.lock")), "auto.lock should also be removed");
|
|
432
|
+
|
|
433
|
+
rmSync(dir, { recursive: true, force: true });
|
|
434
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worktree-sync-milestones.test.ts — Regression test for #1311.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that syncGsdStateToWorktree copies missing milestones,
|
|
5
|
+
* milestone files, and slice directories from the main repo's .gsd/
|
|
6
|
+
* into the worktree's .gsd/.
|
|
7
|
+
*
|
|
8
|
+
* Covers:
|
|
9
|
+
* - Entirely missing milestone directory
|
|
10
|
+
* - Milestone exists but missing CONTEXT/ROADMAP files
|
|
11
|
+
* - Missing slices within an existing milestone
|
|
12
|
+
* - No-op when directories are identical (symlinked)
|
|
13
|
+
* - Root-level files (DECISIONS, REQUIREMENTS, etc.)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync, symlinkSync, realpathSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
|
|
20
|
+
import { syncGsdStateToWorktree } from '../auto-worktree.ts';
|
|
21
|
+
import { createTestContext } from './test-helpers.ts';
|
|
22
|
+
|
|
23
|
+
const { assertEq, assertTrue, report } = createTestContext();
|
|
24
|
+
|
|
25
|
+
function createBase(name: string): string {
|
|
26
|
+
const base = mkdtempSync(join(tmpdir(), `gsd-wt-sync-${name}-`));
|
|
27
|
+
mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true });
|
|
28
|
+
return base;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function cleanup(base: string): void {
|
|
32
|
+
rmSync(base, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function main(): Promise<void> {
|
|
36
|
+
|
|
37
|
+
// ─── 1. Missing milestone directory is synced ─────────────────────────
|
|
38
|
+
console.log('\n=== 1. missing milestone directory is copied from main ===');
|
|
39
|
+
{
|
|
40
|
+
const mainBase = createBase('main');
|
|
41
|
+
const wtBase = createBase('wt');
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
// Main repo has M001 and M002
|
|
45
|
+
const m001Dir = join(mainBase, '.gsd', 'milestones', 'M001');
|
|
46
|
+
mkdirSync(m001Dir, { recursive: true });
|
|
47
|
+
writeFileSync(join(m001Dir, 'M001-CONTEXT.md'), '# M001\nDone.');
|
|
48
|
+
writeFileSync(join(m001Dir, 'M001-ROADMAP.md'), '# Roadmap');
|
|
49
|
+
|
|
50
|
+
const m002Dir = join(mainBase, '.gsd', 'milestones', 'M002');
|
|
51
|
+
mkdirSync(m002Dir, { recursive: true });
|
|
52
|
+
writeFileSync(join(m002Dir, 'M002-CONTEXT.md'), '# M002\nNew milestone.');
|
|
53
|
+
writeFileSync(join(m002Dir, 'M002-ROADMAP.md'), '# Roadmap');
|
|
54
|
+
|
|
55
|
+
// Worktree only has M001
|
|
56
|
+
const wtM001Dir = join(wtBase, '.gsd', 'milestones', 'M001');
|
|
57
|
+
mkdirSync(wtM001Dir, { recursive: true });
|
|
58
|
+
writeFileSync(join(wtM001Dir, 'M001-CONTEXT.md'), '# M001\nDone.');
|
|
59
|
+
|
|
60
|
+
// M002 is missing from worktree
|
|
61
|
+
assertTrue(!existsSync(join(wtBase, '.gsd', 'milestones', 'M002')), 'M002 missing before sync');
|
|
62
|
+
|
|
63
|
+
const result = syncGsdStateToWorktree(mainBase, wtBase);
|
|
64
|
+
|
|
65
|
+
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M002')), '#1311: M002 synced to worktree');
|
|
66
|
+
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M002', 'M002-CONTEXT.md')), 'M002 CONTEXT synced');
|
|
67
|
+
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M002', 'M002-ROADMAP.md')), 'M002 ROADMAP synced');
|
|
68
|
+
assertTrue(result.synced.length > 0, 'sync reported files');
|
|
69
|
+
} finally {
|
|
70
|
+
cleanup(mainBase);
|
|
71
|
+
cleanup(wtBase);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── 2. Missing files within existing milestone ───────────────────────
|
|
76
|
+
console.log('\n=== 2. missing files within existing milestone are synced ===');
|
|
77
|
+
{
|
|
78
|
+
const mainBase = createBase('main');
|
|
79
|
+
const wtBase = createBase('wt');
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
// Main repo M001 has CONTEXT, ROADMAP, RESEARCH
|
|
83
|
+
const m001Dir = join(mainBase, '.gsd', 'milestones', 'M001');
|
|
84
|
+
mkdirSync(m001Dir, { recursive: true });
|
|
85
|
+
writeFileSync(join(m001Dir, 'M001-CONTEXT.md'), '# M001 Context');
|
|
86
|
+
writeFileSync(join(m001Dir, 'M001-ROADMAP.md'), '# M001 Roadmap');
|
|
87
|
+
writeFileSync(join(m001Dir, 'M001-RESEARCH.md'), '# M001 Research');
|
|
88
|
+
|
|
89
|
+
// Worktree M001 only has CONTEXT (stale snapshot)
|
|
90
|
+
const wtM001Dir = join(wtBase, '.gsd', 'milestones', 'M001');
|
|
91
|
+
mkdirSync(wtM001Dir, { recursive: true });
|
|
92
|
+
writeFileSync(join(wtM001Dir, 'M001-CONTEXT.md'), '# M001 Context');
|
|
93
|
+
|
|
94
|
+
const result = syncGsdStateToWorktree(mainBase, wtBase);
|
|
95
|
+
|
|
96
|
+
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-ROADMAP.md')), 'ROADMAP synced');
|
|
97
|
+
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-RESEARCH.md')), 'RESEARCH synced');
|
|
98
|
+
// Existing file should NOT be overwritten
|
|
99
|
+
assertEq(
|
|
100
|
+
readFileSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-CONTEXT.md'), 'utf-8'),
|
|
101
|
+
'# M001 Context',
|
|
102
|
+
'existing CONTEXT not overwritten',
|
|
103
|
+
);
|
|
104
|
+
} finally {
|
|
105
|
+
cleanup(mainBase);
|
|
106
|
+
cleanup(wtBase);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── 3. Missing slices directory synced ───────────────────────────────
|
|
111
|
+
console.log('\n=== 3. missing slices directory synced ===');
|
|
112
|
+
{
|
|
113
|
+
const mainBase = createBase('main');
|
|
114
|
+
const wtBase = createBase('wt');
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
// Main repo has M001 with slices S01–S03
|
|
118
|
+
const m001Dir = join(mainBase, '.gsd', 'milestones', 'M001');
|
|
119
|
+
mkdirSync(join(m001Dir, 'slices', 'S01'), { recursive: true });
|
|
120
|
+
mkdirSync(join(m001Dir, 'slices', 'S02'), { recursive: true });
|
|
121
|
+
mkdirSync(join(m001Dir, 'slices', 'S03'), { recursive: true });
|
|
122
|
+
writeFileSync(join(m001Dir, 'M001-ROADMAP.md'), '# Roadmap');
|
|
123
|
+
writeFileSync(join(m001Dir, 'slices', 'S01', 'S01-PLAN.md'), '# S01 Plan');
|
|
124
|
+
writeFileSync(join(m001Dir, 'slices', 'S02', 'S02-PLAN.md'), '# S02 Plan');
|
|
125
|
+
writeFileSync(join(m001Dir, 'slices', 'S03', 'S03-PLAN.md'), '# S03 Plan');
|
|
126
|
+
|
|
127
|
+
// Worktree M001 has slices S01–S02 only (S03 missing)
|
|
128
|
+
const wtM001Dir = join(wtBase, '.gsd', 'milestones', 'M001');
|
|
129
|
+
mkdirSync(join(wtM001Dir, 'slices', 'S01'), { recursive: true });
|
|
130
|
+
mkdirSync(join(wtM001Dir, 'slices', 'S02'), { recursive: true });
|
|
131
|
+
writeFileSync(join(wtM001Dir, 'M001-ROADMAP.md'), '# Roadmap');
|
|
132
|
+
writeFileSync(join(wtM001Dir, 'slices', 'S01', 'S01-PLAN.md'), '# S01 Plan');
|
|
133
|
+
writeFileSync(join(wtM001Dir, 'slices', 'S02', 'S02-PLAN.md'), '# S02 Plan');
|
|
134
|
+
|
|
135
|
+
assertTrue(!existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'slices', 'S03')), 'S03 missing before sync');
|
|
136
|
+
|
|
137
|
+
syncGsdStateToWorktree(mainBase, wtBase);
|
|
138
|
+
|
|
139
|
+
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'slices', 'S03')), '#1311: S03 synced');
|
|
140
|
+
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'slices', 'S03', 'S03-PLAN.md')), 'S03 PLAN synced');
|
|
141
|
+
} finally {
|
|
142
|
+
cleanup(mainBase);
|
|
143
|
+
cleanup(wtBase);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── 4. No-op when both resolve to same directory (symlink) ───────────
|
|
148
|
+
console.log('\n=== 4. no-op when .gsd/ resolves to same path (symlinked) ===');
|
|
149
|
+
{
|
|
150
|
+
const sharedDir = createBase('shared');
|
|
151
|
+
const mainBase = mkdtempSync(join(tmpdir(), 'gsd-wt-sync-main-'));
|
|
152
|
+
const wtBase = mkdtempSync(join(tmpdir(), 'gsd-wt-sync-wt-'));
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
// Both main and worktree symlink to the same shared directory
|
|
156
|
+
writeFileSync(join(sharedDir, '.gsd', 'milestones', 'keep'), '');
|
|
157
|
+
symlinkSync(join(sharedDir, '.gsd'), join(mainBase, '.gsd'));
|
|
158
|
+
symlinkSync(join(sharedDir, '.gsd'), join(wtBase, '.gsd'));
|
|
159
|
+
|
|
160
|
+
const result = syncGsdStateToWorktree(mainBase, wtBase);
|
|
161
|
+
assertEq(result.synced.length, 0, 'no files synced when both point to same dir');
|
|
162
|
+
} finally {
|
|
163
|
+
cleanup(sharedDir);
|
|
164
|
+
rmSync(mainBase, { recursive: true, force: true });
|
|
165
|
+
rmSync(wtBase, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── 5. Root-level .gsd/ files synced ─────────────────────────────────
|
|
170
|
+
console.log('\n=== 5. root-level .gsd/ files synced ===');
|
|
171
|
+
{
|
|
172
|
+
const mainBase = createBase('main');
|
|
173
|
+
const wtBase = createBase('wt');
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
writeFileSync(join(mainBase, '.gsd', 'DECISIONS.md'), '# Decisions');
|
|
177
|
+
writeFileSync(join(mainBase, '.gsd', 'REQUIREMENTS.md'), '# Requirements');
|
|
178
|
+
writeFileSync(join(mainBase, '.gsd', 'PROJECT.md'), '# Project');
|
|
179
|
+
|
|
180
|
+
// Worktree has none of these
|
|
181
|
+
const result = syncGsdStateToWorktree(mainBase, wtBase);
|
|
182
|
+
|
|
183
|
+
assertTrue(existsSync(join(wtBase, '.gsd', 'DECISIONS.md')), 'DECISIONS.md synced');
|
|
184
|
+
assertTrue(existsSync(join(wtBase, '.gsd', 'REQUIREMENTS.md')), 'REQUIREMENTS.md synced');
|
|
185
|
+
assertTrue(existsSync(join(wtBase, '.gsd', 'PROJECT.md')), 'PROJECT.md synced');
|
|
186
|
+
assertTrue(result.synced.length >= 3, 'at least 3 files synced');
|
|
187
|
+
} finally {
|
|
188
|
+
cleanup(mainBase);
|
|
189
|
+
cleanup(wtBase);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── 6. Non-existent directories handled gracefully ───────────────────
|
|
194
|
+
console.log('\n=== 6. non-existent directories → no-op ===');
|
|
195
|
+
{
|
|
196
|
+
const result = syncGsdStateToWorktree('/tmp/does-not-exist-main', '/tmp/does-not-exist-wt');
|
|
197
|
+
assertEq(result.synced.length, 0, 'no crash on missing directories');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
report();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
main().catch((error) => {
|
|
204
|
+
console.error(error);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
});
|
package/package.json
CHANGED
package/pkg/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Auto-mode Supervisor —
|
|
2
|
+
* Auto-mode Supervisor — signal handling and working-tree activity detection.
|
|
3
3
|
*
|
|
4
4
|
* Pure functions — no module-level globals or AutoContext dependency.
|
|
5
5
|
*/
|
|
@@ -8,10 +8,10 @@ import { clearLock } from "./crash-recovery.js";
|
|
|
8
8
|
import { releaseSessionLock } from "./session-lock.js";
|
|
9
9
|
import { nativeHasChanges } from "./native-git-bridge.js";
|
|
10
10
|
|
|
11
|
-
// ───
|
|
11
|
+
// ─── Signal Handling ──────────────────────────────────────────────────────────
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* Register
|
|
14
|
+
* Register SIGTERM and SIGINT handlers that clear lock files and exit cleanly.
|
|
15
15
|
* Captures the active base path at registration time so the handler
|
|
16
16
|
* always references the correct path even if the module variable changes.
|
|
17
17
|
* Removes any previously registered handler before installing the new one.
|
|
@@ -22,20 +22,25 @@ export function registerSigtermHandler(
|
|
|
22
22
|
currentBasePath: string,
|
|
23
23
|
previousHandler: (() => void) | null,
|
|
24
24
|
): () => void {
|
|
25
|
-
if (previousHandler)
|
|
25
|
+
if (previousHandler) {
|
|
26
|
+
process.off("SIGTERM", previousHandler);
|
|
27
|
+
process.off("SIGINT", previousHandler);
|
|
28
|
+
}
|
|
26
29
|
const handler = () => {
|
|
27
30
|
releaseSessionLock(currentBasePath);
|
|
28
31
|
clearLock(currentBasePath);
|
|
29
32
|
process.exit(0);
|
|
30
33
|
};
|
|
31
34
|
process.on("SIGTERM", handler);
|
|
35
|
+
process.on("SIGINT", handler);
|
|
32
36
|
return handler;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
|
-
/** Deregister
|
|
39
|
+
/** Deregister signal handlers (called on stop/pause). */
|
|
36
40
|
export function deregisterSigtermHandler(handler: (() => void) | null): void {
|
|
37
41
|
if (handler) {
|
|
38
42
|
process.off("SIGTERM", handler);
|
|
43
|
+
process.off("SIGINT", handler);
|
|
39
44
|
}
|
|
40
45
|
}
|
|
41
46
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* manages create, enter, detect, and teardown for auto-mode worktrees.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { existsSync, readFileSync, realpathSync, unlinkSync, statSync, rmSync } from "node:fs";
|
|
9
|
+
import { existsSync, readFileSync, realpathSync, unlinkSync, statSync, rmSync, readdirSync, cpSync, lstatSync as lstatSyncFn } from "node:fs";
|
|
10
10
|
import { isAbsolute, join, sep } from "node:path";
|
|
11
11
|
import { GSDError, GSD_IO_ERROR, GSD_GIT_ERROR } from "./errors.js";
|
|
12
12
|
import { execSync, execFileSync } from "node:child_process";
|
|
@@ -45,6 +45,122 @@ import { getErrorMessage } from "./error-utils.js";
|
|
|
45
45
|
/** Original project root before chdir into auto-worktree. */
|
|
46
46
|
let originalBase: string | null = null;
|
|
47
47
|
|
|
48
|
+
// ─── Worktree ↔ Main Repo Sync (#1311) ──────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Sync .gsd/ state from the main repo into the worktree.
|
|
52
|
+
*
|
|
53
|
+
* When .gsd/ is a symlink to the external state directory, both the main
|
|
54
|
+
* repo and worktree share the same directory — no sync needed.
|
|
55
|
+
*
|
|
56
|
+
* When .gsd/ is a real directory (e.g., git-tracked or manage_gitignore:false),
|
|
57
|
+
* the worktree has its own copy that may be stale. This function copies
|
|
58
|
+
* missing milestones, CONTEXT, ROADMAP, DECISIONS, REQUIREMENTS, and
|
|
59
|
+
* PROJECT files from the main repo's .gsd/ into the worktree's .gsd/.
|
|
60
|
+
*
|
|
61
|
+
* Only adds missing content — never overwrites existing files in the worktree
|
|
62
|
+
* (the worktree's execution state is authoritative for in-progress work).
|
|
63
|
+
*/
|
|
64
|
+
export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: string): { synced: string[] } {
|
|
65
|
+
const mainGsd = gsdRoot(mainBasePath);
|
|
66
|
+
const wtGsd = gsdRoot(worktreePath_);
|
|
67
|
+
const synced: string[] = [];
|
|
68
|
+
|
|
69
|
+
// If both resolve to the same directory (symlink), no sync needed
|
|
70
|
+
try {
|
|
71
|
+
const mainResolved = realpathSync(mainGsd);
|
|
72
|
+
const wtResolved = realpathSync(wtGsd);
|
|
73
|
+
if (mainResolved === wtResolved) return { synced };
|
|
74
|
+
} catch {
|
|
75
|
+
// Can't resolve — proceed with sync as a safety measure
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!existsSync(mainGsd) || !existsSync(wtGsd)) return { synced };
|
|
79
|
+
|
|
80
|
+
// Sync root-level .gsd/ files (DECISIONS, REQUIREMENTS, PROJECT, KNOWLEDGE)
|
|
81
|
+
const rootFiles = ["DECISIONS.md", "REQUIREMENTS.md", "PROJECT.md", "KNOWLEDGE.md", "OVERRIDES.md"];
|
|
82
|
+
for (const f of rootFiles) {
|
|
83
|
+
const src = join(mainGsd, f);
|
|
84
|
+
const dst = join(wtGsd, f);
|
|
85
|
+
if (existsSync(src) && !existsSync(dst)) {
|
|
86
|
+
try {
|
|
87
|
+
cpSync(src, dst);
|
|
88
|
+
synced.push(f);
|
|
89
|
+
} catch { /* non-fatal */ }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Sync milestones: copy entire milestone directories that are missing
|
|
94
|
+
const mainMilestonesDir = join(mainGsd, "milestones");
|
|
95
|
+
const wtMilestonesDir = join(wtGsd, "milestones");
|
|
96
|
+
if (existsSync(mainMilestonesDir) && existsSync(wtMilestonesDir)) {
|
|
97
|
+
try {
|
|
98
|
+
const mainMilestones = readdirSync(mainMilestonesDir, { withFileTypes: true })
|
|
99
|
+
.filter(d => d.isDirectory() && /^M\d{3}/.test(d.name))
|
|
100
|
+
.map(d => d.name);
|
|
101
|
+
|
|
102
|
+
for (const mid of mainMilestones) {
|
|
103
|
+
const srcDir = join(mainMilestonesDir, mid);
|
|
104
|
+
const dstDir = join(wtMilestonesDir, mid);
|
|
105
|
+
|
|
106
|
+
if (!existsSync(dstDir)) {
|
|
107
|
+
// Entire milestone missing from worktree — copy it
|
|
108
|
+
try {
|
|
109
|
+
cpSync(srcDir, dstDir, { recursive: true });
|
|
110
|
+
synced.push(`milestones/${mid}/`);
|
|
111
|
+
} catch { /* non-fatal */ }
|
|
112
|
+
} else {
|
|
113
|
+
// Milestone directory exists but may be missing files (stale snapshot).
|
|
114
|
+
// Sync individual top-level milestone files (CONTEXT, ROADMAP, RESEARCH, etc.)
|
|
115
|
+
try {
|
|
116
|
+
const srcFiles = readdirSync(srcDir).filter(f => f.endsWith(".md") || f.endsWith(".json"));
|
|
117
|
+
for (const f of srcFiles) {
|
|
118
|
+
const srcFile = join(srcDir, f);
|
|
119
|
+
const dstFile = join(dstDir, f);
|
|
120
|
+
if (!existsSync(dstFile)) {
|
|
121
|
+
try {
|
|
122
|
+
const srcStat = lstatSyncFn(srcFile);
|
|
123
|
+
if (srcStat.isFile()) {
|
|
124
|
+
cpSync(srcFile, dstFile);
|
|
125
|
+
synced.push(`milestones/${mid}/${f}`);
|
|
126
|
+
}
|
|
127
|
+
} catch { /* non-fatal */ }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Sync slices directory if it exists in main but not in worktree
|
|
132
|
+
const srcSlicesDir = join(srcDir, "slices");
|
|
133
|
+
const dstSlicesDir = join(dstDir, "slices");
|
|
134
|
+
if (existsSync(srcSlicesDir) && !existsSync(dstSlicesDir)) {
|
|
135
|
+
try {
|
|
136
|
+
cpSync(srcSlicesDir, dstSlicesDir, { recursive: true });
|
|
137
|
+
synced.push(`milestones/${mid}/slices/`);
|
|
138
|
+
} catch { /* non-fatal */ }
|
|
139
|
+
} else if (existsSync(srcSlicesDir) && existsSync(dstSlicesDir)) {
|
|
140
|
+
// Both exist — sync missing slice directories
|
|
141
|
+
const srcSlices = readdirSync(srcSlicesDir, { withFileTypes: true })
|
|
142
|
+
.filter(d => d.isDirectory())
|
|
143
|
+
.map(d => d.name);
|
|
144
|
+
for (const sid of srcSlices) {
|
|
145
|
+
const srcSlice = join(srcSlicesDir, sid);
|
|
146
|
+
const dstSlice = join(dstSlicesDir, sid);
|
|
147
|
+
if (!existsSync(dstSlice)) {
|
|
148
|
+
try {
|
|
149
|
+
cpSync(srcSlice, dstSlice, { recursive: true });
|
|
150
|
+
synced.push(`milestones/${mid}/slices/${sid}/`);
|
|
151
|
+
} catch { /* non-fatal */ }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch { /* non-fatal */ }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} catch { /* non-fatal */ }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { synced };
|
|
162
|
+
}
|
|
163
|
+
|
|
48
164
|
// ─── Worktree Post-Create Hook (#597) ────────────────────────────────────────
|
|
49
165
|
|
|
50
166
|
/**
|
|
@@ -125,6 +241,12 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin
|
|
|
125
241
|
// Ensure worktree shares external state via symlink
|
|
126
242
|
ensureGsdSymlink(info.path);
|
|
127
243
|
|
|
244
|
+
// Sync .gsd/ state from main repo into the worktree (#1311).
|
|
245
|
+
// Even with the symlink, the worktree may have stale git-tracked files
|
|
246
|
+
// if .gsd/ is not gitignored. And on fresh create, the milestone files
|
|
247
|
+
// created on main since the branch point won't be in the worktree.
|
|
248
|
+
syncGsdStateToWorktree(basePath, info.path);
|
|
249
|
+
|
|
128
250
|
// Run user-configured post-create hook (#597) — e.g. copy .env, symlink assets
|
|
129
251
|
const hookError = runWorktreePostCreateHook(basePath, info.path);
|
|
130
252
|
if (hookError) {
|
|
@@ -267,6 +389,18 @@ export function enterAutoWorktree(basePath: string, milestoneId: string): string
|
|
|
267
389
|
throw new GSDError(GSD_IO_ERROR, `Auto-worktree path ${p} exists but .git is unreadable`);
|
|
268
390
|
}
|
|
269
391
|
|
|
392
|
+
// Ensure worktree shares external state via symlink (#1311).
|
|
393
|
+
// On resume (enterAutoWorktree), the symlink may be missing if it was
|
|
394
|
+
// created before ensureGsdSymlink existed, or the .gsd/ directory may be
|
|
395
|
+
// a stale git-tracked copy instead of a symlink. Refreshing here ensures
|
|
396
|
+
// the worktree sees the same milestone state as the main repo.
|
|
397
|
+
ensureGsdSymlink(p);
|
|
398
|
+
|
|
399
|
+
// Sync .gsd/ state from main repo into worktree (#1311).
|
|
400
|
+
// Covers the case where .gsd/ is a real directory (not symlinked) and
|
|
401
|
+
// milestones were created on main after the worktree was last used.
|
|
402
|
+
syncGsdStateToWorktree(basePath, p);
|
|
403
|
+
|
|
270
404
|
const previousCwd = process.cwd();
|
|
271
405
|
|
|
272
406
|
try {
|
|
@@ -52,8 +52,20 @@ import { handleStart, handleTemplates, getTemplateCompletions } from "./commands
|
|
|
52
52
|
|
|
53
53
|
/** Resolve the effective project root, accounting for worktree paths. */
|
|
54
54
|
export function projectRoot(): string {
|
|
55
|
-
const
|
|
56
|
-
|
|
55
|
+
const cwd = process.cwd();
|
|
56
|
+
const root = resolveProjectRoot(cwd);
|
|
57
|
+
|
|
58
|
+
// When running inside a GSD worktree, the resolved root may be a "dangerous"
|
|
59
|
+
// directory (e.g., $HOME used as a git repo root — #1317). The safety check
|
|
60
|
+
// should validate the actual working directory, not the upstream root,
|
|
61
|
+
// because the worktree itself is a safe project subdirectory.
|
|
62
|
+
// Only skip the root check when we can confirm we're in a valid worktree.
|
|
63
|
+
if (root !== cwd) {
|
|
64
|
+
// We're in a worktree — validate the worktree path instead of the root
|
|
65
|
+
assertSafeDirectory(cwd);
|
|
66
|
+
} else {
|
|
67
|
+
assertSafeDirectory(root);
|
|
68
|
+
}
|
|
57
69
|
return root;
|
|
58
70
|
}
|
|
59
71
|
|
|
@@ -336,13 +336,17 @@ export class GitServiceImpl {
|
|
|
336
336
|
* @param extraExclusions Additional pathspec exclusions beyond RUNTIME_EXCLUSION_PATHS.
|
|
337
337
|
*/
|
|
338
338
|
private smartStage(extraExclusions: readonly string[] = []): void {
|
|
339
|
-
// Always exclude .gsd/ — state is managed externally (symlinked to ~/.gsd/projects/<hash>/)
|
|
340
|
-
const allExclusions = [".gsd/", ...extraExclusions];
|
|
341
|
-
|
|
342
339
|
// One-time cleanup: if runtime files are already tracked in the index
|
|
343
340
|
// (from older versions where the fallback bug staged them), untrack them
|
|
344
341
|
// in a dedicated commit. This must happen as a separate commit because
|
|
345
342
|
// the git reset HEAD step below would otherwise undo the rm --cached.
|
|
343
|
+
//
|
|
344
|
+
// SAFETY: Only untrack the specific RUNTIME paths (activity/, runtime/,
|
|
345
|
+
// auto.lock, etc.) — NOT all of .gsd/. If .gsd/milestones/ files were
|
|
346
|
+
// previously tracked, they stay tracked until the milestone completes
|
|
347
|
+
// and the worktree is torn down. This prevents a mid-execution behavioral
|
|
348
|
+
// discontinuity where the first half of a milestone has .gsd/ artifacts
|
|
349
|
+
// committed but the second half doesn't (#1326).
|
|
346
350
|
if (!this._runtimeFilesCleanedUp) {
|
|
347
351
|
let cleaned = false;
|
|
348
352
|
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
|
|
@@ -357,17 +361,19 @@ export class GitServiceImpl {
|
|
|
357
361
|
|
|
358
362
|
// Stage everything, then unstage excluded paths.
|
|
359
363
|
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
364
|
+
// Exclude only RUNTIME paths from staging — not the entire .gsd/ directory.
|
|
365
|
+
// When .gsd/milestones/ files are already tracked in the index (projects
|
|
366
|
+
// where .gsd/ is not gitignored, or Windows junctions that git sees as
|
|
367
|
+
// real directories), they should continue to be committed. Excluding the
|
|
368
|
+
// entire .gsd/ directory mid-milestone causes silent commit failure where
|
|
369
|
+
// the second half of a milestone's artifacts are never committed (#1326).
|
|
365
370
|
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
371
|
+
// If .gsd/ IS in .gitignore (the default for external state projects),
|
|
372
|
+
// git add -A already skips it and the reset is a harmless no-op.
|
|
368
373
|
nativeAddAll(this.basePath);
|
|
369
374
|
|
|
370
|
-
|
|
375
|
+
const runtimeExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions];
|
|
376
|
+
for (const exclusion of runtimeExclusions) {
|
|
371
377
|
try { nativeResetPaths(this.basePath, [exclusion]); } catch { /* path not staged — ignore */ }
|
|
372
378
|
}
|
|
373
379
|
}
|