"""Consolidated reproduction suite for the empty-directory-sentinel family of bugs (muse#101, #102, #103, #104). Root cause (muse#104): ``EMPTY_DIR_OID = blob_id(b"")`` is both the object ID used to mark a tracked *empty directory* sentinel AND the legitimate content hash of a genuinely empty *file* -- the two states were, before this fix, indistinguishable by object_id alone. The fix adds an explicit ``kind: "file" | "dir"`` discriminator to ``StagedEntry`` (stage schema v4) and updates every call site that used to infer file-vs-directory from ``object_id == EMPTY_DIR_OID``. The dir->file transition-clobber regression (fixed earlier, shipped in 0.2.1rc8) already has a locked-in regression guard in ``test_cmd_code_add.py::TestDirToFileTransitionStageClobber`` -- not duplicated here. """ from __future__ import annotations import json import pathlib import pytest from muse.plugins.code.stage import read_stage, write_stage, StagedEntry from tests.cli_test_helper import CliRunner runner = CliRunner() cli = None def _env(root: pathlib.Path) -> dict[str, str]: return {"MUSE_REPO_ROOT": str(root)} def _run(root: pathlib.Path, *args: str) -> tuple[int, str]: result = runner.invoke(cli, list(args), env=_env(root), catch_exceptions=False) return result.exit_code, result.output @pytest.fixture() def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: """Fresh code-domain repo, no commits yet.""" monkeypatch.chdir(tmp_path) r = runner.invoke(cli, ["init", "--domain", "code"], env=_env(tmp_path)) assert r.exit_code == 0, r.output return tmp_path # =========================================================================== # muse#102 — genuinely empty file silently dropped from the manifest # =========================================================================== class TestEmptyFileNotMisclassifiedAsDirSentinel: """A 0-byte file has the same object_id as an empty-dir sentinel (both are ``blob_id(b"")``). Before the ``kind`` discriminator, every call site that checked ``object_id == EMPTY_DIR_OID`` treated a staged empty file as a directory sentinel and silently excluded it from the commit manifest. """ def test_empty_file_is_staged_as_new_file_not_a_dir( self, repo: pathlib.Path ) -> None: (repo / "brand-new-empty.md").touch() code, out = _run(repo, "code", "add", ".", "--json") assert code == 0 data = json.loads(out) paths = {f["path"]: f["mode"] for f in data["files"]} assert paths.get("brand-new-empty.md") == "new file", ( f"expected empty file staged as 'new file', got: {paths}" ) def test_empty_file_survives_commit_in_manifest( self, repo: pathlib.Path ) -> None: (repo / "brand-new-empty.md").touch() code, _ = _run(repo, "code", "add", ".") assert code == 0 code, _ = _run(repo, "commit", "-m", "track empty file") assert code == 0 from muse.core.refs import get_head_commit_id from muse.core.commits import read_commit from muse.core.snapshots import read_snapshot head_id = get_head_commit_id(repo, "main") assert head_id is not None commit_rec = read_commit(repo, head_id) assert commit_rec is not None snap = read_snapshot(repo, commit_rec.snapshot_id) assert snap is not None assert "brand-new-empty.md" in snap.manifest, ( "empty file silently dropped from committed manifest" ) assert "brand-new-empty.md" not in snap.directories, ( "empty file incorrectly recorded as a directory" ) def test_empty_file_not_untracked_after_commit( self, repo: pathlib.Path ) -> None: (repo / "brand-new-empty.md").touch() _run(repo, "code", "add", ".") _run(repo, "commit", "-m", "track empty file") code, out = _run(repo, "status", "--json") assert code == 0 data = json.loads(out) assert "brand-new-empty.md" not in data.get("untracked", []), ( f"empty file reappeared as untracked after commit: {data}" ) def test_empty_dir_sentinel_still_works_alongside_empty_files( self, repo: pathlib.Path ) -> None: """Regression guard: fixing #102 must not break tracked empty dirs.""" (repo / "an-empty-dir").mkdir() (repo / "an-empty-file.txt").touch() code, _ = _run(repo, "code", "add", ".") assert code == 0 code, _ = _run(repo, "commit", "-m", "track dir and file") assert code == 0 from muse.core.refs import get_head_commit_id from muse.core.commits import read_commit from muse.core.snapshots import read_snapshot head_id = get_head_commit_id(repo, "main") assert head_id is not None commit_rec = read_commit(repo, head_id) assert commit_rec is not None snap = read_snapshot(repo, commit_rec.snapshot_id) assert snap is not None assert "an-empty-file.txt" in snap.manifest assert "an-empty-dir" in snap.directories assert "an-empty-dir" not in snap.manifest assert "an-empty-file.txt" not in snap.directories # =========================================================================== # muse#103 — stuck directory sentinel, directory still exists on disk # =========================================================================== class TestStuckDirectorySentinelUntrack: """A committed empty-directory sentinel whose physical directory is still present on disk (but now fully excluded by ``.museignore``) must be untrackable via ``muse code add .`` -- automatic detection, no manual object-store surgery required. """ def test_ignored_still_present_dir_is_auto_untracked( self, repo: pathlib.Path ) -> None: (repo / "stuck-dir").mkdir() code, _ = _run(repo, "code", "add", "stuck-dir") assert code == 0 code, _ = _run(repo, "commit", "-m", "track empty dir", "--allow-empty") assert code == 0 # Directory stays on disk — only now excluded via .museignore. (repo / ".museignore").write_text( '[global]\npatterns = ["stuck-dir/"]\n' ) code, out = _run(repo, "status", "--json") assert code == 0 data = json.loads(out) assert "stuck-dir/" in data.get("deleted", []), ( f"expected stuck-dir/ to show as deleted pre-fix: {data}" ) code, out = _run(repo, "code", "add", ".", "--json") assert code == 0 data = json.loads(out) paths = {f["path"].rstrip("/") for f in data["files"]} assert "stuck-dir" in paths, ( f"ignored-but-still-present dir sentinel was not auto-staged " f"for removal: {data}" ) code, _ = _run(repo, "commit", "-m", "untrack stuck-dir", "--allow-empty") assert code == 0 code, out = _run(repo, "status", "--json") assert code == 0 data = json.loads(out) assert data.get("clean") is True, ( f"repo not clean after untracking stuck-dir: {data}" ) # =========================================================================== # muse#101 — general invariant: no path in both manifest and directories # =========================================================================== class TestManifestDirectoriesMutualExclusion: """A committed snapshot must never record the same path in both ``manifest`` (files) and ``directories`` simultaneously -- that state is what produces #101's false "deleted" status. """ def test_fresh_commit_never_double_records_a_path( self, repo: pathlib.Path ) -> None: (repo / "sub").mkdir() (repo / "sub" / "file.py").write_text("x = 1\n") (repo / "empty-dir").mkdir() (repo / "empty-file.txt").touch() code, _ = _run(repo, "code", "add", ".") assert code == 0 code, _ = _run(repo, "commit", "-m", "initial") assert code == 0 from muse.core.refs import get_head_commit_id from muse.core.commits import read_commit from muse.core.snapshots import read_snapshot head_id = get_head_commit_id(repo, "main") assert head_id is not None commit_rec = read_commit(repo, head_id) assert commit_rec is not None snap = read_snapshot(repo, commit_rec.snapshot_id) assert snap is not None overlap = set(snap.manifest) & set(snap.directories) assert not overlap, f"path(s) recorded as both file and directory: {overlap}" def test_dir_to_file_and_file_to_dir_transitions_never_overlap( self, repo: pathlib.Path ) -> None: (repo / "path-a").mkdir() (repo / "path-b.txt").write_text("content\n") _run(repo, "code", "add", ".") code, _ = _run(repo, "commit", "-m", "step 1") assert code == 0 (repo / "path-a").rmdir() (repo / "path-a").write_text("now a file\n") _run(repo, "code", "add", ".") code, _ = _run(repo, "commit", "-m", "step 2: path-a becomes a file") assert code == 0 from muse.core.refs import get_head_commit_id from muse.core.commits import read_commit from muse.core.snapshots import read_snapshot head_id = get_head_commit_id(repo, "main") assert head_id is not None commit_rec = read_commit(repo, head_id) assert commit_rec is not None snap = read_snapshot(repo, commit_rec.snapshot_id) assert snap is not None overlap = set(snap.manifest) & set(snap.directories) assert not overlap, f"path(s) recorded as both file and directory: {overlap}" assert "path-a" in snap.manifest assert "path-a" not in snap.directories # =========================================================================== # Stage schema v4 migration — pre-v4 entries with no explicit ``kind`` # =========================================================================== class TestStageSchemaV4Migration: """Entries written under the pre-v4 schema (no ``kind`` field) must migrate correctly on read: an on-disk directory path infers ``kind="dir"``, everything else infers ``kind="file"``. """ def test_legacy_entry_for_existing_dir_migrates_to_dir_kind( self, repo: pathlib.Path ) -> None: from muse.plugins.code.stage import EMPTY_DIR_OID, stage_path import json as _json (repo / "legacy-dir").mkdir() legacy_stage = { "version": 3, "entries": { "legacy-dir": { "object_id": EMPTY_DIR_OID, "mode": "A", "staged_at": "2026-01-01T00:00:00+00:00", } }, } stage_path(repo).parent.mkdir(parents=True, exist_ok=True) stage_path(repo).write_text(_json.dumps(legacy_stage)) entries = read_stage(repo) assert entries["legacy-dir"]["kind"] == "dir" def test_legacy_entry_for_nonexistent_path_migrates_to_file_kind( self, repo: pathlib.Path ) -> None: from muse.plugins.code.stage import EMPTY_DIR_OID, stage_path import json as _json legacy_stage = { "version": 3, "entries": { "legacy-empty-file.txt": { "object_id": EMPTY_DIR_OID, "mode": "A", "staged_at": "2026-01-01T00:00:00+00:00", } }, } stage_path(repo).parent.mkdir(parents=True, exist_ok=True) stage_path(repo).write_text(_json.dumps(legacy_stage)) entries = read_stage(repo) assert entries["legacy-empty-file.txt"]["kind"] == "file"