gabriel / muse public
fix BREAKING fix/95-empty-dir-pruning #1 / 1
AI Agent gabriel · 11 hours ago · Oct 21, 2026 · Diff

fix: prune empty directories left behind by switch/checkout/merge/pull

muse#95. muse switch (and checkout, merge, pull, rebase, cherry-pick, reset, revert, shelf -- every caller of apply_manifest, plus checkout's own separate incremental-delta path) correctly removes files absent from the target branch's manifest, but never pruned the directories those files lived in. A directory whose only file was removed persisted as an empty, untracked leftover indistinguishable from a directory a human just created and hasn't staged yet.

Confirmed broader blast radius per the ticket's own follow-up comment: not just cross-branch switch -- the identical leftover appears from a plain fast-forward along a single branch's own history, since fast- forward also goes through apply_manifest.

Git has no analogous state inconsistency here (it never tracked the directory as an entity, so a stray empty dir is pure filesystem noise, not a tracked-state problem). Muse directories are real, content- addressed tracked objects, so a leftover empty one is a genuine correctness gap, not cosmetic.

Fix: apply_manifest (muse/core/workdir.py) now prunes any directory left empty by file removal, deepest-first, stopping at the first directory that still has content, is explicitly protected via the new target_directories parameter, or is the repo root. checkout.py's separate _checkout_snapshot incremental-delta path (used by switch) gets the same treatment via the same shared helper, using the target snapshot's own directories list (already loaded there) as protection.

Threaded target_directories through every apply_manifest call site that has a target manifest at hand -- merge (fast-forward, octopus, all three merge-strategy branches, abort, partial-conflict), pull (fast-forward, three-way), rebase, cherry-pick, reset. Deliberately derives the protected-directories set from directories_from_manifest(target_manifest) everywhere, NOT from a loaded SnapshotRecord.directories, even at call sites where one was already in scope -- found live that a plain `muse commit` leaves its own snapshot's directories field stale (still lists a directory whose last file that same commit just removed), which would have made target_directories wrongly protect exactly the directories this fix exists to prune. directories_from_manifest derives fresh from the file manifest every time and can't go stale this way. (The underlying muse-commit stale-directories-field bug is real and worth its own ticket -- not fixed here, scoped out.)

Tests: new tests/test_apply_manifest_dir_pruning.py, 7 tests -- unit coverage for apply_manifest's pruning (single and multi-level empty directories, an intentionally-protected directory surviving, a directory with other surviving files left alone, a directory with an untracked human file left alone) plus two full CLI integration tests: the ticket's exact switch repro, and a same-repo fast-forward-merge reproduction of the follow-up comment's broader blast radius. All green. Fixed one pre-existing test (test_cmd_cherry_pick_hardening.py) whose apply_manifest tracking mock didn't accept the new keyword argument.

Regression suite across every apply_manifest/checkout caller (workdir integrity, commit workdir preservation, switch, detached-head switch, checkout, merge, octopus merge, pull, clone, rebase, cherry-pick, reset, revert, shelf): 870 passed, zero regressions.

Verified against the real CLI (muse-dev, not just pytest): both the ticket's exact repro and the fast-forward blast-radius scenario, confirmed fixed live -- the previously-leftover directories no longer exist on disk after switch/merge, and muse status reports no scaffold leftovers.

sha256:1f1fca3c6e64bf11f2bfded0e1f03ee4955e297af4d6b5138a2b1362e31f8187 sha
+24 ~12 symbols
10 changed · 1213 in snapshot files
sha256:ba03dfd35f5eafbc82a818537037337d2e380a428b0a1e73ed9122d2ee7d95ef snapshot
+24
symbols added
~12
symbols modified
10
files changed
1213
files in snapshot
0
dead code introduced
Semantic Changes 36 symbols
+ _run function function _run L43–49
+ _write_blob function function _write_blob L52–55
+ CliRunner import import CliRunner L35–35
+ InvokeResult import import InvokeResult L35–35
+ Manifest import import Manifest L38–38
+ annotations import import annotations L28–28
+ apply_manifest import import apply_manifest L37–37
+ blob_id import import blob_id L38–38
+ os import import os L30–30
+ pathlib import import pathlib L31–31
+ pytest import import pytest L33–33
+ write_object import import write_object L36–36
+ repo function function repo L59–61
+ runner variable variable runner L40–40
+ test_apply_manifest_does_not_prune_directory_containing_untracked_file function function test_apply_manifest_does_not_prune_directory_containing_untracked_file L138–156
+ test_apply_manifest_does_not_prune_directory_still_in_target_directories function function test_apply_manifest_does_not_prune_directory_still_in_target_directories L101–116
+ test_apply_manifest_leaves_directory_with_other_surviving_files_alone function function test_apply_manifest_leaves_directory_with_other_surviving_files_alone L119–135
+ test_apply_manifest_prunes_directory_whose_last_file_was_removed function function test_apply_manifest_prunes_directory_whose_last_file_was_ L68–82
+ test_apply_manifest_prunes_recursively_up_to_but_not_past_root function function test_apply_manifest_prunes_recursively_up_to_but_not_past_root L85–98
+ test_fast_forward_merge_leaves_no_empty_directories function function test_fast_forward_merge_leaves_no_empty_directories L195–231
+ test_switch_between_branches_leaves_no_empty_directories function function test_switch_between_branches_leaves_no_empty_directories L163–192
~ muse/cli/commands/cherry_pick.py .py 1 symbol modified
~ run
~ muse/cli/commands/pull.py .py 1 symbol modified
~ run
~ muse/cli/commands/reset.py .py 1 symbol added, 1 symbol modified
+ directories_from_manifest import import directories_from_manifest L56–56
~ run
~ muse/core/rebase.py .py 1 symbol modified
~ muse/core/workdir.py .py 2 symbols added, 1 symbol modified
+ _prune_empty_directories function function _prune_empty_directories L21–65
+ Iterable import import Iterable L11–11
Files Changed
+1 ~9
1213 in snapshot
← Older Oldest on fix/95-empty-dir-pruning
All commits
Newer → Latest on fix/95-empty-dir-pruning

0 comments

No comments yet. Be the first to start the discussion.

To add a comment, use the Muse CLI: muse hub commit comment sha256:1f1fca3c6e64bf11f2bfded0e1f03ee4955e297af4d6b5138a2b1362e31f8187 --body "your comment"