diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index 06da9d13c..e97833ff2 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -7,6 +7,11 @@ const path = require('path'); const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, normalizeMd, planningDir, planningPaths, output, error, atomicWriteFileSync } = require('./core.cjs'); const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs'); +// Cache disk scan results from buildStateFrontmatter per cwd per process (#1967). +// Avoids re-reading N+1 directories on every state write when the phase structure +// hasn't changed within the same gsd-tools invocation. +const _diskScanCache = new Map(); + /** Shorthand — every state command needs this path */ function getStatePath(cwd) { return planningPaths(cwd).state; @@ -737,28 +742,40 @@ function buildStateFrontmatter(bodyContent, cwd) { try { const phasesDir = planningPaths(cwd).phases; if (fs.existsSync(phasesDir)) { - const isDirInMilestone = getMilestonePhaseFilter(cwd); - const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true }) - .filter(e => e.isDirectory()).map(e => e.name) - .filter(isDirInMilestone); - let diskTotalPlans = 0; - let diskTotalSummaries = 0; - let diskCompletedPhases = 0; + // Use cached disk scan when available — avoids N+1 readdirSync calls + // on repeated buildStateFrontmatter invocations within the same process (#1967) + let cached = _diskScanCache.get(cwd); + if (!cached) { + const isDirInMilestone = getMilestonePhaseFilter(cwd); + const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true }) + .filter(e => e.isDirectory()).map(e => e.name) + .filter(isDirInMilestone); + let diskTotalPlans = 0; + let diskTotalSummaries = 0; + let diskCompletedPhases = 0; - for (const dir of phaseDirs) { - const files = fs.readdirSync(path.join(phasesDir, dir)); - const plans = files.filter(f => f.match(/-PLAN\.md$/i)).length; - const summaries = files.filter(f => f.match(/-SUMMARY\.md$/i)).length; - diskTotalPlans += plans; - diskTotalSummaries += summaries; - if (plans > 0 && summaries >= plans) diskCompletedPhases++; + for (const dir of phaseDirs) { + const files = fs.readdirSync(path.join(phasesDir, dir)); + const plans = files.filter(f => f.match(/-PLAN\.md$/i)).length; + const summaries = files.filter(f => f.match(/-SUMMARY\.md$/i)).length; + diskTotalPlans += plans; + diskTotalSummaries += summaries; + if (plans > 0 && summaries >= plans) diskCompletedPhases++; + } + cached = { + totalPhases: isDirInMilestone.phaseCount > 0 + ? Math.max(phaseDirs.length, isDirInMilestone.phaseCount) + : phaseDirs.length, + completedPhases: diskCompletedPhases, + totalPlans: diskTotalPlans, + completedPlans: diskTotalSummaries, + }; + _diskScanCache.set(cwd, cached); } - totalPhases = isDirInMilestone.phaseCount > 0 - ? Math.max(phaseDirs.length, isDirInMilestone.phaseCount) - : phaseDirs.length; - completedPhases = diskCompletedPhases; - totalPlans = diskTotalPlans; - completedPlans = diskTotalSummaries; + totalPhases = cached.totalPhases; + completedPhases = cached.completedPhases; + totalPlans = cached.totalPlans; + completedPlans = cached.completedPlans; } } catch { /* intentionally empty */ } }