From 7f11362952685b7311b4e9b460c3d4731bb7906f Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 10 Apr 2026 12:04:33 -0400 Subject: [PATCH] fix(phase): scan .planning/phases/ for orphan dirs in phase add (#2034) cmdPhaseAdd computed maxPhase from ROADMAP.md only, allowing orphan directories on disk (untracked in roadmap) to silently collide with newly added phases. The new phase's mkdirSync succeeded against the existing directory, contaminating it with fresh content. Fix: take max(roadmapMax, diskMax) where diskMax scans .planning/phases/ and strips optional project_code prefix before parsing the leading integer. Backlog orphans (>=999) are skipped. Adds 3 regression tests covering: - orphan dir with number higher than roadmap max - prefixed orphan dirs (project_code-NN-slug) - no collision when orphan number is lower than roadmap max Fixes #2026 Co-authored-by: Claude Sonnet 4.6 --- get-shit-done/bin/lib/phase.cjs | 19 +++++- tests/phase.test.cjs | 111 ++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs index ecde4d5c..e470eec0 100644 --- a/get-shit-done/bin/lib/phase.cjs +++ b/get-shit-done/bin/lib/phase.cjs @@ -340,7 +340,9 @@ function cmdPhaseAdd(cwd, description, raw, customId) { if (!_newPhaseId) error('--id required when phase_naming is "custom"'); _dirName = `${prefix}${_newPhaseId}-${slug}`; } else { - // Sequential mode: find highest integer phase number (in current milestone only) + // Sequential mode: find highest integer phase number from two sources: + // 1. ROADMAP.md (current milestone only) + // 2. .planning/phases/ on disk (orphan directories not tracked in roadmap) // Skip 999.x backlog phases — they live outside the active sequence const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi; let maxPhase = 0; @@ -351,6 +353,21 @@ function cmdPhaseAdd(cwd, description, raw, customId) { if (num > maxPhase) maxPhase = num; } + // Also scan .planning/phases/ for orphan directories not tracked in ROADMAP. + // Directory names follow: [PREFIX-]NN-slug (e.g. 03-api or CK-05-old-feature). + // Strip the optional project_code prefix before extracting the leading integer. + const phasesOnDisk = path.join(planningDir(cwd), 'phases'); + if (fs.existsSync(phasesOnDisk)) { + const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/; + for (const entry of fs.readdirSync(phasesOnDisk)) { + const match = entry.match(dirNumPattern); + if (!match) continue; + const num = parseInt(match[1], 10); + if (num >= 999) continue; // skip backlog orphans + if (num > maxPhase) maxPhase = num; + } + } + _newPhaseId = maxPhase + 1; const paddedNum = String(_newPhaseId).padStart(2, '0'); _dirName = `${prefix}${paddedNum}-${slug}`; diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index c06cf3d9..338fb259 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -694,6 +694,117 @@ describe('phase add command', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// phase add — orphan directory collision prevention (#2026) +// ───────────────────────────────────────────────────────────────────────────── + +describe('phase add — orphan directory collision prevention (#2026)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('orphan directory with higher number than ROADMAP pushes maxPhase up', () => { + // Orphan directory 05-orphan exists on disk but is NOT in ROADMAP.md + const orphanDir = path.join(tmpDir, '.planning', 'phases', '05-orphan'); + fs.mkdirSync(orphanDir, { recursive: true }); + fs.writeFileSync(path.join(orphanDir, 'SUMMARY.md'), 'existing work'); + + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# Roadmap', + '## Milestone v1', + '### Phase 1: First phase', + '**Plans:** 0 plans', + '---', + ].join('\n') + ); + + const result = runGsdTools('phase add dashboard', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + // ROADMAP max is 1, but orphan 05-orphan means disk max is 5 → new phase = 6 + assert.strictEqual(output.phase_number, 6, 'should be phase 6 (orphan 05 pushes max to 5)'); + + // The new directory must be 06-dashboard, not 02-dashboard + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'phases', '06-dashboard')), + 'new phase directory must be 06-dashboard, not collide with orphan 05-orphan' + ); + + // The orphan directory must be untouched + assert.ok( + fs.existsSync(path.join(orphanDir, 'SUMMARY.md')), + 'orphan directory content must be preserved (not overwritten)' + ); + }); + + test('orphan directories with 999.x prefix are skipped when calculating disk max', () => { + // 999.x backlog orphans must not inflate the next sequential phase number + const backlogOrphan = path.join(tmpDir, '.planning', 'phases', '999-backlog-stuff'); + fs.mkdirSync(backlogOrphan, { recursive: true }); + + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# Roadmap', + '### Phase 1: Foundation', + '**Plans:** 0 plans', + '---', + ].join('\n') + ); + + const result = runGsdTools('phase add new-feature', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + // ROADMAP max is 1, disk orphan is 999 (backlog) → should be ignored → new phase = 2 + assert.strictEqual(output.phase_number, 2, 'backlog 999.x orphan must not inflate phase count'); + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-new-feature')), + 'new phase directory should be 02-new-feature' + ); + }); + + test('project_code prefix in orphan directory name is stripped before comparing', () => { + // Orphan directory has project_code prefix e.g. CK-05-orphan + const orphanDir = path.join(tmpDir, '.planning', 'phases', 'CK-05-old-feature'); + fs.mkdirSync(orphanDir, { recursive: true }); + + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ project_code: 'CK' }) + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# Roadmap', + '### Phase 1: Foundation', + '**Plans:** 0 plans', + '---', + ].join('\n') + ); + + const result = runGsdTools('phase add new-feature', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + // ROADMAP max is 1, disk has CK-05-old-feature → strip prefix → disk max is 5 → new phase = 6 + assert.strictEqual(output.phase_number, 6, 'project_code prefix must be stripped before disk max calculation'); + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'phases', 'CK-06-new-feature')), + 'new phase directory must be CK-06-new-feature' + ); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // phase add with project_code prefix // ─────────────────────────────────────────────────────────────────────────────