fix(prune): Stop reading a setup symlink as uncommitted work - #80
Merged
Conversation
Every Claude-Code-created worktree currently demands the uncommitted-work opt-in before it can be pruned, for a symlink the tooling itself made. A `node_modules/` gitignore pattern is DIRECTORY-only, so it does not match a symlink of that name and git reports `?? node_modules`. Every worktree set up with `worktree.symlinkDirectories` therefore reads as dirty. This is not hypothetical: every agent-created worktree in this repo reports exactly that today. The exemption is narrow on purpose: only names the repo actually configured, and only when the entry really is a symlink on disk. A real directory of that name, or a symlink the user made for their own reasons, still counts as dirt. Applied at both the scan and the pre-deletion re-check. The second one matters as much as the first: without it a worktree would pass the list and then be refused at the point of no return for the same link. `readSymlinkDirectories` lives in `worktree-git.ts` with the other git-adjacent plumbing, since reading it is now part of answering "is this worktree dirty". Removal itself was never affected and stays verified: ccmux never calls `git worktree remove` (which upstream refuses on symlinked untracked entries), and the recursive delete unlinks the symlink rather than following it, so the main checkout's node_modules survives intact.
The comment explained what the exemption skips and how narrow it is, but not why skipping it cannot lose anything. A future reader looking at "we skip this entry" needs the reason, not the assurance that someone checked once. The obvious worry is that the symlink could point at real work. It can, and that is still safe: the blast radius is not what the link points at, because deleting a symlink never touches its target. The worst an exemption can cost is the link itself, which the tooling recreates. Verified end to end, and the result is recorded here. Also records why the check is lstat rather than stat, since stat follows the link and would let a real directory of the configured name pass as one.
…t note Three review should-fixes. The safety comment named the wrong hazard. It said a `stat` check would let a real directory pass as a symlink; it would not. `stat` follows the link, so `isSymbolicLink()` is false for EVERYTHING, and the exemption would silently never fire, restoring the bug rather than over-exempting. Measured both ways. A wrong safety comment is worse than none, since it is exactly what a future reader leans on to judge whether a simplification is safe. Claude Code resolves `worktree.symlinkDirectories` from MERGED settings, so reading only `.claude/settings.json` missed user scope, which is the natural home for a machine-wide "share node_modules" preference. Those users kept the bug with no signal why. Now local, then project, then user, with a defined list replacing rather than extending the one below it, mirroring the tool's own precedence. README and architecture.md described the dirty gate without the carve-out. The architecture note sits beside the sibling "ignored files are shown, not gated" paragraph, which is where prune internals live. Also: the module docstring promised every call goes through the injectable `GitRun`, which the config reader does not; it now says so and explains what makes those readers testable instead. Whole-path matching is documented as deliberate and fail-closed. The two config read sites now explain why one is per-repo and the other re-reads at the point of no return. Adds tests for the scope precedence, a scope that exists without the key, and a plain FILE of the configured name.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
Stops the prune list from treating a
worktree.symlinkDirectorieslink as uncommitted work.Follow-up to #76.
Motivation
Every Claude-Code-created worktree currently demands the uncommitted-work opt-in before
ccmux worktree prunewill remove it, for a symlink the tooling itself created.A
node_modules/gitignore pattern is directory-only, so it does not match a symlink of that name. Git therefore reports?? node_modules, the prune scan counts that as an untracked file, and the row is flagged dirty. That is exactly the alarm-fatigue case the dirty gate is supposed to avoid: a gate that fires on nearly every row trains people to clear it reflexively, which is worse than no gate for the case it exists to catch.This is not hypothetical. Every agent-created worktree in this repo reports it today:
Changes
Dirty detection
readDirtyStatetakes the repo's configured symlink directories and skips an untracked entry that is one of them. The check is narrow deliberately: the name must be configured by the repo AND the entry must really be a symlink on disk, so a real directory of that name, or a symlink the user made for their own reasons, still counts as dirt.readSymlinkDirectoriesmoves in alongside the other git-adjacent plumbing, since reading it is now part of answering "is this worktree dirty". Absent, unreadable and malformed config all resolve to an empty list, so a parse failure cannot change how a destructive feature behaves.Tests
node_modulesintact afterwards.Breaking Changes
None
Testing
node_modulessurvives the prune with contents intactNote for review
Removal itself was never affected, which is the first thing worth checking given the known upstream issue where
git worktree removerefuses to remove symlinks as untracked entries. ccmux never callsgit worktree remove; it renames the directory aside and runsgit worktree prune. I verified on a fixture that the recursive delete unlinks the symlink rather than following it, so the main checkout'snode_modulescomes through untouched. This PR only changes whether the row is classified dirty.