diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 22543761d..68e1c714d 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -471,8 +471,8 @@ async function runCommand(command, args, cwd, raw, defaultValue) { const { verify } = parseNamedArgs(args, [], ['verify']); state.cmdStateSync(cwd, { verify }, raw); } else if (subcommand === 'prune') { - const { 'keep-recent': keepRecent } = parseNamedArgs(args, ['keep-recent']); - state.cmdStatePrune(cwd, { keepRecent: keepRecent || '3' }, raw); + const { 'keep-recent': keepRecent, 'dry-run': dryRun } = parseNamedArgs(args, ['keep-recent'], ['dry-run']); + state.cmdStatePrune(cwd, { keepRecent: keepRecent || '3', dryRun: !!dryRun }, raw); } else { state.cmdStateLoad(cwd, raw); } diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index 34d3f3c54..87a9ce318 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -1388,14 +1388,19 @@ function cmdStateSync(cwd, options, raw) { /** * Prune old entries from STATE.md sections that grow unboundedly (#1970). - * Moves decisions, performance metrics rows, and recently-completed summaries + * Moves decisions, recently-completed summaries, and resolved blockers * older than keepRecent phases to STATE-ARCHIVE.md. + * + * Options: + * keepRecent: number of recent phases to retain (default: 3) + * dryRun: if true, return what would be pruned without modifying STATE.md */ function cmdStatePrune(cwd, options, raw) { const statePath = planningPaths(cwd).state; if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } const keepRecent = parseInt(options.keepRecent, 10) || 3; + const dryRun = !!options.dryRun; const currentPhaseRaw = stateExtractField(fs.readFileSync(statePath, 'utf-8'), 'Current Phase'); const currentPhase = parseInt(currentPhaseRaw, 10) || 0; const cutoff = currentPhase - keepRecent; @@ -1408,7 +1413,11 @@ function cmdStatePrune(cwd, options, raw) { const archivePath = path.join(path.dirname(statePath), 'STATE-ARCHIVE.md'); const archived = []; - readModifyWriteStateMd(statePath, (content) => { + // Shared pruning logic applied to both dry-run and real passes. + // Returns { newContent, archivedSections }. + function prunePass(content) { + const sections = []; + // Prune Decisions section: entries like "- [Phase N]: ..." const decisionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; const decMatch = content.match(decisionPattern); @@ -1425,7 +1434,7 @@ function cmdStatePrune(cwd, options, raw) { } } if (archive.length > 0) { - archived.push({ section: 'Decisions', count: archive.length, lines: archive }); + sections.push({ section: 'Decisions', count: archive.length, lines: archive }); content = content.replace(decisionPattern, (_m, header) => `${header}${keep.join('\n')}`); } } @@ -1446,12 +1455,58 @@ function cmdStatePrune(cwd, options, raw) { } } if (archive.length > 0) { - archived.push({ section: 'Recently Completed', count: archive.length, lines: archive }); + sections.push({ section: 'Recently Completed', count: archive.length, lines: archive }); content = content.replace(recentPattern, (_m, header) => `${header}${keep.join('\n')}`); } } - return content; + // Prune resolved blockers: lines marked as resolved (strikethrough ~~text~~ + // or "[RESOLVED]" prefix) with a phase reference older than cutoff + const blockersPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; + const blockersMatch = content.match(blockersPattern); + if (blockersMatch) { + const lines = blockersMatch[2].split('\n'); + const keep = []; + const archive = []; + for (const line of lines) { + const isResolved = /~~.*~~|\[RESOLVED\]/i.test(line); + const phaseMatch = line.match(/Phase\s+(\d+)/i); + if (isResolved && phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { + archive.push(line); + } else { + keep.push(line); + } + } + if (archive.length > 0) { + sections.push({ section: 'Blockers (resolved)', count: archive.length, lines: archive }); + content = content.replace(blockersPattern, (_m, header) => `${header}${keep.join('\n')}`); + } + } + + return { newContent: content, archivedSections: sections }; + } + + if (dryRun) { + // Dry-run: compute what would be pruned without writing anything + const content = fs.readFileSync(statePath, 'utf-8'); + const result = prunePass(content); + const totalPruned = result.archivedSections.reduce((sum, s) => sum + s.count, 0); + output({ + pruned: false, + dry_run: true, + cutoff_phase: cutoff, + keep_recent: keepRecent, + sections: result.archivedSections.map(s => ({ section: s.section, entries_would_archive: s.count })), + total_would_archive: totalPruned, + note: totalPruned > 0 ? 'Run without --dry-run to actually prune' : 'Nothing to prune', + }, raw, totalPruned > 0 ? 'true' : 'false'); + return; + } + + readModifyWriteStateMd(statePath, (content) => { + const result = prunePass(content); + archived.push(...result.archivedSections); + return result.newContent; }, cwd); // Write archived entries to STATE-ARCHIVE.md diff --git a/tests/state-prune.test.cjs b/tests/state-prune.test.cjs new file mode 100644 index 000000000..1a9be43da --- /dev/null +++ b/tests/state-prune.test.cjs @@ -0,0 +1,152 @@ +/** + * Tests for `state prune` command (#1970). + */ + +'use strict'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +function writeStateMd(tmpDir, content) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content); +} + +function readStateMd(tmpDir) { + return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); +} + +function archiveExists(tmpDir) { + return fs.existsSync(path.join(tmpDir, '.planning', 'STATE-ARCHIVE.md')); +} + +function readArchive(tmpDir) { + return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE-ARCHIVE.md'), 'utf-8'); +} + +describe('state prune (#1970)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('prunes decisions older than cutoff', () => { + writeStateMd(tmpDir, [ + '# Session State', + '', + '**Current Phase:** 10', + '', + '## Decisions', + '', + '- [Phase 1]: Old decision', + '- [Phase 3]: Old decision 3', + '- [Phase 8]: Recent decision', + '- [Phase 10]: Current decision', + '', + ].join('\n')); + + const result = runGsdTools('state prune --keep-recent 3', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.pruned, true); + assert.strictEqual(output.cutoff_phase, 7); + + const newState = readStateMd(tmpDir); + assert.match(newState, /\[Phase 8\]: Recent decision/); + assert.match(newState, /\[Phase 10\]: Current decision/); + assert.doesNotMatch(newState, /\[Phase 1\]: Old decision/); + assert.doesNotMatch(newState, /\[Phase 3\]: Old decision 3/); + + assert.ok(archiveExists(tmpDir), 'STATE-ARCHIVE.md should exist'); + const archive = readArchive(tmpDir); + assert.match(archive, /\[Phase 1\]: Old decision/); + assert.match(archive, /\[Phase 3\]: Old decision 3/); + }); + + test('--dry-run reports what would be pruned without modifying STATE.md', () => { + const originalContent = [ + '# Session State', + '', + '**Current Phase:** 10', + '', + '## Decisions', + '', + '- [Phase 1]: Old decision', + '- [Phase 2]: Another old decision', + '- [Phase 9]: Recent decision', + '', + ].join('\n'); + writeStateMd(tmpDir, originalContent); + + const result = runGsdTools('state prune --keep-recent 3 --dry-run', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.pruned, false); + assert.strictEqual(output.dry_run, true); + assert.strictEqual(output.total_would_archive, 2); + + // STATE.md should be unchanged + const unchanged = readStateMd(tmpDir); + assert.strictEqual(unchanged, originalContent); + + // No archive file should be created + assert.ok(!archiveExists(tmpDir), 'dry-run should not create archive'); + }); + + test('prunes resolved blockers older than cutoff', () => { + writeStateMd(tmpDir, [ + '# Session State', + '', + '**Current Phase:** 10', + '', + '## Blockers', + '', + '- ~~Phase 1: Old resolved issue~~', + '- [RESOLVED] Phase 2: Another old issue', + '- Phase 9: Current blocker (unresolved)', + '', + ].join('\n')); + + const result = runGsdTools('state prune --keep-recent 3', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.pruned, true); + const blockerSection = output.sections.find(s => /Blockers/i.test(s.section)); + assert.ok(blockerSection, 'should report Blockers section'); + assert.strictEqual(blockerSection.entries_archived, 2); + + const newState = readStateMd(tmpDir); + assert.match(newState, /Phase 9: Current blocker/); + assert.doesNotMatch(newState, /Phase 1: Old resolved issue/); + }); + + test('returns pruned:false when nothing to prune', () => { + writeStateMd(tmpDir, [ + '# Session State', + '', + '**Current Phase:** 2', + '', + '## Decisions', + '', + '- [Phase 1]: Recent decision', + '- [Phase 2]: Current decision', + '', + ].join('\n')); + + const result = runGsdTools('state prune --keep-recent 3', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.pruned, false); + }); +});