LearnNewsExamplesServices
Frontmatter
id11654
titlebootstrapWorktree: prune stale `.claude/worktrees//` after branch merge
stateClosed
labels
enhancementai
assigneesneo-gpt
createdAtMay 19, 2026, 5:39 PM
updatedAtMay 20, 2026, 2:45 AM
githubUrlhttps://github.com/neomjs/neo/issues/11654
authorneo-opus-4-7
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtMay 20, 2026, 2:45 AM

bootstrapWorktree: prune stale .claude/worktrees/<name>/ after branch merge

Closedenhancementai
neo-opus-4-7
neo-opus-4-7 commented on May 19, 2026, 5:39 PM

Context

The .claude/worktrees/ directory has accumulated 58 worktrees totaling 21GB in this checkout. Each session spins up a fresh .claude/worktrees/<random-name>/ carrying its own node_modules (~85-106MB), .neo-ai-data/, and dist/ artifacts, but nothing removes them after the session's branch is merged to dev — the directory grows monotonically.

Operator framing (2026-05-19): ".claude/worktrees => assumption: you never deleted a single one... bootstrapWorktree.mjs could get enhanced to clean up."

Adjacent context — the same session that filed this ticket also discovered .claude/worktrees/<X>/node_modules/ directories were being scanned by FileSystemIngestor and emitting graph noise (3.17M-element graph, vs ~50k baseline 10 days prior). That bleed is now fixed by #11651 (FileSystemIngestor ignorePatterns extension). This ticket addresses the upstream cause: the worktrees shouldn't accumulate in the first place.

The Problem

ai/scripts/bootstrapWorktree.mjs (621 lines) currently handles:

  • ERR_MODULE_NOT_FOUND fix — copies gitignored config.mjs files into fresh worktrees (#10095).
  • Independent-clone support — --canonical-root / NEO_AI_CANONICAL_ROOT (#10435).
  • Per-subdir symlinks for .neo-ai-data/{backups,chroma,datasets,neo-sqlite,sqlite,wake-daemon} to share substrate across worktrees (#10435).
  • Build artifact generation — runs npm run build-all for fresh-worktree test suite support (#11163).

Missing: no cleanup / pruning path. Once a worktree's branch is merged to dev, the worktree directory remains forever. Across a multi-month MX cycle this scales linearly with session count.

Worktree-isolation note: this is specific to Claude Code's worktree-isolated harness (the .claude/worktrees/ convention). Codex/Antigravity run shared-checkout and don't accumulate this way. See feedback memory: harness isolation models.

The Architectural Reality

Worktrees become prunable when:

  1. Git-merged: the worktree's branch is in git log dev (squashed or merge-committed).
  2. Git-detached: the worktree's HEAD references a commit that's an ancestor of dev.
  3. Manually-closed: the worktree's branch is deleted (or never existed — e.g., user abandoned the session).

git worktree list --porcelain exposes the per-worktree branch + HEAD info. git worktree prune (built-in) removes worktree directories whose admin records were already removed but leaves directories with intact admin records untouched. The framework-level enhancement should pair git worktree remove <path> (or --force for dirty trees) with merge-status detection.

Disk-cost evidence (this checkout, 2026-05-19):

  • Total: 58 worktrees / 21GB
  • Per-worktree: 85-106MB typical, with one outlier at 1.2GB (amazing-hermann-e45f1e — likely accumulated .neo-ai-data substrate that escaped the symlink convention)
  • Largest contributors per worktree: node_modules/ (~80MB), dist/ (~10MB), per-session .neo-ai-data/ if not symlinked

The Fix

Add a new bootstrapWorktree.mjs --prune-stale mode (subcommand or dedicated flag) that:

  1. Enumerates all worktrees under .claude/worktrees/ via git worktree list --porcelain.
  2. Classifies each as:
    • prunable-merged — branch already merged to dev (squashed-merge detection: branch HEAD points at a commit unreachable from dev, but the branch's tree state is reachable via the squashed-merge commit message linking to the branch name OR PR number). Use git branch --merged dev for fast-path; fallback to git cherry dev <branch> for squash-merge detection.
    • prunable-deleted — worktree's branch no longer exists in refs/heads/.
    • active — branch open AND not yet merged.
    • dirty — uncommitted/unstaged changes in the worktree (preserve unconditionally; pruning would destroy work).
  3. Dry-run by default — print classification + size per worktree. Require --apply to actually remove.
  4. Removes classified-prunable worktrees via git worktree remove <path> (the canonical removal that also cleans the admin record), falling back to git worktree remove --force <path> only with explicit --force-dirty flag.
  5. Reports pre/post total disk reclaimed.

Integration point: can run as a manual operator command (node ai/scripts/bootstrapWorktree.mjs --prune-stale) and/or surfaced as an npm script (npm run ai:prune-worktrees).

Safety invariant: the script MUST NEVER remove a worktree with uncommitted changes, an open PR, or a branch that has commits not merged into dev. Default-dry-run + --apply opt-in is the primary defense; the classification logic is the secondary defense.

Acceptance Criteria

  • bootstrapWorktree.mjs accepts a --prune-stale mode (or --mode prune-stale if subcommand pattern preferred).
  • Classification logic distinguishes prunable-merged / prunable-deleted / active / dirty per the criteria above.
  • Dry-run is the default; --apply required to mutate.
  • Squash-merge detection works (branch HEAD not reachable from dev, but branch is in dev's squashed-merge history).
  • Dirty worktrees (uncommitted changes) are NEVER removed without --force-dirty.
  • Reports disk reclaimed (pre-total - post-total).
  • Unit test: classification function tested against fixture worktrees (merged, deleted, active, dirty) in tmp.
  • Manual verification: run against this checkout, prune the merged worktrees, target reduction to <5GB (estimated based on 58 worktrees most of which are merged).
  • Optional npm run ai:prune-worktrees script in package.json.

Out of Scope

  • Cron / daemon-based auto-cleanup — operator-triggered is the right granularity for the initial implementation; daemon-mode can be a follow-up if friction justifies it.
  • Cross-harness cleanup convention — Codex/Antigravity use shared-checkout and don't have this problem.
  • Worktree size reduction at creation time (e.g., shared node_modules via symlink) — separate substrate change, has different trade-offs.
  • Per-worktree .neo-ai-data/ symlink hardening (already covered by #10435; the 1.2GB outlier may indicate a regression there but is a separate forensic).
  • Forensic on why the 1.2GB outlier accumulated — separate investigation.

Avoided Traps

Trap Why rejected
rm -rf .claude/worktrees/* shortcut Destroys dirty work; doesn't update git's admin records → git worktree list shows phantoms.
Cron-based auto-cleanup Premature automation. Operator-triggered first; promote to cron after friction signals justify.
Remove ALL worktrees whose branch is not in git worktree list False positives — Claude Code creates the worktree before adding the branch in some flows. Use git's own enumeration.
Pruning while a session is active in that worktree Process locks would surface (rm errors), but the cleaner gate is checking dirty-tree first.
Skip squash-merge detection Most Neo PRs use squash-merge; branch HEAD never appears in git log dev linearly. Squash detection is the dominant case.

Related

  • FileSystemIngestor leak fix (downstream symptom): #11651.claude + .codex added to ignorePatterns.
  • bootstrapWorktree origin: #10095 (config.mjs copy)
  • Independent-clone support: #10435 (canonical-root + symlinks)
  • Build artifact generation: #11163 (npm run build-all)
  • Harness asymmetry context: feedback_harness_isolation_models.md — Claude Code worktree-isolated vs Codex/Antigravity shared-checkout.

Origin Session ID

7360e917-1733-4cdd-a6f3-5ac51c34b838

Handoff Retrieval Hints

  • query_raw_memories({query: 'worktree cleanup bootstrapWorktree prune stale disk accumulation'})
  • ask_knowledge_base({query: 'bootstrapWorktree symlink convention canonical root', type: 'src'})
  • Empirical anchor: 58 worktrees / 21GB in .claude/worktrees/ on 2026-05-19; one outlier at 1.2GB
  • Git plumbing reference: git worktree list --porcelain, git worktree remove <path>, git branch --merged dev
tobiu closed this issue on May 20, 2026, 2:45 AM
tobiu referenced in commit b57285c - "feat(worktree): add stale Claude worktree pruning (#11654) (#11655) on May 20, 2026, 2:45 AM