gabriel / muse public
test_empty_dir_sentinel_invariants.py python
316 lines 11.7 KB
Raw
sha256:47e5542341c5a936597b3974abf274d75185c50c3b89917c21d8face06fa6691 fix: eliminate EMPTY_DIR_OID/empty-file collision with expl… Sonnet 5 minor ⚠ breaking 10 hours ago
1 """Consolidated reproduction suite for the empty-directory-sentinel family
2 of bugs (muse#101, #102, #103, #104).
3
4 Root cause (muse#104): ``EMPTY_DIR_OID = blob_id(b"")`` is both the object
5 ID used to mark a tracked *empty directory* sentinel AND the legitimate
6 content hash of a genuinely empty *file* -- the two states were, before
7 this fix, indistinguishable by object_id alone. The fix adds an explicit
8 ``kind: "file" | "dir"`` discriminator to ``StagedEntry`` (stage schema
9 v4) and updates every call site that used to infer file-vs-directory from
10 ``object_id == EMPTY_DIR_OID``.
11
12 The dir->file transition-clobber regression (fixed earlier, shipped in
13 0.2.1rc8) already has a locked-in regression guard in
14 ``test_cmd_code_add.py::TestDirToFileTransitionStageClobber`` -- not
15 duplicated here.
16 """
17
18 from __future__ import annotations
19
20 import json
21 import pathlib
22
23 import pytest
24
25 from muse.plugins.code.stage import read_stage, write_stage, StagedEntry
26 from tests.cli_test_helper import CliRunner
27
28 runner = CliRunner()
29 cli = None
30
31
32 def _env(root: pathlib.Path) -> dict[str, str]:
33 return {"MUSE_REPO_ROOT": str(root)}
34
35
36 def _run(root: pathlib.Path, *args: str) -> tuple[int, str]:
37 result = runner.invoke(cli, list(args), env=_env(root), catch_exceptions=False)
38 return result.exit_code, result.output
39
40
41 @pytest.fixture()
42 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
43 """Fresh code-domain repo, no commits yet."""
44 monkeypatch.chdir(tmp_path)
45 r = runner.invoke(cli, ["init", "--domain", "code"], env=_env(tmp_path))
46 assert r.exit_code == 0, r.output
47 return tmp_path
48
49
50 # ===========================================================================
51 # muse#102 — genuinely empty file silently dropped from the manifest
52 # ===========================================================================
53
54
55 class TestEmptyFileNotMisclassifiedAsDirSentinel:
56 """A 0-byte file has the same object_id as an empty-dir sentinel
57 (both are ``blob_id(b"")``). Before the ``kind`` discriminator, every
58 call site that checked ``object_id == EMPTY_DIR_OID`` treated a
59 staged empty file as a directory sentinel and silently excluded it
60 from the commit manifest.
61 """
62
63 def test_empty_file_is_staged_as_new_file_not_a_dir(
64 self, repo: pathlib.Path
65 ) -> None:
66 (repo / "brand-new-empty.md").touch()
67 code, out = _run(repo, "code", "add", ".", "--json")
68 assert code == 0
69 data = json.loads(out)
70 paths = {f["path"]: f["mode"] for f in data["files"]}
71 assert paths.get("brand-new-empty.md") == "new file", (
72 f"expected empty file staged as 'new file', got: {paths}"
73 )
74
75 def test_empty_file_survives_commit_in_manifest(
76 self, repo: pathlib.Path
77 ) -> None:
78 (repo / "brand-new-empty.md").touch()
79 code, _ = _run(repo, "code", "add", ".")
80 assert code == 0
81 code, _ = _run(repo, "commit", "-m", "track empty file")
82 assert code == 0
83
84 from muse.core.refs import get_head_commit_id
85 from muse.core.commits import read_commit
86 from muse.core.snapshots import read_snapshot
87
88 head_id = get_head_commit_id(repo, "main")
89 assert head_id is not None
90 commit_rec = read_commit(repo, head_id)
91 assert commit_rec is not None
92 snap = read_snapshot(repo, commit_rec.snapshot_id)
93 assert snap is not None
94 assert "brand-new-empty.md" in snap.manifest, (
95 "empty file silently dropped from committed manifest"
96 )
97 assert "brand-new-empty.md" not in snap.directories, (
98 "empty file incorrectly recorded as a directory"
99 )
100
101 def test_empty_file_not_untracked_after_commit(
102 self, repo: pathlib.Path
103 ) -> None:
104 (repo / "brand-new-empty.md").touch()
105 _run(repo, "code", "add", ".")
106 _run(repo, "commit", "-m", "track empty file")
107
108 code, out = _run(repo, "status", "--json")
109 assert code == 0
110 data = json.loads(out)
111 assert "brand-new-empty.md" not in data.get("untracked", []), (
112 f"empty file reappeared as untracked after commit: {data}"
113 )
114
115 def test_empty_dir_sentinel_still_works_alongside_empty_files(
116 self, repo: pathlib.Path
117 ) -> None:
118 """Regression guard: fixing #102 must not break tracked empty dirs."""
119 (repo / "an-empty-dir").mkdir()
120 (repo / "an-empty-file.txt").touch()
121 code, _ = _run(repo, "code", "add", ".")
122 assert code == 0
123 code, _ = _run(repo, "commit", "-m", "track dir and file")
124 assert code == 0
125
126 from muse.core.refs import get_head_commit_id
127 from muse.core.commits import read_commit
128 from muse.core.snapshots import read_snapshot
129
130 head_id = get_head_commit_id(repo, "main")
131 assert head_id is not None
132 commit_rec = read_commit(repo, head_id)
133 assert commit_rec is not None
134 snap = read_snapshot(repo, commit_rec.snapshot_id)
135 assert snap is not None
136 assert "an-empty-file.txt" in snap.manifest
137 assert "an-empty-dir" in snap.directories
138 assert "an-empty-dir" not in snap.manifest
139 assert "an-empty-file.txt" not in snap.directories
140
141
142 # ===========================================================================
143 # muse#103 — stuck directory sentinel, directory still exists on disk
144 # ===========================================================================
145
146
147 class TestStuckDirectorySentinelUntrack:
148 """A committed empty-directory sentinel whose physical directory is
149 still present on disk (but now fully excluded by ``.museignore``)
150 must be untrackable via ``muse code add .`` -- automatic detection,
151 no manual object-store surgery required.
152 """
153
154 def test_ignored_still_present_dir_is_auto_untracked(
155 self, repo: pathlib.Path
156 ) -> None:
157 (repo / "stuck-dir").mkdir()
158 code, _ = _run(repo, "code", "add", "stuck-dir")
159 assert code == 0
160 code, _ = _run(repo, "commit", "-m", "track empty dir", "--allow-empty")
161 assert code == 0
162
163 # Directory stays on disk — only now excluded via .museignore.
164 (repo / ".museignore").write_text(
165 '[global]\npatterns = ["stuck-dir/"]\n'
166 )
167
168 code, out = _run(repo, "status", "--json")
169 assert code == 0
170 data = json.loads(out)
171 assert "stuck-dir/" in data.get("deleted", []), (
172 f"expected stuck-dir/ to show as deleted pre-fix: {data}"
173 )
174
175 code, out = _run(repo, "code", "add", ".", "--json")
176 assert code == 0
177 data = json.loads(out)
178 paths = {f["path"].rstrip("/") for f in data["files"]}
179 assert "stuck-dir" in paths, (
180 f"ignored-but-still-present dir sentinel was not auto-staged "
181 f"for removal: {data}"
182 )
183
184 code, _ = _run(repo, "commit", "-m", "untrack stuck-dir", "--allow-empty")
185 assert code == 0
186
187 code, out = _run(repo, "status", "--json")
188 assert code == 0
189 data = json.loads(out)
190 assert data.get("clean") is True, (
191 f"repo not clean after untracking stuck-dir: {data}"
192 )
193
194
195 # ===========================================================================
196 # muse#101 — general invariant: no path in both manifest and directories
197 # ===========================================================================
198
199
200 class TestManifestDirectoriesMutualExclusion:
201 """A committed snapshot must never record the same path in both
202 ``manifest`` (files) and ``directories`` simultaneously -- that state
203 is what produces #101's false "deleted" status.
204 """
205
206 def test_fresh_commit_never_double_records_a_path(
207 self, repo: pathlib.Path
208 ) -> None:
209 (repo / "sub").mkdir()
210 (repo / "sub" / "file.py").write_text("x = 1\n")
211 (repo / "empty-dir").mkdir()
212 (repo / "empty-file.txt").touch()
213 code, _ = _run(repo, "code", "add", ".")
214 assert code == 0
215 code, _ = _run(repo, "commit", "-m", "initial")
216 assert code == 0
217
218 from muse.core.refs import get_head_commit_id
219 from muse.core.commits import read_commit
220 from muse.core.snapshots import read_snapshot
221
222 head_id = get_head_commit_id(repo, "main")
223 assert head_id is not None
224 commit_rec = read_commit(repo, head_id)
225 assert commit_rec is not None
226 snap = read_snapshot(repo, commit_rec.snapshot_id)
227 assert snap is not None
228 overlap = set(snap.manifest) & set(snap.directories)
229 assert not overlap, f"path(s) recorded as both file and directory: {overlap}"
230
231 def test_dir_to_file_and_file_to_dir_transitions_never_overlap(
232 self, repo: pathlib.Path
233 ) -> None:
234 (repo / "path-a").mkdir()
235 (repo / "path-b.txt").write_text("content\n")
236 _run(repo, "code", "add", ".")
237 code, _ = _run(repo, "commit", "-m", "step 1")
238 assert code == 0
239
240 (repo / "path-a").rmdir()
241 (repo / "path-a").write_text("now a file\n")
242 _run(repo, "code", "add", ".")
243 code, _ = _run(repo, "commit", "-m", "step 2: path-a becomes a file")
244 assert code == 0
245
246 from muse.core.refs import get_head_commit_id
247 from muse.core.commits import read_commit
248 from muse.core.snapshots import read_snapshot
249
250 head_id = get_head_commit_id(repo, "main")
251 assert head_id is not None
252 commit_rec = read_commit(repo, head_id)
253 assert commit_rec is not None
254 snap = read_snapshot(repo, commit_rec.snapshot_id)
255 assert snap is not None
256 overlap = set(snap.manifest) & set(snap.directories)
257 assert not overlap, f"path(s) recorded as both file and directory: {overlap}"
258 assert "path-a" in snap.manifest
259 assert "path-a" not in snap.directories
260
261
262 # ===========================================================================
263 # Stage schema v4 migration — pre-v4 entries with no explicit ``kind``
264 # ===========================================================================
265
266
267 class TestStageSchemaV4Migration:
268 """Entries written under the pre-v4 schema (no ``kind`` field) must
269 migrate correctly on read: an on-disk directory path infers
270 ``kind="dir"``, everything else infers ``kind="file"``.
271 """
272
273 def test_legacy_entry_for_existing_dir_migrates_to_dir_kind(
274 self, repo: pathlib.Path
275 ) -> None:
276 from muse.plugins.code.stage import EMPTY_DIR_OID, stage_path
277 import json as _json
278
279 (repo / "legacy-dir").mkdir()
280 legacy_stage = {
281 "version": 3,
282 "entries": {
283 "legacy-dir": {
284 "object_id": EMPTY_DIR_OID,
285 "mode": "A",
286 "staged_at": "2026-01-01T00:00:00+00:00",
287 }
288 },
289 }
290 stage_path(repo).parent.mkdir(parents=True, exist_ok=True)
291 stage_path(repo).write_text(_json.dumps(legacy_stage))
292
293 entries = read_stage(repo)
294 assert entries["legacy-dir"]["kind"] == "dir"
295
296 def test_legacy_entry_for_nonexistent_path_migrates_to_file_kind(
297 self, repo: pathlib.Path
298 ) -> None:
299 from muse.plugins.code.stage import EMPTY_DIR_OID, stage_path
300 import json as _json
301
302 legacy_stage = {
303 "version": 3,
304 "entries": {
305 "legacy-empty-file.txt": {
306 "object_id": EMPTY_DIR_OID,
307 "mode": "A",
308 "staged_at": "2026-01-01T00:00:00+00:00",
309 }
310 },
311 }
312 stage_path(repo).parent.mkdir(parents=True, exist_ok=True)
313 stage_path(repo).write_text(_json.dumps(legacy_stage))
314
315 entries = read_stage(repo)
316 assert entries["legacy-empty-file.txt"]["kind"] == "file"
File History 1 commit
sha256:47e5542341c5a936597b3974abf274d75185c50c3b89917c21d8face06fa6691 fix: eliminate EMPTY_DIR_OID/empty-file collision with expl… Sonnet 5 minor 10 hours ago