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:
- Git-merged: the worktree's branch is in
git log dev (squashed or merge-committed).
- Git-detached: the worktree's
HEAD references a commit that's an ancestor of dev.
- 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:
- Enumerates all worktrees under
.claude/worktrees/ via git worktree list --porcelain.
- 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).
- Dry-run by default — print classification + size per worktree. Require
--apply to actually remove.
- 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.
- 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
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
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 ownnode_modules(~85-106MB),.neo-ai-data/, anddist/artifacts, but nothing removes them after the session's branch is merged todev— the directory grows monotonically.Operator framing (2026-05-19): "
.claude/worktrees=> assumption: you never deleted a single one...bootstrapWorktree.mjscould 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 byFileSystemIngestorand 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_FOUNDfix — copies gitignoredconfig.mjsfiles into fresh worktrees (#10095).--canonical-root/NEO_AI_CANONICAL_ROOT(#10435)..neo-ai-data/{backups,chroma,datasets,neo-sqlite,sqlite,wake-daemon}to share substrate across worktrees (#10435).npm run build-allfor 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:
git log dev(squashed or merge-committed).HEADreferences a commit that's an ancestor ofdev.git worktree list --porcelainexposes 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 pairgit worktree remove <path>(or--forcefor dirty trees) with merge-status detection.Disk-cost evidence (this checkout, 2026-05-19):
amazing-hermann-e45f1e— likely accumulated.neo-ai-datasubstrate that escaped the symlink convention)node_modules/(~80MB),dist/(~10MB), per-session.neo-ai-data/if not symlinkedThe Fix
Add a new
bootstrapWorktree.mjs --prune-stalemode (subcommand or dedicated flag) that:.claude/worktrees/viagit worktree list --porcelain.dev(squashed-merge detection: branch HEAD points at a commit unreachable fromdev, but the branch's tree state is reachable via the squashed-merge commit message linking to the branch name OR PR number). Usegit branch --merged devfor fast-path; fallback togit cherry dev <branch>for squash-merge detection.refs/heads/.--applyto actually remove.git worktree remove <path>(the canonical removal that also cleans the admin record), falling back togit worktree remove --force <path>only with explicit--force-dirtyflag.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 +--applyopt-in is the primary defense; the classification logic is the secondary defense.Acceptance Criteria
bootstrapWorktree.mjsaccepts a--prune-stalemode (or--mode prune-staleif subcommand pattern preferred).--applyrequired to mutate.dev, but branch is indev's squashed-merge history).--force-dirty.npm run ai:prune-worktreesscript inpackage.json.Out of Scope
node_modulesvia symlink) — separate substrate change, has different trade-offs..neo-ai-data/symlink hardening (already covered by #10435; the 1.2GB outlier may indicate a regression there but is a separate forensic).Avoided Traps
rm -rf .claude/worktrees/*shortcutgit worktree listshows phantoms.git worktree listgit log devlinearly. Squash detection is the dominant case.Related
.claude+.codexadded to ignorePatterns.npm run build-all)Origin Session ID
7360e917-1733-4cdd-a6f3-5ac51c34b838Handoff Retrieval Hints
query_raw_memories({query: 'worktree cleanup bootstrapWorktree prune stale disk accumulation'})ask_knowledge_base({query: 'bootstrapWorktree symlink convention canonical root', type: 'src'}).claude/worktrees/on 2026-05-19; one outlier at 1.2GBgit worktree list --porcelain,git worktree remove <path>,git branch --merged dev