fix(worktrees): prune orphaned worktrees in code, not prose (#2367)

* feat: add /gsd-spec-phase — Socratic spec refinement with ambiguity scoring (#2213)

Introduces `/gsd-spec-phase <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 `<spec_lock>` 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-17 10:11:08 -04:00
committed by GitHub
parent d7b613d147
commit 3589f7b256
3 changed files with 274 additions and 0 deletions

View File

@@ -609,6 +609,98 @@ function resolveWorktreeRoot(cwd) {
return 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 <branch> 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. * Acquire a file-based lock for .planning/ writes.
* Prevents concurrent worktrees from corrupting shared planning files. * Prevents concurrent worktrees from corrupting shared planning files.
@@ -1637,4 +1729,5 @@ module.exports = {
checkAgentsInstalled, checkAgentsInstalled,
atomicWriteFileSync, atomicWriteFileSync,
timeAgo, timeAgo,
pruneOrphanedWorktrees,
}; };

View File

@@ -1211,6 +1211,10 @@ function cmdInitManager(cwd, raw) {
} }
function cmdInitProgress(cwd, raw) { function cmdInitProgress(cwd, raw) {
try {
const { pruneOrphanedWorktrees } = require('./core.cjs');
pruneOrphanedWorktrees(cwd);
} catch (_) {}
const config = loadConfig(cwd); const config = loadConfig(cwd);
const milestone = getMilestoneInfo(cwd); const milestone = getMilestoneInfo(cwd);

View File

@@ -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
);
});
});