"""muse#95 — apply_manifest leaves empty directories behind. `apply_manifest` removes files present in `prev_manifest` but absent from `target_manifest`, but never pruned the directories those files lived in -- so a directory whose only files were removed persists as an empty, untracked leftover. Confirmed to affect every caller of apply_manifest (switch, checkout, merge, pull, fast-forward, rebase, cherry-pick, clone, reset, revert, shelf) since the bug lives in the one shared function all of them delegate to. Git has no analogous state-inconsistency here (it never tracked the directory as an entity), but muse's content-addressed directories make a leftover empty directory indistinguishable from one a human just created and hasn't `code add`ed yet. Coverage: - unit: apply_manifest prunes a directory whose last file was removed, recursively up the tree when that empties a grandparent too. - unit: a directory still listed in target_directories (even with zero files on the target -- an intentionally-tracked empty directory) is NOT pruned. - unit: a directory that still has other files on the target is untouched (the common, non-buggy case). - integration: the exact CLI repro from the ticket (muse switch between branches with different directory structures) via muse-dev's real CLI behavior proxied through direct apply_manifest + commit plumbing. """ from __future__ import annotations import os import pathlib import pytest from tests.cli_test_helper import CliRunner, InvokeResult from muse.core.object_store import write_object from muse.core.workdir import apply_manifest from muse.core.types import Manifest, blob_id runner = CliRunner() def _run(repo: pathlib.Path, *args: str) -> InvokeResult: saved = os.getcwd() try: os.chdir(repo) return runner.invoke(None, list(args)) finally: os.chdir(saved) def _write_blob(root: pathlib.Path, content: bytes) -> str: oid = blob_id(content) write_object(root, oid, content) return oid @pytest.fixture() def repo(tmp_path: pathlib.Path) -> pathlib.Path: _run(tmp_path, "init") return tmp_path # --------------------------------------------------------------------------- # Unit: apply_manifest prunes newly-empty directories # --------------------------------------------------------------------------- def test_apply_manifest_prunes_directory_whose_last_file_was_removed(repo: pathlib.Path) -> None: oid = _write_blob(repo, b"content") keep_oid = _write_blob(repo, b"keep") prev: Manifest = {"scaffold/sub/file.md": oid, "keep.md": keep_oid} target: Manifest = {"keep.md": keep_oid} (repo / "scaffold" / "sub").mkdir(parents=True) (repo / "scaffold" / "sub" / "file.md").write_text("content") (repo / "keep.md").write_text("keep") apply_manifest(repo, prev, target) assert not (repo / "scaffold" / "sub" / "file.md").exists() assert not (repo / "scaffold" / "sub").exists(), "empty dir left behind" assert not (repo / "scaffold").exists(), "empty grandparent dir left behind" def test_apply_manifest_prunes_recursively_up_to_but_not_past_root(repo: pathlib.Path) -> None: oid = _write_blob(repo, b"deep") keep_oid = _write_blob(repo, b"keep") prev: Manifest = {"a/b/c/d/file.md": oid, "keep.md": keep_oid} target: Manifest = {"keep.md": keep_oid} (repo / "a" / "b" / "c" / "d").mkdir(parents=True) (repo / "a" / "b" / "c" / "d" / "file.md").write_text("deep") (repo / "keep.md").write_text("keep") apply_manifest(repo, prev, target) assert not (repo / "a").exists() assert repo.exists() def test_apply_manifest_does_not_prune_directory_still_in_target_directories(repo: pathlib.Path) -> None: """An intentionally-tracked empty directory on the target branch (listed in target_directories even though it has zero files) must survive.""" oid = _write_blob(repo, b"content") keep_oid = _write_blob(repo, b"keep") prev: Manifest = {"scaffold/file.md": oid, "keep.md": keep_oid} target: Manifest = {"keep.md": keep_oid} (repo / "scaffold").mkdir(parents=True) (repo / "scaffold" / "file.md").write_text("content") (repo / "keep.md").write_text("keep") apply_manifest(repo, prev, target, target_directories=["scaffold"]) assert not (repo / "scaffold" / "file.md").exists() assert (repo / "scaffold").exists(), "intentionally-tracked empty dir must survive" def test_apply_manifest_leaves_directory_with_other_surviving_files_alone(repo: pathlib.Path) -> None: """The common, non-buggy case: a directory with other files in the target manifest is untouched (it was never actually empty).""" oid_removed = _write_blob(repo, b"removed") oid_kept = _write_blob(repo, b"kept") prev: Manifest = {"scaffold/removed.md": oid_removed, "scaffold/kept.md": oid_kept} target: Manifest = {"scaffold/kept.md": oid_kept} (repo / "scaffold").mkdir(parents=True) (repo / "scaffold" / "removed.md").write_text("removed") (repo / "scaffold" / "kept.md").write_text("kept") apply_manifest(repo, prev, target) assert not (repo / "scaffold" / "removed.md").exists() assert (repo / "scaffold" / "kept.md").exists() assert (repo / "scaffold").exists() def test_apply_manifest_does_not_prune_directory_containing_untracked_file(repo: pathlib.Path) -> None: """A directory that LOOKS empty from the manifest's perspective but actually still has a human-created untracked file on disk must not be deleted -- only genuinely empty directories are pruned.""" oid = _write_blob(repo, b"content") keep_oid = _write_blob(repo, b"keep") prev: Manifest = {"scaffold/file.md": oid, "keep.md": keep_oid} target: Manifest = {"keep.md": keep_oid} (repo / "scaffold").mkdir(parents=True) (repo / "scaffold" / "file.md").write_text("content") (repo / "scaffold" / "notes.txt").write_text("untracked human notes") (repo / "keep.md").write_text("keep") apply_manifest(repo, prev, target) assert not (repo / "scaffold" / "file.md").exists() assert (repo / "scaffold" / "notes.txt").exists() assert (repo / "scaffold").exists(), "directory with untracked content must survive" # --------------------------------------------------------------------------- # Integration: the exact ticket repro via the real CLI # --------------------------------------------------------------------------- def test_switch_between_branches_leaves_no_empty_directories(tmp_path: pathlib.Path) -> None: _run(tmp_path, "init") (tmp_path / "README.md").write_text("hello\n") _run(tmp_path, "code", "add", "README.md") _run(tmp_path, "commit", "-m", "init") r = _run(tmp_path, "branch", "feat/scaffold") assert r.exit_code == 0, r.stderr r = _run(tmp_path, "checkout", "feat/scaffold") assert r.exit_code == 0, r.stderr (tmp_path / "scaffold" / "sub").mkdir(parents=True) (tmp_path / "scaffold" / "sub" / "file.md").write_text("content\n") r = _run(tmp_path, "code", "add", "scaffold/") assert r.exit_code == 0, r.stderr r = _run(tmp_path, "commit", "-m", "add scaffold files") assert r.exit_code == 0, r.stderr r = _run(tmp_path, "checkout", "main") assert r.exit_code == 0, r.stderr assert not (tmp_path / "scaffold" / "sub").exists(), "scaffold/sub left behind" assert not (tmp_path / "scaffold").exists(), "scaffold left behind" status = _run(tmp_path, "status", "--json") assert status.exit_code == 0, status.stderr import json as _json payload = _json.loads(status.output) scaffold_leftovers = [p for p in payload["untracked"] if p.startswith("scaffold")] assert scaffold_leftovers == [], f"leftover untracked entries: {scaffold_leftovers}" def test_fast_forward_merge_leaves_no_empty_directories(tmp_path: pathlib.Path) -> None: """Covers the broader blast radius from the ticket's own follow-up comment: the same leftover-directory bug from a plain fast-forward along a single branch's own history, not just a cross-branch switch. Uses a same-repo fast-forward (checkout behind, then merge forward) rather than a networked clone/pull round trip -- exercises the exact fast-forward apply_manifest call site noted in the comment without needing a live remote.""" _run(tmp_path, "init") (tmp_path / "keep.md").write_text("keep\n") (tmp_path / "tools" / "plugin").mkdir(parents=True) (tmp_path / "tools" / "plugin" / "README.md").write_text("plugin docs\n") _run(tmp_path, "code", "add", ".") _run(tmp_path, "commit", "-m", "add tools/plugin") r = _run(tmp_path, "branch", "behind") assert r.exit_code == 0, r.stderr # main moves ahead: removes the plugin dir's only file. (tmp_path / "tools" / "plugin" / "README.md").unlink() _run(tmp_path, "code", "add", "tools/") _run(tmp_path, "commit", "-m", "chore: remove stale tools/plugin") # "behind" fast-forward-merges main's new tip -- same apply_manifest # fast-forward path a `pull` would exercise. r = _run(tmp_path, "checkout", "behind") assert r.exit_code == 0, r.stderr r = _run(tmp_path, "merge", "main") assert r.exit_code == 0, r.stderr assert not (tmp_path / "tools" / "plugin").exists(), "tools/plugin left behind after fast-forward" status = _run(tmp_path, "status", "--json") import json as _json payload = _json.loads(status.output) tools_leftovers = [p for p in payload["untracked"] if p.startswith("tools")] assert tools_leftovers == [], f"leftover untracked entries: {tools_leftovers}"