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