From 66a5f939b05dc79d1be8b850f7ce136421c424fe Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Sun, 12 Apr 2026 14:56:39 -0700 Subject: [PATCH] feat(health): detect stale and orphan worktrees in validate-health (W017) (#2175) Add W017 warning to cmdValidateHealth that detects linked git worktrees that are stale (older than 1 hour, likely from crashed agents) or orphaned (path no longer exists on disk). Parses git worktree list --porcelain output, skips the main worktree, and provides actionable fix suggestions. Gracefully degrades if git worktree is unavailable. Closes #2167 Co-authored-by: Claude Opus 4.6 (1M context) --- get-shit-done/bin/lib/verify.cjs | 34 ++++++ tests/orphan-worktree-detection.test.cjs | 126 +++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 tests/orphan-worktree-detection.test.cjs diff --git a/get-shit-done/bin/lib/verify.cjs b/get-shit-done/bin/lib/verify.cjs index 30b4c6ff3..048ff9b31 100644 --- a/get-shit-done/bin/lib/verify.cjs +++ b/get-shit-done/bin/lib/verify.cjs @@ -837,6 +837,40 @@ function cmdValidateHealth(cwd, options, raw) { } catch { /* parse error already caught in Check 5 */ } } + // ─── Check 11: Stale / orphan git worktrees (#2167) ──────────────────────── + try { + const worktreeResult = execGit(cwd, ['worktree', 'list', '--porcelain']); + if (worktreeResult.exitCode === 0 && worktreeResult.stdout) { + const blocks = worktreeResult.stdout.split('\n\n').filter(Boolean); + // Skip the first block — it is always the main worktree + for (let i = 1; i < blocks.length; i++) { + const lines = blocks[i].split('\n'); + const wtLine = lines.find(l => l.startsWith('worktree ')); + if (!wtLine) continue; + const wtPath = wtLine.slice('worktree '.length); + + if (!fs.existsSync(wtPath)) { + // Orphan: path no longer exists on disk + addIssue('warning', 'W017', + `Orphan git worktree: ${wtPath} (path no longer exists on disk)`, + 'Run: git worktree prune'); + } else { + // Check if stale (older than 1 hour) + try { + const stat = fs.statSync(wtPath); + const ageMs = Date.now() - stat.mtimeMs; + const ONE_HOUR = 60 * 60 * 1000; + if (ageMs > ONE_HOUR) { + addIssue('warning', 'W017', + `Stale git worktree: ${wtPath} (last modified ${Math.round(ageMs / 60000)} minutes ago)`, + `Run: git worktree remove ${wtPath} --force`); + } + } catch { /* stat failed — skip */ } + } + } + } + } catch { /* git worktree not available or not a git repo — skip silently */ } + // ─── Perform repairs if requested ───────────────────────────────────────── const repairActions = []; if (options.repair && repairs.length > 0) { diff --git a/tests/orphan-worktree-detection.test.cjs b/tests/orphan-worktree-detection.test.cjs new file mode 100644 index 000000000..3b20073db --- /dev/null +++ b/tests/orphan-worktree-detection.test.cjs @@ -0,0 +1,126 @@ +/** + * GSD Tools Tests - Orphan/Stale Worktree Detection (W017) + * + * Tests for feat/worktree-health-w017-2167: + * - W017 code exists in verify.cjs (structural) + * - No false positives on projects without linked worktrees + * - Adding the check does not regress baseline health status + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempGitProject, cleanup } = require('./helpers.cjs'); + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function writeMinimalProjectMd(tmpDir) { + const sections = ['## What This Is', '## Core Value', '## Requirements']; + const content = sections.map(s => `${s}\n\nContent here.\n`).join('\n'); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'PROJECT.md'), + `# Project\n\n${content}` + ); +} + +function writeMinimalRoadmap(tmpDir) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap\n\n### Phase 1: Setup\n' + ); +} + +function writeMinimalStateMd(tmpDir) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + '# Session State\n\n## Current Position\n\nPhase: 1\n' + ); +} + +function writeValidConfigJson(tmpDir) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + model_profile: 'balanced', + commit_docs: true, + workflow: { nyquist_validation: true, ai_integration_phase: true }, + }, null, 2) + ); +} + +function setupHealthyProject(tmpDir) { + writeMinimalProjectMd(tmpDir); + writeMinimalRoadmap(tmpDir); + writeMinimalStateMd(tmpDir); + writeValidConfigJson(tmpDir); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-setup'), { recursive: true }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Structural: W017 code exists in verify.cjs +// ───────────────────────────────────────────────────────────────────────────── + +describe('W017: structural presence', () => { + test('verify.cjs contains W017 warning code', () => { + const verifyPath = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'verify.cjs'); + const source = fs.readFileSync(verifyPath, 'utf-8'); + assert.ok(source.includes("'W017'"), 'verify.cjs should contain W017 warning code'); + }); + + test('verify.cjs contains worktree list --porcelain invocation', () => { + const verifyPath = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'verify.cjs'); + const source = fs.readFileSync(verifyPath, 'utf-8'); + assert.ok( + source.includes('worktree') && source.includes('--porcelain'), + 'verify.cjs should invoke git worktree list --porcelain' + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 2. No worktrees = no W017 +// ───────────────────────────────────────────────────────────────────────────── + +describe('W017: no false positives', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempGitProject(); + setupHealthyProject(tmpDir); + }); + + afterEach(() => cleanup(tmpDir)); + + test('no W017 when project has no linked worktrees', () => { + const result = runGsdTools('validate health --raw', tmpDir); + assert.ok(result.success, `validate health should succeed: ${result.error || ''}`); + const parsed = JSON.parse(result.output); + + // Collect all warning codes + const warningCodes = (parsed.warnings || []).map(w => w.code); + assert.ok(!warningCodes.includes('W017'), `W017 should not fire when no linked worktrees exist, got warnings: ${JSON.stringify(warningCodes)}`); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Clean project still reports healthy +// ───────────────────────────────────────────────────────────────────────────── + +describe('W017: no regression on healthy projects', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempGitProject(); + setupHealthyProject(tmpDir); + }); + + afterEach(() => cleanup(tmpDir)); + + test('validate health still reports healthy on a clean project', () => { + const result = runGsdTools('validate health --raw', tmpDir); + assert.ok(result.success, `validate health should succeed: ${result.error || ''}`); + const parsed = JSON.parse(result.output); + assert.equal(parsed.status, 'healthy', `Expected healthy status, got ${parsed.status}. Errors: ${JSON.stringify(parsed.errors)}. Warnings: ${JSON.stringify(parsed.warnings)}`); + }); +});