gabriel / muse public
test_core_coverage_gaps.py python
509 lines 20.0 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 124 days ago
1 """Tests targeting coverage gaps in core modules: object_store, repo, store, merge_engine."""
2
3 import json
4 import os
5 import pathlib
6
7 import pytest
8
9 from muse.core.types import NULL_LONG_ID, blob_id, fake_id, long_id
10 from muse.core.object_store import (
11 has_object,
12 object_path,
13 objects_dir,
14 read_object,
15 restore_object,
16 write_object,
17 write_object_from_path,
18 )
19 from muse.core.repo import find_repo_root, require_repo
20 from muse.core.store import (
21 CommitRecord,
22 SnapshotRecord,
23 get_commits_for_branch,
24 get_head_commit_id,
25 get_head_snapshot_id,
26 get_head_snapshot_manifest,
27 get_tags_for_commit,
28 read_commit,
29 read_snapshot,
30 resolve_commit_ref,
31 update_commit_metadata,
32 write_commit,
33 write_snapshot,
34 )
35 from muse.core.merge_engine import apply_resolution, clear_merge_state, read_merge_state, write_merge_state
36 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
37 from muse.core.types import Manifest
38 from muse.core.paths import heads_dir, merge_state_path, muse_dir, objects_dir
39
40 import datetime
41
42
43 # ---------------------------------------------------------------------------
44 # object_store
45 # ---------------------------------------------------------------------------
46
47
48 class TestObjectStore:
49 def test_objects_dir_path(self, tmp_path: pathlib.Path) -> None:
50 d = objects_dir(tmp_path)
51 assert d == objects_dir(tmp_path)
52
53 def test_object_path_sharding(self, tmp_path: pathlib.Path) -> None:
54 oid = long_id(f"ab{'c' * 62}")
55 p = object_path(tmp_path, oid)
56 assert p.parent.name == "ab"
57 assert p.name == "c" * 62
58
59 def test_has_object_false_when_absent(self, tmp_path: pathlib.Path) -> None:
60 assert not has_object(tmp_path, long_id("a" * 64))
61
62 def test_has_object_true_after_write(self, tmp_path: pathlib.Path) -> None:
63 content = b"hello"
64 oid = blob_id(content)
65 write_object(tmp_path, oid, content)
66 assert has_object(tmp_path, oid)
67
68 def test_write_object_idempotent_returns_false(self, tmp_path: pathlib.Path) -> None:
69 content = b"first"
70 oid = blob_id(content)
71 assert write_object(tmp_path, oid, content) is True
72 # Second write with correct hash but same ID — idempotent
73 assert write_object(tmp_path, oid, content) is False
74 # content should not change
75 assert read_object(tmp_path, oid) == content
76
77 def test_write_object_from_path_idempotent(self, tmp_path: pathlib.Path) -> None:
78 content = b"content"
79 src = tmp_path / "src.bin"
80 src.write_bytes(content)
81 oid = blob_id(content)
82 assert write_object_from_path(tmp_path, oid, src) is True
83 assert write_object_from_path(tmp_path, oid, src) is False
84
85 def test_write_object_from_path_stores_content(self, tmp_path: pathlib.Path) -> None:
86 content = b"my bytes"
87 src = tmp_path / "file.bin"
88 src.write_bytes(content)
89 oid = blob_id(content)
90 write_object_from_path(tmp_path, oid, src)
91 assert read_object(tmp_path, oid) == content
92
93 def test_read_object_returns_none_when_absent(self, tmp_path: pathlib.Path) -> None:
94 assert read_object(tmp_path, long_id("e" * 64)) is None
95
96 def test_read_object_returns_bytes(self, tmp_path: pathlib.Path) -> None:
97 content = b"data"
98 oid = blob_id(content)
99 write_object(tmp_path, oid, content)
100 assert read_object(tmp_path, oid) == content
101
102 def test_restore_object_returns_false_when_absent(self, tmp_path: pathlib.Path) -> None:
103 dest = tmp_path / "out.bin"
104 result = restore_object(tmp_path, NULL_LONG_ID, dest)
105 assert result is False
106 assert not dest.exists()
107
108 def test_restore_object_creates_dest(self, tmp_path: pathlib.Path) -> None:
109 content = b"restored"
110 oid = blob_id(content)
111 write_object(tmp_path, oid, content)
112 dest = tmp_path / "sub" / "out.bin"
113 result = restore_object(tmp_path, oid, dest)
114 assert result is True
115 assert dest.read_bytes() == content
116
117 def test_restore_object_creates_parent_dirs(self, tmp_path: pathlib.Path) -> None:
118 content = b"nested"
119 oid = blob_id(content)
120 write_object(tmp_path, oid, content)
121 dest = tmp_path / "a" / "b" / "c" / "file.bin"
122 restore_object(tmp_path, oid, dest)
123 assert dest.exists()
124
125
126 class TestRestoreObjectIdempotency:
127 """restore_object must preserve the destination inode when content matches.
128
129 The ``os.replace`` rename syscall always produces a new inode. Editors
130 (Cursor, VS Code, Vim, …) use inode-based filesystem-event watchers; a
131 spurious rename blinds them to subsequent changes, leaving permanently stale
132 buffers. The fix: hash-check dest before writing — if bytes already match
133 the requested object_id, return without touching the file.
134
135 These tests are the regression gate for that fix. They prove:
136
137 1. When dest already has the correct content the inode is preserved.
138 2. When dest has *different* content the file is replaced (inode changes).
139 3. When dest does not yet exist the write proceeds normally.
140 4. A checkout-style simulation: many files, only changed ones get new inodes.
141 """
142
143 def test_inode_preserved_when_content_matches(self, tmp_path: pathlib.Path) -> None:
144 """Core regression: restore_object must NOT rename when content is correct."""
145 content = b"editor-watching-this-file"
146 oid = blob_id(content)
147 write_object(tmp_path, oid, content)
148
149 dest = tmp_path / "file.txt"
150 dest.write_bytes(content)
151 inode_before = dest.stat().st_ino
152
153 result = restore_object(tmp_path, oid, dest)
154
155 assert result is True
156 assert dest.read_bytes() == content
157 # The inode must not change — a rename would produce a new inode and
158 # blind any editor that was watching the original file descriptor.
159 assert dest.stat().st_ino == inode_before, (
160 "restore_object issued a spurious rename even though dest already "
161 "contained the correct content — this blinds inode-watching editors"
162 )
163
164 def test_mtime_preserved_when_content_matches(self, tmp_path: pathlib.Path) -> None:
165 """mtime stability: no spurious write means no mtime bump."""
166 content = b"stable-mtime-check"
167 oid = blob_id(content)
168 write_object(tmp_path, oid, content)
169
170 dest = tmp_path / "file.txt"
171 dest.write_bytes(content)
172 mtime_ns_before = dest.stat().st_mtime_ns
173
174 restore_object(tmp_path, oid, dest)
175
176 assert dest.stat().st_mtime_ns == mtime_ns_before, (
177 "restore_object bumped mtime even though content was already correct"
178 )
179
180 def test_inode_changes_when_content_differs(self, tmp_path: pathlib.Path) -> None:
181 """When dest has wrong content the file must be replaced."""
182 correct = b"correct-content"
183 wrong = b"wrong-content-different-bytes"
184 oid = blob_id(correct)
185 write_object(tmp_path, oid, correct)
186
187 dest = tmp_path / "file.txt"
188 dest.write_bytes(wrong)
189 inode_before = dest.stat().st_ino
190
191 result = restore_object(tmp_path, oid, dest)
192
193 assert result is True
194 assert dest.read_bytes() == correct
195 # Content changed — a rename is expected; the inode must be different.
196 assert dest.stat().st_ino != inode_before
197
198 def test_idempotent_on_fresh_file(self, tmp_path: pathlib.Path) -> None:
199 """When dest does not yet exist the write proceeds normally."""
200 content = b"brand-new-file"
201 oid = blob_id(content)
202 write_object(tmp_path, oid, content)
203
204 dest = tmp_path / "new.txt"
205 assert not dest.exists()
206
207 result = restore_object(tmp_path, oid, dest)
208
209 assert result is True
210 assert dest.read_bytes() == content
211
212 def test_second_restore_is_truly_noop(self, tmp_path: pathlib.Path) -> None:
213 """Calling restore_object twice leaves the file and inode unchanged."""
214 content = b"idempotent-restore"
215 oid = blob_id(content)
216 write_object(tmp_path, oid, content)
217
218 dest = tmp_path / "file.txt"
219 restore_object(tmp_path, oid, dest) # first call — writes the file
220 inode_first = dest.stat().st_ino
221 mtime_first = dest.stat().st_mtime_ns
222
223 restore_object(tmp_path, oid, dest) # second call — must be a no-op
224
225 assert dest.stat().st_ino == inode_first
226 assert dest.stat().st_mtime_ns == mtime_first
227 assert dest.read_bytes() == content
228
229 def test_checkout_simulation_only_changed_files_renamed(
230 self, tmp_path: pathlib.Path
231 ) -> None:
232 """Simulate a branch checkout: only files that changed get new inodes.
233
234 This is the end-to-end scenario that caused the Cursor stale-buffer bug:
235 a ``muse checkout`` that touches N files would rename ALL of them even
236 when most were identical on both branches. After the fix, only the
237 genuinely changed file gets a new inode.
238 """
239 unchanged_content = b"I am the same on both branches"
240 changed_old = b"old branch content"
241 changed_new = b"new branch content"
242
243 unchanged_oid = blob_id(unchanged_content)
244 changed_oid = blob_id(changed_new)
245
246 write_object(tmp_path, unchanged_oid, unchanged_content)
247 write_object(tmp_path, changed_oid, changed_new)
248
249 unchanged_dest = tmp_path / "unchanged.py"
250 changed_dest = tmp_path / "changed.py"
251
252 # Simulate working tree before checkout
253 unchanged_dest.write_bytes(unchanged_content)
254 changed_dest.write_bytes(changed_old)
255
256 inode_unchanged_before = unchanged_dest.stat().st_ino
257 inode_changed_before = changed_dest.stat().st_ino
258
259 # Simulate _checkout_snapshot restoring both files
260 restore_object(tmp_path, unchanged_oid, unchanged_dest)
261 restore_object(tmp_path, changed_oid, changed_dest)
262
263 # unchanged file: inode must be preserved (no rename)
264 assert unchanged_dest.stat().st_ino == inode_unchanged_before, (
265 "unchanged file got a new inode — editor watching it would go blind"
266 )
267 # changed file: inode should differ (content replaced)
268 assert changed_dest.stat().st_ino != inode_changed_before
269 assert changed_dest.read_bytes() == changed_new
270
271
272 # ---------------------------------------------------------------------------
273 # repo
274 # ---------------------------------------------------------------------------
275
276
277 class TestFindRepoRoot:
278 def test_finds_muse_dir_in_cwd(self, tmp_path: pathlib.Path) -> None:
279 muse_dir(tmp_path).mkdir()
280 result = find_repo_root(tmp_path)
281 assert result == tmp_path
282
283 def test_finds_muse_dir_in_parent(self, tmp_path: pathlib.Path) -> None:
284 muse_dir(tmp_path).mkdir()
285 subdir = tmp_path / "a" / "b"
286 subdir.mkdir(parents=True)
287 result = find_repo_root(subdir)
288 assert result == tmp_path
289
290 def test_returns_none_when_no_repo(self, tmp_path: pathlib.Path) -> None:
291 result = find_repo_root(tmp_path)
292 assert result is None
293
294 def test_env_override_returns_path(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
295 muse_dir(tmp_path).mkdir()
296 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
297 result = find_repo_root()
298 assert result == tmp_path
299
300 def test_env_override_returns_none_when_not_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
301 # tmp_path exists but has no .muse/
302 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
303 result = find_repo_root()
304 assert result is None
305
306 def test_require_repo_exits_when_no_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
307 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
308 monkeypatch.chdir(tmp_path)
309 with pytest.raises(SystemExit):
310 require_repo()
311
312
313 # ---------------------------------------------------------------------------
314 # store coverage gaps
315 # ---------------------------------------------------------------------------
316
317
318 class TestStoreGaps:
319 def _make_repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
320 muse = muse_dir(tmp_path)
321 for d in ("commits", "snapshots", "objects", "refs/heads"):
322 (muse / d).mkdir(parents=True)
323 (muse / "HEAD").write_text("ref: refs/heads/main\n")
324 (muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo"}))
325 (muse / "refs" / "heads" / "main").write_text("")
326 return tmp_path
327
328 def test_get_head_commit_id_empty_branch(self, tmp_path: pathlib.Path) -> None:
329 root = self._make_repo(tmp_path)
330 assert get_head_commit_id(root, "main") is None
331
332 def test_get_head_snapshot_id_no_commits(self, tmp_path: pathlib.Path) -> None:
333 root = self._make_repo(tmp_path)
334 assert get_head_snapshot_id(root, "test-repo", "main") is None
335
336 def test_get_head_snapshot_manifest_no_commits(self, tmp_path: pathlib.Path) -> None:
337 root = self._make_repo(tmp_path)
338 assert get_head_snapshot_manifest(root, "test-repo", "main") is None
339
340 def test_get_commits_for_branch_empty(self, tmp_path: pathlib.Path) -> None:
341 root = self._make_repo(tmp_path)
342 commits = get_commits_for_branch(root, "test-repo", "main")
343 assert commits == []
344
345 def _seed_chain(self, root: pathlib.Path, n: int) -> list[str]:
346 """Write a linear chain of *n* commits on ``main`` and return their IDs (newest first)."""
347 ids: list[str] = []
348 parent_id: str | None = None
349 manifest: Manifest = {}
350 snap_id = compute_snapshot_id(manifest)
351 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
352 for i in range(n):
353 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) + datetime.timedelta(hours=i)
354 message = f"commit {i}"
355 parent_ids = [parent_id] if parent_id else []
356 commit_id = compute_commit_id(
357 parent_ids=parent_ids,
358 snapshot_id=snap_id,
359 message=message,
360 committed_at_iso=committed_at.isoformat(),
361 )
362 commit = CommitRecord(
363 repo_id="test-repo",
364 commit_id=commit_id,
365 branch="main",
366 snapshot_id=snap_id,
367 message=message,
368 committed_at=committed_at,
369 parent_commit_id=parent_id,
370 )
371 write_commit(root, commit)
372 ids.append(commit_id)
373 parent_id = commit_id
374 # HEAD points at the last (newest) commit
375 (heads_dir(root) / "main").write_text(ids[-1])
376 ids.reverse() # newest first, matching get_commits_for_branch order
377 return ids
378
379 def test_get_commits_for_branch_max_count_stops_early(
380 self, tmp_path: pathlib.Path
381 ) -> None:
382 """max_count caps the walk — only that many commits are returned."""
383 root = self._make_repo(tmp_path)
384 all_ids = self._seed_chain(root, 5)
385
386 result = get_commits_for_branch(root, "test-repo", "main", max_count=2)
387 assert len(result) == 2
388 assert result[0].commit_id == all_ids[0]
389 assert result[1].commit_id == all_ids[1]
390
391 def test_get_commits_for_branch_max_count_zero_returns_all(
392 self, tmp_path: pathlib.Path
393 ) -> None:
394 """max_count=0 (the default) returns the full chain."""
395 root = self._make_repo(tmp_path)
396 all_ids = self._seed_chain(root, 5)
397
398 result = get_commits_for_branch(root, "test-repo", "main", max_count=0)
399 assert len(result) == 5
400 assert [c.commit_id for c in result] == all_ids
401
402 def test_get_commits_for_branch_max_count_larger_than_chain(
403 self, tmp_path: pathlib.Path
404 ) -> None:
405 """max_count larger than the chain length returns every commit without error."""
406 root = self._make_repo(tmp_path)
407 all_ids = self._seed_chain(root, 3)
408
409 result = get_commits_for_branch(root, "test-repo", "main", max_count=100)
410 assert len(result) == 3
411 assert [c.commit_id for c in result] == all_ids
412
413 def test_resolve_commit_ref_with_none_returns_head(self, tmp_path: pathlib.Path) -> None:
414 root = self._make_repo(tmp_path)
415 manifest: Manifest = {"a.mid": fake_id("a.mid-content")}
416 snap_id = compute_snapshot_id(manifest)
417 snap = SnapshotRecord(snapshot_id=snap_id, manifest=manifest)
418 write_snapshot(root, snap)
419 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
420 commit_id = compute_commit_id(
421 parent_ids=[],
422 snapshot_id=snap_id,
423 message="test",
424 committed_at_iso=committed_at.isoformat(),
425 )
426 commit = CommitRecord(
427 repo_id="test-repo",
428 commit_id=commit_id,
429 branch="main",
430 snapshot_id=snap_id,
431 message="test",
432 committed_at=committed_at,
433 )
434 write_commit(root, commit)
435 (heads_dir(root) / "main").write_text(commit_id)
436
437 result = resolve_commit_ref(root, "test-repo", "main", None)
438 assert result is not None
439 assert result.commit_id == commit_id
440
441 def test_read_commit_returns_none_for_unknown(self, tmp_path: pathlib.Path) -> None:
442 root = self._make_repo(tmp_path)
443 assert read_commit(root, long_id("a" * 64)) is None
444
445 def test_read_snapshot_returns_none_for_unknown(self, tmp_path: pathlib.Path) -> None:
446 root = self._make_repo(tmp_path)
447 assert read_snapshot(root, long_id("b" * 64)) is None
448
449 def test_update_commit_metadata_false_for_unknown(self, tmp_path: pathlib.Path) -> None:
450 root = self._make_repo(tmp_path)
451 assert update_commit_metadata(root, long_id("c" * 64), "key", "val") is False
452
453 def test_get_tags_for_commit_empty(self, tmp_path: pathlib.Path) -> None:
454 root = self._make_repo(tmp_path)
455 tags = get_tags_for_commit(root, long_id("d" * 64), long_id("c" * 64))
456 assert tags == []
457
458
459 # ---------------------------------------------------------------------------
460 # merge_engine coverage gaps
461 # ---------------------------------------------------------------------------
462
463
464 class TestMergeEngineCoverageGaps:
465 def _make_repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
466 muse = muse_dir(tmp_path)
467 muse.mkdir(parents=True)
468 return tmp_path
469
470 def test_clear_merge_state_no_file(self, tmp_path: pathlib.Path) -> None:
471 root = self._make_repo(tmp_path)
472 # Should not raise even if MERGE_STATE.json is absent
473 clear_merge_state(root)
474
475 def test_apply_resolution_copies_object(self, tmp_path: pathlib.Path) -> None:
476 root = self._make_repo(tmp_path)
477 # Write a real object to the store — oid must be the SHA-256 of the content.
478 content = b"resolved content"
479 oid = blob_id(content)
480 write_object(root, oid, content)
481
482 apply_resolution(root, "track.mid", oid)
483 dest = root / "track.mid"
484 assert dest.exists()
485 assert dest.read_bytes() == b"resolved content"
486
487 def test_apply_resolution_raises_when_object_absent(self, tmp_path: pathlib.Path) -> None:
488 root = self._make_repo(tmp_path)
489 with pytest.raises(FileNotFoundError):
490 apply_resolution(root, "track.mid", NULL_LONG_ID)
491
492 def test_read_merge_state_invalid_json_returns_none(self, tmp_path: pathlib.Path) -> None:
493 root = self._make_repo(tmp_path)
494 (merge_state_path(root)).write_text("not json {{")
495 result = read_merge_state(root)
496 assert result is None
497
498 def test_write_then_clear_merge_state(self, tmp_path: pathlib.Path) -> None:
499 root = self._make_repo(tmp_path)
500 write_merge_state(
501 root,
502 base_commit="b" * 64,
503 ours_commit="o" * 64,
504 theirs_commit="t" * 64,
505 conflict_paths=["a.mid"],
506 )
507 assert (merge_state_path(root)).exists()
508 clear_merge_state(root)
509 assert not (merge_state_path(root)).exists()
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 124 days ago