mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
Compare commits
1 Commits
fix/2240-i
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56a8041b3a |
@@ -714,6 +714,16 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
|
||||
}
|
||||
}
|
||||
phase.cmdPhaseAdd(cwd, descArgs.join(' '), raw, customId);
|
||||
} else if (subcommand === 'add-batch') {
|
||||
// Accepts JSON array of descriptions via --descriptions '[...]' or positional args
|
||||
const descFlagIdx = args.indexOf('--descriptions');
|
||||
let descriptions;
|
||||
if (descFlagIdx !== -1 && args[descFlagIdx + 1]) {
|
||||
try { descriptions = JSON.parse(args[descFlagIdx + 1]); } catch (e) { error('--descriptions must be a JSON array'); }
|
||||
} else {
|
||||
descriptions = args.slice(2).filter(a => a !== '--raw');
|
||||
}
|
||||
phase.cmdPhaseAddBatch(cwd, descriptions, raw);
|
||||
} else if (subcommand === 'insert') {
|
||||
phase.cmdPhaseInsert(cwd, args[2], args.slice(3).join(' '), raw);
|
||||
} else if (subcommand === 'remove') {
|
||||
@@ -722,7 +732,7 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
|
||||
} else if (subcommand === 'complete') {
|
||||
phase.cmdPhaseComplete(cwd, args[2], raw);
|
||||
} else {
|
||||
error('Unknown phase subcommand. Available: next-decimal, add, insert, remove, complete');
|
||||
error('Unknown phase subcommand. Available: next-decimal, add, add-batch, insert, remove, complete');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -408,6 +408,76 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
||||
output(result, raw, result.padded);
|
||||
}
|
||||
|
||||
function cmdPhaseAddBatch(cwd, descriptions, raw) {
|
||||
if (!Array.isArray(descriptions) || descriptions.length === 0) {
|
||||
error('descriptions array required for phase add-batch');
|
||||
}
|
||||
const config = loadConfig(cwd);
|
||||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||||
if (!fs.existsSync(roadmapPath)) { error('ROADMAP.md not found'); }
|
||||
const projectCode = config.project_code || '';
|
||||
const prefix = projectCode ? `${projectCode}-` : '';
|
||||
|
||||
const results = withPlanningLock(cwd, () => {
|
||||
let rawContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const content = extractCurrentMilestone(rawContent, cwd);
|
||||
let maxPhase = 0;
|
||||
if (config.phase_naming !== 'custom') {
|
||||
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
|
||||
let m;
|
||||
while ((m = phasePattern.exec(content)) !== null) {
|
||||
const num = parseInt(m[1], 10);
|
||||
if (num >= 999) continue;
|
||||
if (num > maxPhase) maxPhase = num;
|
||||
}
|
||||
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;
|
||||
if (num > maxPhase) maxPhase = num;
|
||||
}
|
||||
}
|
||||
}
|
||||
const added = [];
|
||||
for (const description of descriptions) {
|
||||
const slug = generateSlugInternal(description);
|
||||
let newPhaseId, dirName;
|
||||
if (config.phase_naming === 'custom') {
|
||||
newPhaseId = slug.toUpperCase().replace(/-/g, '-');
|
||||
dirName = `${prefix}${newPhaseId}-${slug}`;
|
||||
} else {
|
||||
maxPhase += 1;
|
||||
newPhaseId = maxPhase;
|
||||
dirName = `${prefix}${String(newPhaseId).padStart(2, '0')}-${slug}`;
|
||||
}
|
||||
const dirPath = path.join(planningDir(cwd), 'phases', dirName);
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
|
||||
const dependsOn = config.phase_naming === 'custom' ? '' : `\n**Depends on:** Phase ${typeof newPhaseId === 'number' ? newPhaseId - 1 : 'TBD'}`;
|
||||
const phaseEntry = `\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd-plan-phase ${newPhaseId} to break down)\n`;
|
||||
const lastSeparator = rawContent.lastIndexOf('\n---');
|
||||
rawContent = lastSeparator > 0
|
||||
? rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator)
|
||||
: rawContent + phaseEntry;
|
||||
added.push({
|
||||
phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
|
||||
padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
|
||||
name: description,
|
||||
slug,
|
||||
directory: toPosixPath(path.join(path.relative(cwd, planningDir(cwd)), 'phases', dirName)),
|
||||
naming_mode: config.phase_naming,
|
||||
});
|
||||
}
|
||||
atomicWriteFileSync(roadmapPath, rawContent);
|
||||
return added;
|
||||
});
|
||||
output({ phases: results, count: results.length }, raw);
|
||||
}
|
||||
|
||||
function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
||||
if (!afterPhase || !description) {
|
||||
error('after-phase and description required for phase insert');
|
||||
@@ -979,6 +1049,7 @@ module.exports = {
|
||||
cmdFindPhase,
|
||||
cmdPhasePlanIndex,
|
||||
cmdPhaseAdd,
|
||||
cmdPhaseAddBatch,
|
||||
cmdPhaseInsert,
|
||||
cmdPhaseRemove,
|
||||
cmdPhaseComplete,
|
||||
|
||||
@@ -891,6 +891,95 @@ describe('phase add with project_code', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// phase add-batch command (#2165)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('phase add-batch command (#2165)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
[
|
||||
'# Roadmap v1.0',
|
||||
'',
|
||||
'### Phase 1: Foundation',
|
||||
'**Goal:** Setup',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('adds multiple phases with sequential numbers in a single call', () => {
|
||||
// Use array form to avoid shell quoting issues with JSON args
|
||||
const result = runGsdTools(['phase', 'add-batch', '--descriptions', '["Alpha","Beta","Gamma"]'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.count, 3, 'should report 3 phases added');
|
||||
assert.strictEqual(output.phases[0].phase_number, 2);
|
||||
assert.strictEqual(output.phases[1].phase_number, 3);
|
||||
assert.strictEqual(output.phases[2].phase_number, 4);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-alpha')), '02-alpha dir must exist');
|
||||
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-beta')), '03-beta dir must exist');
|
||||
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'phases', '04-gamma')), '04-gamma dir must exist');
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
assert.ok(roadmap.includes('### Phase 2: Alpha'), 'roadmap should include Phase 2');
|
||||
assert.ok(roadmap.includes('### Phase 3: Beta'), 'roadmap should include Phase 3');
|
||||
assert.ok(roadmap.includes('### Phase 4: Gamma'), 'roadmap should include Phase 4');
|
||||
});
|
||||
|
||||
test('no duplicate phase numbers when multiple add-batch calls are made sequentially', () => {
|
||||
// Regression for #2165: parallel `phase add` invocations produced duplicates
|
||||
// because each read disk state before any write landed. add-batch serializes
|
||||
// the entire batch under a single lock so the next call sees the updated state.
|
||||
const r1 = runGsdTools(['phase', 'add-batch', '--descriptions', '["Wave-One-A","Wave-One-B"]'], tmpDir);
|
||||
assert.ok(r1.success, `First batch failed: ${r1.error}`);
|
||||
|
||||
const r2 = runGsdTools(['phase', 'add-batch', '--descriptions', '["Wave-Two-A","Wave-Two-B"]'], tmpDir);
|
||||
assert.ok(r2.success, `Second batch failed: ${r2.error}`);
|
||||
|
||||
const out1 = JSON.parse(r1.output);
|
||||
const out2 = JSON.parse(r2.output);
|
||||
const allNums = [...out1.phases, ...out2.phases].map(p => p.phase_number);
|
||||
const unique = new Set(allNums);
|
||||
assert.strictEqual(unique.size, allNums.length, `Duplicate phase numbers detected: ${allNums}`);
|
||||
|
||||
// Directories must all exist and be unique
|
||||
const dirs = fs.readdirSync(path.join(tmpDir, '.planning', 'phases'));
|
||||
assert.strictEqual(dirs.length, 4, `Expected 4 phase dirs, got: ${dirs}`);
|
||||
});
|
||||
|
||||
test('each phase directory contains a .gitkeep file', () => {
|
||||
const result = runGsdTools(['phase', 'add-batch', '--descriptions', '["Setup","Build"]'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-setup', '.gitkeep')),
|
||||
'.gitkeep must exist in 02-setup'
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-build', '.gitkeep')),
|
||||
'.gitkeep must exist in 03-build'
|
||||
);
|
||||
});
|
||||
|
||||
test('returns error for empty descriptions array', () => {
|
||||
const result = runGsdTools(['phase', 'add-batch', '--descriptions', '[]'], tmpDir);
|
||||
assert.ok(!result.success, 'should fail on empty array');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// phase insert command
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user