gabriel / muse public
test_empty_dir_sentinel_invariants.py python
366 lines 13.8 KB
Raw
sha256:d2dc1ad1242cf18f8b17e8311abe3fd3f596d4fb73b60e411699e42681a24653 test: add muse#90's exact path-poisoning regression to the … Sonnet 5 16 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_path_poisoned_by_empty_file_accepts_real_content_later(
116 self, repo: pathlib.Path
117 ) -> None:
118 """muse#90's exact reported scenario: commit a 0-byte file at a
119 path, then later commit real content at that SAME path. Before
120 the ``kind`` discriminator, the path's directory-sentinel
121 misclassification persisted across commits -- real content
122 written there afterward was silently excluded from the manifest
123 too, with no error and no workaround short of renaming the file
124 and `muse mv`-ing it back.
125 """
126 target = repo / "episode-03" / "everything-is-content-addressed.md"
127 target.parent.mkdir()
128 target.touch()
129 code, _ = _run(repo, "code", "add", ".")
130 assert code == 0
131 code, _ = _run(repo, "commit", "-m", "track empty placeholder")
132 assert code == 0
133
134 target.write_text("x" * 10_000 + "\n10KB of real content\n")
135 code, _ = _run(repo, "code", "add", str(target.relative_to(repo)))
136 assert code == 0
137 code, _ = _run(repo, "commit", "-m", "real content update")
138 assert code == 0
139
140 from muse.core.refs import get_head_commit_id
141 from muse.core.commits import read_commit
142 from muse.core.snapshots import read_snapshot
143
144 head_id = get_head_commit_id(repo, "main")
145 assert head_id is not None
146 commit_rec = read_commit(repo, head_id)
147 assert commit_rec is not None
148 snap = read_snapshot(repo, commit_rec.snapshot_id)
149 assert snap is not None
150 rel = "episode-03/everything-is-content-addressed.md"
151 assert rel in snap.manifest, (
152 "real content silently dropped from manifest at a path "
153 "previously used for a 0-byte file -- the path stayed "
154 "poisoned across commits"
155 )
156 assert rel not in snap.directories
157
158 from muse.core.object_store import read_object
159
160 obj = read_object(repo, snap.manifest[rel])
161 assert obj is not None and len(obj) > 10_000, (
162 "manifest points at the wrong object -- real content didn't land"
163 )
164
165 def test_empty_dir_sentinel_still_works_alongside_empty_files(
166 self, repo: pathlib.Path
167 ) -> None:
168 """Regression guard: fixing #102 must not break tracked empty dirs."""
169 (repo / "an-empty-dir").mkdir()
170 (repo / "an-empty-file.txt").touch()
171 code, _ = _run(repo, "code", "add", ".")
172 assert code == 0
173 code, _ = _run(repo, "commit", "-m", "track dir and file")
174 assert code == 0
175
176 from muse.core.refs import get_head_commit_id
177 from muse.core.commits import read_commit
178 from muse.core.snapshots import read_snapshot
179
180 head_id = get_head_commit_id(repo, "main")
181 assert head_id is not None
182 commit_rec = read_commit(repo, head_id)
183 assert commit_rec is not None
184 snap = read_snapshot(repo, commit_rec.snapshot_id)
185 assert snap is not None
186 assert "an-empty-file.txt" in snap.manifest
187 assert "an-empty-dir" in snap.directories
188 assert "an-empty-dir" not in snap.manifest
189 assert "an-empty-file.txt" not in snap.directories
190
191
192 # ===========================================================================
193 # muse#103 — stuck directory sentinel, directory still exists on disk
194 # ===========================================================================
195
196
197 class TestStuckDirectorySentinelUntrack:
198 """A committed empty-directory sentinel whose physical directory is
199 still present on disk (but now fully excluded by ``.museignore``)
200 must be untrackable via ``muse code add .`` -- automatic detection,
201 no manual object-store surgery required.
202 """
203
204 def test_ignored_still_present_dir_is_auto_untracked(
205 self, repo: pathlib.Path
206 ) -> None:
207 (repo / "stuck-dir").mkdir()
208 code, _ = _run(repo, "code", "add", "stuck-dir")
209 assert code == 0
210 code, _ = _run(repo, "commit", "-m", "track empty dir", "--allow-empty")
211 assert code == 0
212
213 # Directory stays on disk — only now excluded via .museignore.
214 (repo / ".museignore").write_text(
215 '[global]\npatterns = ["stuck-dir/"]\n'
216 )
217
218 code, out = _run(repo, "status", "--json")
219 assert code == 0
220 data = json.loads(out)
221 assert "stuck-dir/" in data.get("deleted", []), (
222 f"expected stuck-dir/ to show as deleted pre-fix: {data}"
223 )
224
225 code, out = _run(repo, "code", "add", ".", "--json")
226 assert code == 0
227 data = json.loads(out)
228 paths = {f["path"].rstrip("/") for f in data["files"]}
229 assert "stuck-dir" in paths, (
230 f"ignored-but-still-present dir sentinel was not auto-staged "
231 f"for removal: {data}"
232 )
233
234 code, _ = _run(repo, "commit", "-m", "untrack stuck-dir", "--allow-empty")
235 assert code == 0
236
237 code, out = _run(repo, "status", "--json")
238 assert code == 0
239 data = json.loads(out)
240 assert data.get("clean") is True, (
241 f"repo not clean after untracking stuck-dir: {data}"
242 )
243
244
245 # ===========================================================================
246 # muse#101 — general invariant: no path in both manifest and directories
247 # ===========================================================================
248
249
250 class TestManifestDirectoriesMutualExclusion:
251 """A committed snapshot must never record the same path in both
252 ``manifest`` (files) and ``directories`` simultaneously -- that state
253 is what produces #101's false "deleted" status.
254 """
255
256 def test_fresh_commit_never_double_records_a_path(
257 self, repo: pathlib.Path
258 ) -> None:
259 (repo / "sub").mkdir()
260 (repo / "sub" / "file.py").write_text("x = 1\n")
261 (repo / "empty-dir").mkdir()
262 (repo / "empty-file.txt").touch()
263 code, _ = _run(repo, "code", "add", ".")
264 assert code == 0
265 code, _ = _run(repo, "commit", "-m", "initial")
266 assert code == 0
267
268 from muse.core.refs import get_head_commit_id
269 from muse.core.commits import read_commit
270 from muse.core.snapshots import read_snapshot
271
272 head_id = get_head_commit_id(repo, "main")
273 assert head_id is not None
274 commit_rec = read_commit(repo, head_id)
275 assert commit_rec is not None
276 snap = read_snapshot(repo, commit_rec.snapshot_id)
277 assert snap is not None
278 overlap = set(snap.manifest) & set(snap.directories)
279 assert not overlap, f"path(s) recorded as both file and directory: {overlap}"
280
281 def test_dir_to_file_and_file_to_dir_transitions_never_overlap(
282 self, repo: pathlib.Path
283 ) -> None:
284 (repo / "path-a").mkdir()
285 (repo / "path-b.txt").write_text("content\n")
286 _run(repo, "code", "add", ".")
287 code, _ = _run(repo, "commit", "-m", "step 1")
288 assert code == 0
289
290 (repo / "path-a").rmdir()
291 (repo / "path-a").write_text("now a file\n")
292 _run(repo, "code", "add", ".")
293 code, _ = _run(repo, "commit", "-m", "step 2: path-a becomes a file")
294 assert code == 0
295
296 from muse.core.refs import get_head_commit_id
297 from muse.core.commits import read_commit
298 from muse.core.snapshots import read_snapshot
299
300 head_id = get_head_commit_id(repo, "main")
301 assert head_id is not None
302 commit_rec = read_commit(repo, head_id)
303 assert commit_rec is not None
304 snap = read_snapshot(repo, commit_rec.snapshot_id)
305 assert snap is not None
306 overlap = set(snap.manifest) & set(snap.directories)
307 assert not overlap, f"path(s) recorded as both file and directory: {overlap}"
308 assert "path-a" in snap.manifest
309 assert "path-a" not in snap.directories
310
311
312 # ===========================================================================
313 # Stage schema v4 migration — pre-v4 entries with no explicit ``kind``
314 # ===========================================================================
315
316
317 class TestStageSchemaV4Migration:
318 """Entries written under the pre-v4 schema (no ``kind`` field) must
319 migrate correctly on read: an on-disk directory path infers
320 ``kind="dir"``, everything else infers ``kind="file"``.
321 """
322
323 def test_legacy_entry_for_existing_dir_migrates_to_dir_kind(
324 self, repo: pathlib.Path
325 ) -> None:
326 from muse.plugins.code.stage import EMPTY_DIR_OID, stage_path
327 import json as _json
328
329 (repo / "legacy-dir").mkdir()
330 legacy_stage = {
331 "version": 3,
332 "entries": {
333 "legacy-dir": {
334 "object_id": EMPTY_DIR_OID,
335 "mode": "A",
336 "staged_at": "2026-01-01T00:00:00+00:00",
337 }
338 },
339 }
340 stage_path(repo).parent.mkdir(parents=True, exist_ok=True)
341 stage_path(repo).write_text(_json.dumps(legacy_stage))
342
343 entries = read_stage(repo)
344 assert entries["legacy-dir"]["kind"] == "dir"
345
346 def test_legacy_entry_for_nonexistent_path_migrates_to_file_kind(
347 self, repo: pathlib.Path
348 ) -> None:
349 from muse.plugins.code.stage import EMPTY_DIR_OID, stage_path
350 import json as _json
351
352 legacy_stage = {
353 "version": 3,
354 "entries": {
355 "legacy-empty-file.txt": {
356 "object_id": EMPTY_DIR_OID,
357 "mode": "A",
358 "staged_at": "2026-01-01T00:00:00+00:00",
359 }
360 },
361 }
362 stage_path(repo).parent.mkdir(parents=True, exist_ok=True)
363 stage_path(repo).write_text(_json.dumps(legacy_stage))
364
365 entries = read_stage(repo)
366 assert entries["legacy-empty-file.txt"]["kind"] == "file"
File History 1 commit
sha256:d2dc1ad1242cf18f8b17e8311abe3fd3f596d4fb73b60e411699e42681a24653 test: add muse#90's exact path-poisoning regression to the … Sonnet 5 16 hours ago