gabriel / muse public
Closed #95
filed by gabriel human · 35 days ago

muse switch leaves empty directories behind when their last file is removed

0 Anchors
— Blast radius
— Churn 30d
0 Proposals

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.

◎ Activity2
●
gabriel opened this issue 35 days ago
○
gabriel 35 days ago

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:

  • completions/_muse -- removed in a33602232 ('chore: remove stale completions/_muse')
  • tools/omzsh-plugin/{README.md,_muse,muse.plugin.zsh} -- removed in 19ac19c97 ('chore: remove stale tools/omzsh-plugin')

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.

○
gabriel 2 days ago

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.

✓
closed this issue 2 days ago