Skip to content

fix(prune): Stop reading a setup symlink as uncommitted work - #80

Merged
epilande merged 4 commits into
mainfrom
fix/prune-symlink-dirty
Jul 29, 2026
Merged

fix(prune): Stop reading a setup symlink as uncommitted work#80
epilande merged 4 commits into
mainfrom
fix/prune-symlink-dirty

Conversation

@epilande

Copy link
Copy Markdown
Owner

Overview

Stops the prune list from treating a worktree.symlinkDirectories link as uncommitted work.

Follow-up to #76.

Motivation

Every Claude-Code-created worktree currently demands the uncommitted-work opt-in before ccmux worktree prune will 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:

$ git -C .claude/worktrees/agent-a47362189dab1962c status --porcelain
?? node_modules

Changes

Dirty detection

  • worktree-git.ts - readDirtyState takes 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.
  • worktree-git.ts - readSymlinkDirectories moves 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.
  • worktree-prune.ts - Reads the config once per repo and passes it to both the scan and the pre-deletion re-check. The second application matters as much as the first: without it a worktree passes the list and is then refused at the point of no return for the same link.

Tests

  • worktree-prune.test.ts - Four cases: the link is not dirty; a real directory of the same name still is; an unconfigured symlink still is; and a symlinked worktree prunes with no opt-in, with the main checkout's node_modules intact afterwards.

Breaking Changes

None

Testing

  • Typecheck clean
  • Full suite green: 3406 pass, 0 fail (main's 3402 plus these four)
  • Verified on a fixture that the symlinked worktree now prunes with no dirty opt-in
  • Confirmed the main checkout's node_modules survives the prune with contents intact

Note for review

Removal itself was never affected, which is the first thing worth checking given the known upstream issue where git worktree remove refuses to remove symlinks as untracked entries. ccmux never calls git worktree remove; it renames the directory aside and runs git worktree prune. I verified on a fixture that the recursive delete unlinks the symlink rather than following it, so the main checkout's node_modules comes through untouched. This PR only changes whether the row is classified dirty.

epilande added 4 commits July 29, 2026 07:29
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.
@epilande
epilande merged commit 73e8011 into main Jul 29, 2026
1 check passed
@epilande
epilande deleted the fix/prune-symlink-dirty branch July 29, 2026 15:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant