diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 0eb9d628..e92f7491 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -112,6 +112,40 @@ function findProjectRoot(startDir) { // ─── Output helpers ─────────────────────────────────────────────────────────── +/** + * Remove stale gsd-* temp files/dirs older than maxAgeMs (default: 5 minutes). + * Runs opportunistically before each new temp file write to prevent unbounded accumulation. + * @param {string} prefix - filename prefix to match (e.g., 'gsd-') + * @param {object} opts + * @param {number} opts.maxAgeMs - max age in ms before removal (default: 5 min) + * @param {boolean} opts.dirsOnly - if true, only remove directories (default: false) + */ +function reapStaleTempFiles(prefix = 'gsd-', { maxAgeMs = 5 * 60 * 1000, dirsOnly = false } = {}) { + try { + const tmpDir = require('os').tmpdir(); + const now = Date.now(); + const entries = fs.readdirSync(tmpDir); + for (const entry of entries) { + if (!entry.startsWith(prefix)) continue; + const fullPath = path.join(tmpDir, entry); + try { + const stat = fs.statSync(fullPath); + if (now - stat.mtimeMs > maxAgeMs) { + if (stat.isDirectory()) { + fs.rmSync(fullPath, { recursive: true, force: true }); + } else if (!dirsOnly) { + fs.unlinkSync(fullPath); + } + } + } catch { + // File may have been removed between readdir and stat — ignore + } + } + } catch { + // Non-critical — don't let cleanup failures break output + } +} + function output(result, raw, rawValue) { if (raw && rawValue !== undefined) { process.stdout.write(String(rawValue)); @@ -120,6 +154,7 @@ function output(result, raw, rawValue) { // Large payloads exceed Claude Code's Bash tool buffer (~50KB). // Write to tmpfile and output the path prefixed with @file: so callers can detect it. if (json.length > 50000) { + reapStaleTempFiles(); const tmpPath = path.join(require('os').tmpdir(), `gsd-${Date.now()}.json`); fs.writeFileSync(tmpPath, json, 'utf-8'); process.stdout.write('@file:' + tmpPath); @@ -1005,6 +1040,7 @@ module.exports = { withPlanningLock, findProjectRoot, detectSubRepos, + reapStaleTempFiles, MODEL_ALIAS_MAP, planningDir, planningPaths, diff --git a/get-shit-done/bin/lib/profile-pipeline.cjs b/get-shit-done/bin/lib/profile-pipeline.cjs index dc06592d..acfc73d6 100644 --- a/get-shit-done/bin/lib/profile-pipeline.cjs +++ b/get-shit-done/bin/lib/profile-pipeline.cjs @@ -12,7 +12,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); const readline = require('readline'); -const { output, error, safeReadFile } = require('./core.cjs'); +const { output, error, safeReadFile, reapStaleTempFiles } = require('./core.cjs'); // ─── Session I/O Helpers ────────────────────────────────────────────────────── @@ -333,6 +333,7 @@ async function cmdExtractMessages(projectArg, options, raw, overridePath) { sessions = sessions.slice(0, options.limit); } + reapStaleTempFiles('gsd-pipeline-', { dirsOnly: true }); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pipeline-')); const outputPath = path.join(tmpDir, 'extracted-messages.jsonl'); @@ -511,6 +512,7 @@ async function cmdProfileSample(overridePath, options, raw) { } } + reapStaleTempFiles('gsd-profile-', { dirsOnly: true }); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-')); const outputPath = path.join(tmpDir, 'profile-sample.jsonl'); for (const msg of allMessages) { diff --git a/tests/core.test.cjs b/tests/core.test.cjs index e43f53f9..68b80c96 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -18,6 +18,7 @@ const { escapeRegex, generateSlugInternal, normalizePhaseName, + reapStaleTempFiles, normalizeMd, comparePhaseNum, safeReadFile, @@ -1315,3 +1316,51 @@ describe('findProjectRoot', () => { assert.strictEqual(findProjectRoot(backendDir), backendDir); }); }); + +// ─── reapStaleTempFiles ───────────────────────────────────────────────────── + +describe('reapStaleTempFiles', () => { + test('removes stale gsd-*.json files older than maxAgeMs', () => { + const tmpDir = os.tmpdir(); + const stalePath = path.join(tmpDir, `gsd-reap-test-${Date.now()}.json`); + fs.writeFileSync(stalePath, '{}'); + // Set mtime to 10 minutes ago + const oldTime = new Date(Date.now() - 10 * 60 * 1000); + fs.utimesSync(stalePath, oldTime, oldTime); + + reapStaleTempFiles('gsd-reap-test-', { maxAgeMs: 5 * 60 * 1000 }); + + assert.ok(!fs.existsSync(stalePath), 'stale file should be removed'); + }); + + test('preserves fresh gsd-*.json files', () => { + const tmpDir = os.tmpdir(); + const freshPath = path.join(tmpDir, `gsd-reap-fresh-${Date.now()}.json`); + fs.writeFileSync(freshPath, '{}'); + + reapStaleTempFiles('gsd-reap-fresh-', { maxAgeMs: 5 * 60 * 1000 }); + + assert.ok(fs.existsSync(freshPath), 'fresh file should be preserved'); + // Clean up + fs.unlinkSync(freshPath); + }); + + test('removes stale temp directories when present', () => { + const tmpDir = os.tmpdir(); + const staleDir = fs.mkdtempSync(path.join(tmpDir, 'gsd-reap-dir-')); + fs.writeFileSync(path.join(staleDir, 'data.jsonl'), 'test'); + // Set mtime to 10 minutes ago + const oldTime = new Date(Date.now() - 10 * 60 * 1000); + fs.utimesSync(staleDir, oldTime, oldTime); + + reapStaleTempFiles('gsd-reap-dir-', { maxAgeMs: 5 * 60 * 1000 }); + + assert.ok(!fs.existsSync(staleDir), 'stale directory should be removed'); + }); + + test('does not throw on empty or missing prefix matches', () => { + assert.doesNotThrow(() => { + reapStaleTempFiles('gsd-nonexistent-prefix-xyz-', { maxAgeMs: 0 }); + }); + }); +});