muse switch leaves empty directories behind when their last file is removed
Summary
muse switch <branch> correctly removes files that aren't part of the
target branch, but does not remove directories that become empty as a
result of that removal. The empty directory is left on disk and
immediately shows up as untracked noise in every subsequent muse status
on the branch you switched to -- even though that branch never had it.
This is the confirmed root cause of the 12 mystery empty directories found
on a local ~/ecosystem/muse checkout (see #94): a proposal review
(#12, feat/216-branchmeta-created-by) with a real, populated OverseerKit
install (39 files under .claude/skills/, .cursor/rules/,
.overseer/policy/, etc. -- commit sha256:05ad6bb82, "chore: init
Overseer kit...") was checked out locally for review, then switched away
from back to dev. The files correctly disappeared; the directory shells
did not.
Reproduction (100% reliable, isolated sandbox, no dependency on any
real repo state)
rm -rf /tmp/muse_dir_repro && mkdir -p /tmp/muse_dir_repro && cd /tmp/muse_dir_repro
muse init --json
echo "hello" > README.md
muse code add README.md --json
muse commit -m "init" --json
# Branch A: commit a real file nested two directories deep
muse switch -c feat/scaffold --json
mkdir -p scaffold/sub
echo "content" > scaffold/sub/file.md
muse code add scaffold/ --json
muse commit -m "add scaffold files" --json
# Switch back to main, which never had this file
muse switch main --json
ls -la scaffold/sub/ # file.md is correctly gone (empty dir)
ls -la scaffold/ # sub/ is still there
muse status --json # "untracked": ["scaffold/", "scaffold/sub/"]
Expected
After muse switch main, scaffold/ and scaffold/sub/ should not exist
on disk at all -- the working tree should exactly match main's tracked
state, with no leftover empty directories from the branch switched away
from. muse status on main should be clean.
Actual
scaffold/ and scaffold/sub/ persist as empty directories after the
switch, and immediately appear as untracked in muse status on the
target branch -- permanently, until someone notices and manually deletes
them (there's no automatic way to detect "this untracked directory is a
switch-cleanup leftover" vs. "this is an intentional new directory a
human just created").
Why this is a sharper correctness gap for muse than it would be for git
Given directories are first-class, content-addressed objects in muse
(EMPTY_DIR_OID -- see docs/agent-guide.md's "Content-addressing over
marker files" convention), a leftover empty directory isn't just an
incidental OS artifact the way it effectively is in git (git never tracked
the directory as an entity to begin with, so there's nothing to clean up
in git's own data model -- any stray empty dir left behind is purely a
filesystem side effect, not a tracked-state inconsistency). In muse,
this produces an actual state inconsistency: the directory now looks
identical to a directory a human deliberately created and simply hasn't
code added yet, and nothing distinguishes the two. Git handles the
analogous case (files removed by checkout leave behind now-empty parent
dirs) by pruning those directories as part of checkout's working-tree
sync -- muse should have at least that level of parity, and arguably a
stronger guarantee given directories are real tracked objects here.
Suggested fix
During muse switch's working-tree sync step, after removing every file
not present in the target branch's manifest, walk the set of parent
directories of each removed file (deepest-first) and rmdir any that are
now empty AND are not present in the target branch's directories list
(i.e. not an intentionally-tracked empty directory on the branch being
switched to). Needs to handle the case correctly where the target
branch's OWN manifest legitimately wants an empty directory to exist
there (via directories) -- that one should NOT be removed, only ones
that have zero footprint (no files, not in directories) in the target
branch.
Affected repos
Confirmed in a fresh, isolated muse init sandbox -- not specific to any
one repo's history or config. Very likely affects every muse repo with
branches that touch different directory structures.
Fixed and shipped to source.
Confirmed broader blast radius matches your follow-up comment exactly — this wasn't only a switch/checkout thing. apply_manifest (the shared function underlying merge, pull, rebase, cherry-pick, reset, revert, shelf) never pruned empty directories either; checkout's own separate incremental-delta path had the identical gap independently.
Fix: apply_manifest now prunes any directory left empty by file removal, deepest-first, stopping at the first non-empty/protected/root directory. checkout.py's _checkout_snapshot gets the same treatment via the same shared helper. Threaded a target_directories protection parameter through every call site that has a target manifest at hand.
A twist worth flagging: while wiring the protection parameter through the fast-forward merge/pull paths, found that a plain muse commit leaves its own snapshot's directories field stale — still listing a directory whose last file that same commit just removed. If I'd sourced target_directories from that stored field (which was available and seemed like the "proper" source), it would have wrongly protected exactly the directories this fix exists to prune, silently defeating the fix for the fast-forward case. Caught this because my own fast-forward test regressed when I wired it that way. Fixed by deriving target_directories fresh from the file manifest (directories_from_manifest) everywhere instead — always correct, just unable to protect a genuinely intentional .musekeep-marked empty directory (a narrower case than what this ticket is about). Filed #110 for the stale-directories-field bug itself, since it's real and separate.
Testing: 7 new tests in tests/test_apply_manifest_dir_pruning.py — unit coverage for the pruning logic (single/multi-level, protected directories, directories with surviving files, directories with untracked human content) plus two full CLI integration tests: your exact repro, and a same-repo fast-forward reproduction of the broader blast radius from your comment. All green. 870-test regression suite across every affected command: zero regressions.
Verified live (muse-dev, not just pytest): both your exact repro and the fast-forward scenario, confirmed fixed — scaffold/, scaffold/sub/, and tools/plugin/ no longer exist on disk after switch/merge, muse status reports no leftovers.
Not yet in a published release — same batching note as the other tickets this session.
Closing.
Broader blast radius confirmed: this isn't only a cross-branch switch trigger. Found the same phenomenon from a plain fast-forward pull/merge along a single branch's own history. On this dev checkout, completions/ and tools/omzsh-plugin/ are empty and untracked; their real tracked files were removed in later, unrelated dev commits:
Both directories persisted as empty, untracked leftovers on this machine long after those removal commits landed -- this checkout was simply behind at the time and caught up via a later pull/fast-forward, not a branch switch. docs/protocol/ and muse/cli/commands/plumbing/ have matching 'remove stale X' commit titles in dev's history too, strongly suggesting the same cause.
Implication for the fix: the empty-directory pruning needs to happen in whatever shared working-tree sync path underlies switch, pull, and fast-forward merge -- not just the switch code path specifically. Any operation that removes a previously-tracked file from the working tree needs to also prune now-empty parent directories (except ones intentionally tracked as empty in the resulting state), regardless of which command triggered the removal.