From 3589f7b2564f64d5b458c6d77af5949800473ef9 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 17 Apr 2026 10:11:08 -0400 Subject: [PATCH] fix(worktrees): prune orphaned worktrees in code, not prose (#2367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add /gsd-spec-phase — Socratic spec refinement with ambiguity scoring (#2213) Introduces `/gsd-spec-phase ` as an optional pre-step before discuss-phase. Clarifies WHAT a phase delivers (requirements, boundaries, acceptance criteria) with quantitative ambiguity scoring before discuss-phase handles HOW to implement. - `commands/gsd/spec-phase.md` — slash command routing to workflow - `get-shit-done/workflows/spec-phase.md` — full Socratic interview loop (up to 6 rounds, 5 rotating perspectives: Researcher, Simplifier, Boundary Keeper, Failure Analyst, Seed Closer) with weighted 4-dimension ambiguity gate (≤ 0.20 to write SPEC.md) - `get-shit-done/templates/spec.md` — SPEC.md template with falsifiable requirements (Current/Target/Acceptance per requirement), Boundaries, Acceptance Criteria, Ambiguity Report, and Interview Log; includes two full worked examples - `get-shit-done/workflows/discuss-phase.md` — new `check_spec` step detects `{padded_phase}-SPEC.md` at startup; displays "Found SPEC.md — N requirements locked. Focusing on implementation decisions."; `analyze_phase` respects `spec_loaded` flag to skip "what/why" gray areas; `write_context` emits `` section with boundary summary and canonical ref to SPEC.md - `docs/ARCHITECTURE.md` — update command/workflow counts (74→75, 71→72) Closes #2213 Co-Authored-By: Claude Sonnet 4.6 * feat(worktrees): auto-prune merged worktrees in code, not prose Adds pruneOrphanedWorktrees(repoRoot) to core.cjs. It runs on every cmdInitProgress call (the entry point for most GSD commands) and removes linked worktrees whose branch is fully merged into main, then runs git worktree prune to clear stale references. Guards prevent removal of the main worktree, the current process.cwd(), or any unmerged branch. Covered by 4 new real-git integration tests in tests/prune-orphaned-worktrees.test.cjs (TDD red→green). Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- get-shit-done/bin/lib/core.cjs | 93 +++++++++++++ get-shit-done/bin/lib/init.cjs | 4 + tests/prune-orphaned-worktrees.test.cjs | 177 ++++++++++++++++++++++++ 3 files changed, 274 insertions(+) create mode 100644 tests/prune-orphaned-worktrees.test.cjs diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 9a1dfa07..c140c0af 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -609,6 +609,98 @@ function resolveWorktreeRoot(cwd) { return cwd; } +/** + * Parse `git worktree list --porcelain` output into an array of + * { path, branch } objects. Entries with a detached HEAD (no branch line) + * are skipped because we cannot safely reason about their merge status. + * + * @param {string} porcelain - raw output from git worktree list --porcelain + * @returns {{ path: string, branch: string }[]} + */ +function parseWorktreePorcelain(porcelain) { + const entries = []; + let current = null; + for (const line of porcelain.split('\n')) { + if (line.startsWith('worktree ')) { + current = { path: line.slice('worktree '.length).trim(), branch: null }; + } else if (line.startsWith('branch refs/heads/') && current) { + current.branch = line.slice('branch refs/heads/'.length).trim(); + } else if (line === '' && current) { + if (current.branch) entries.push(current); + current = null; + } + } + // flush last entry if file doesn't end with blank line + if (current && current.branch) entries.push(current); + return entries; +} + +/** + * Remove linked git worktrees whose branch has already been merged into the + * current HEAD of the main worktree. Also runs `git worktree prune` to clear + * any stale references left by manually-deleted worktree directories. + * + * Safe guards: + * - Never removes the main worktree (first entry in --porcelain output). + * - Never removes the worktree at process.cwd(). + * - Never removes a worktree whose branch has unmerged commits. + * - Skips detached-HEAD worktrees (no branch name). + * + * @param {string} repoRoot - absolute path to the main (or any) worktree of + * the repository; used as `cwd` for git commands. + * @returns {string[]} list of worktree paths that were removed + */ +function pruneOrphanedWorktrees(repoRoot) { + const pruned = []; + const cwd = process.cwd(); + + try { + // 1. Get all worktrees in porcelain format + const listResult = execGit(repoRoot, ['worktree', 'list', '--porcelain']); + if (listResult.exitCode !== 0) return pruned; + + const worktrees = parseWorktreePorcelain(listResult.stdout); + if (worktrees.length === 0) { + execGit(repoRoot, ['worktree', 'prune']); + return pruned; + } + + // 2. First entry is the main worktree — never touch it + const mainWorktreePath = worktrees[0].path; + + // 3. Check each non-main worktree + for (let i = 1; i < worktrees.length; i++) { + const { path: wtPath, branch } = worktrees[i]; + + // Never remove the worktree for the current process directory + if (wtPath === cwd || cwd.startsWith(wtPath + path.sep)) continue; + + // Check if the branch is fully merged into HEAD (main) + // git merge-base --is-ancestor HEAD exits 0 when merged + const ancestorCheck = execGit(repoRoot, [ + 'merge-base', '--is-ancestor', branch, 'HEAD', + ]); + + if (ancestorCheck.exitCode !== 0) { + // Not yet merged — leave it alone + continue; + } + + // Remove the worktree and delete the branch + const removeResult = execGit(repoRoot, ['worktree', 'remove', '--force', wtPath]); + if (removeResult.exitCode === 0) { + execGit(repoRoot, ['branch', '-D', branch]); + pruned.push(wtPath); + } + } + } catch { /* never crash the caller */ } + + // 4. Always run prune to clear stale references (e.g. manually-deleted dirs) + execGit(repoRoot, ['worktree', 'prune']); + + return pruned; +} + /** * Acquire a file-based lock for .planning/ writes. * Prevents concurrent worktrees from corrupting shared planning files. @@ -1637,4 +1729,5 @@ module.exports = { checkAgentsInstalled, atomicWriteFileSync, timeAgo, + pruneOrphanedWorktrees, }; diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index 7754be48..1736d774 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -1211,6 +1211,10 @@ function cmdInitManager(cwd, raw) { } function cmdInitProgress(cwd, raw) { + try { + const { pruneOrphanedWorktrees } = require('./core.cjs'); + pruneOrphanedWorktrees(cwd); + } catch (_) {} const config = loadConfig(cwd); const milestone = getMilestoneInfo(cwd); diff --git a/tests/prune-orphaned-worktrees.test.cjs b/tests/prune-orphaned-worktrees.test.cjs new file mode 100644 index 00000000..6f84edc1 --- /dev/null +++ b/tests/prune-orphaned-worktrees.test.cjs @@ -0,0 +1,177 @@ +/** + * Tests for pruneOrphanedWorktrees() + * + * Uses real temporary git repos (no mocks). + * All 4 tests must fail (RED) before implementation is added. + */ + +'use strict'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +// Lazy-loaded so tests can fail clearly when the export doesn't exist yet. +function getPruneOrphanedWorktrees() { + const { pruneOrphanedWorktrees } = require('../get-shit-done/bin/lib/core.cjs'); + return pruneOrphanedWorktrees; +} + +// Create a minimal git repo with an initial commit on main. +function createGitRepo(dir) { + fs.mkdirSync(dir, { recursive: true }); + execSync('git init', { cwd: dir, stdio: 'pipe' }); + execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' }); + execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' }); + execSync('git config commit.gpgsign false', { cwd: dir, stdio: 'pipe' }); + fs.writeFileSync(path.join(dir, 'README.md'), '# Test\n'); + execSync('git add -A', { cwd: dir, stdio: 'pipe' }); + execSync('git commit -m "initial commit"', { cwd: dir, stdio: 'pipe' }); + // Rename to main if it isn't already (handles older git defaults) + try { + execSync('git branch -m master main', { cwd: dir, stdio: 'pipe' }); + } catch { /* already named main */ } +} + +// --- Test suite --------------------------------------------------------------- + +describe('pruneOrphanedWorktrees', () => { + let tmpBase; + + beforeEach(() => { + tmpBase = createTempDir('prune-wt-test-'); + }); + + afterEach(() => { + cleanup(tmpBase); + }); + + // Test 1: removes a worktree whose branch is merged into main + test('removes a worktree whose branch is merged into main', () => { + const repoDir = path.join(tmpBase, 'repo'); + const worktreeDir = path.join(tmpBase, 'wt-merged'); + + createGitRepo(repoDir); + + // Create worktree on a new branch (main is checked out in repoDir) + execSync('git worktree add "' + worktreeDir + '" -b fix/old-work', { cwd: repoDir, stdio: 'pipe' }); + assert.ok(fs.existsSync(worktreeDir), 'worktree dir should exist before prune'); + + // Add a commit in the worktree + fs.writeFileSync(path.join(worktreeDir, 'feature.txt'), 'work\n'); + execSync('git add -A', { cwd: worktreeDir, stdio: 'pipe' }); + execSync('git commit -m "old work"', { cwd: worktreeDir, stdio: 'pipe' }); + + // Merge the branch into main from repoDir + execSync('git merge fix/old-work --no-ff -m "merge old-work"', { cwd: repoDir, stdio: 'pipe' }); + + // Act + const pruneOrphanedWorktrees = getPruneOrphanedWorktrees(); + pruneOrphanedWorktrees(repoDir); + + // Assert: worktree directory no longer exists + assert.ok( + !fs.existsSync(worktreeDir), + 'worktree directory should have been removed but still exists: ' + worktreeDir + ); + + // Assert: git worktree list no longer shows it + const listOut = execSync('git worktree list', { cwd: repoDir, encoding: 'utf8' }); + assert.ok( + !listOut.includes(worktreeDir), + 'git worktree list still references removed worktree:\n' + listOut + ); + }); + + // Test 2: keeps a worktree whose branch has unmerged commits + test('keeps a worktree whose branch has unmerged commits', () => { + const repoDir = path.join(tmpBase, 'repo2'); + const worktreeDir = path.join(tmpBase, 'wt-active'); + + createGitRepo(repoDir); + + // Create the worktree on a new branch (main is checked out in repoDir) + execSync('git worktree add "' + worktreeDir + '" -b fix/active-work', { cwd: repoDir, stdio: 'pipe' }); + + // Add a commit in the worktree (NOT merged into main) + fs.writeFileSync(path.join(worktreeDir, 'active.txt'), 'active\n'); + execSync('git add -A', { cwd: worktreeDir, stdio: 'pipe' }); + execSync('git commit -m "active work"', { cwd: worktreeDir, stdio: 'pipe' }); + // main stays at its original commit — no merge + + // Act + const pruneOrphanedWorktrees = getPruneOrphanedWorktrees(); + pruneOrphanedWorktrees(repoDir); + + // Assert: worktree directory still exists + assert.ok( + fs.existsSync(worktreeDir), + 'worktree directory should NOT have been removed: ' + worktreeDir + ); + }); + + // Test 3: never removes the worktree at process.cwd() + test('never removes the worktree at process.cwd()', () => { + const repoDir = path.join(tmpBase, 'repo3'); + const wtDir = path.join(tmpBase, 'wt-cwd-test'); + + createGitRepo(repoDir); + + // Create a worktree, add a commit, merge it into main + execSync('git worktree add "' + wtDir + '" -b fix/another-merged', { cwd: repoDir, stdio: 'pipe' }); + fs.writeFileSync(path.join(wtDir, 'more.txt'), 'more\n'); + execSync('git add -A', { cwd: wtDir, stdio: 'pipe' }); + execSync('git commit -m "another merged"', { cwd: wtDir, stdio: 'pipe' }); + execSync('git checkout main', { cwd: repoDir, stdio: 'pipe' }); + execSync('git merge fix/another-merged --no-ff -m "merge another"', { cwd: repoDir, stdio: 'pipe' }); + + // Run pruning + const pruneOrphanedWorktrees = getPruneOrphanedWorktrees(); + const pruned = pruneOrphanedWorktrees(repoDir); + + // process.cwd() must not appear in pruned paths + assert.ok( + !pruned.includes(process.cwd()), + 'process.cwd() should never be pruned, but found in: ' + JSON.stringify(pruned) + ); + + // The main worktree (repoDir) itself must still exist + assert.ok( + fs.existsSync(repoDir), + 'main repo dir should still exist: ' + repoDir + ); + }); + + // Test 4: runs git worktree prune to clear stale references + test('runs git worktree prune to clear stale references', () => { + const repoDir = path.join(tmpBase, 'repo4'); + const worktreeDir = path.join(tmpBase, 'wt-stale'); + + createGitRepo(repoDir); + + // Create a worktree + execSync('git worktree add "' + worktreeDir + '" -b fix/stale-ref', { cwd: repoDir, stdio: 'pipe' }); + assert.ok(fs.existsSync(worktreeDir), 'worktree dir should exist before manual deletion'); + + // Verify it appears in git worktree list + const beforeList = execSync('git worktree list --porcelain', { cwd: repoDir, encoding: 'utf8' }); + assert.ok(beforeList.includes(worktreeDir), 'worktree should appear in list before deletion'); + + // Manually delete the worktree directory (simulate orphan) + fs.rmSync(worktreeDir, { recursive: true, force: true }); + + // Act + const pruneOrphanedWorktrees = getPruneOrphanedWorktrees(); + pruneOrphanedWorktrees(repoDir); + + // Assert: git worktree list no longer shows the stale entry + const afterList = execSync('git worktree list --porcelain', { cwd: repoDir, encoding: 'utf8' }); + assert.ok( + !afterList.includes(worktreeDir), + 'git worktree list still shows stale entry after prune:\n' + afterList + ); + }); +});