gabriel / muse public
test_mpack_core.py python
350 lines 13.0 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Tests for muse.core.pack — MPackBundle build and apply operations."""
2
3 from __future__ import annotations
4
5 import datetime
6 import json
7 import pathlib
8
9 import pytest
10
11 from muse.core.object_store import has_object, read_object, write_object
12 from muse.core.pack import (
13 ObjectPayload,
14 MPackBundle,
15 apply_mpack,
16 build_mpack,
17 )
18 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
19
20 from muse.core._types import Manifest, blob_id, fake_id
21 from muse.core.store import (
22 CommitRecord,
23 SnapshotRecord,
24 read_commit,
25 read_snapshot,
26 write_commit,
27 write_snapshot,
28 )
29
30
31 # ---------------------------------------------------------------------------
32 # Fixtures
33 # ---------------------------------------------------------------------------
34
35
36 @pytest.fixture
37 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
38 """Minimal .muse/ repo structure."""
39 muse_dir = tmp_path / ".muse"
40 (muse_dir / "commits").mkdir(parents=True)
41 (muse_dir / "snapshots").mkdir(parents=True)
42 (muse_dir / "objects").mkdir(parents=True)
43 (muse_dir / "refs" / "heads").mkdir(parents=True)
44 (muse_dir / "repo.json").write_text(json.dumps({"repo_id": "test-repo"}))
45 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
46 (muse_dir / "refs" / "heads" / "main").write_text("")
47 return tmp_path
48
49
50 def _make_object(root: pathlib.Path, content: bytes) -> str:
51 """Write raw bytes into the object store; return the object_id."""
52 oid = blob_id(content)
53 write_object(root, oid, content)
54 return oid
55
56
57 def _make_snapshot(root: pathlib.Path, manifest: Manifest) -> str:
58 """Write a snapshot with a valid content-hash snapshot_id. Returns the snapshot_id."""
59 snap_id = compute_snapshot_id(manifest)
60 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
61 return snap_id
62
63
64 def _make_commit(
65 root: pathlib.Path,
66 snapshot_id: str,
67 message: str = "test",
68 parent: str | None = None,
69 ) -> str:
70 """Write a commit with a valid content-hash commit_id. Returns the commit_id."""
71 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
72 parent_ids = [parent] if parent else []
73 commit_id = compute_commit_id(
74 repo_id="test-repo",
75 parent_ids=parent_ids,
76 snapshot_id=snapshot_id,
77 message=message,
78 committed_at_iso=committed_at.isoformat(),
79 )
80 c = CommitRecord(
81 commit_id=commit_id,
82 repo_id="test-repo",
83 created_on_branch="main",
84 snapshot_id=snapshot_id,
85 message=message,
86 committed_at=committed_at,
87 parent_commit_id=parent,
88 )
89 write_commit(root, c)
90 return commit_id
91
92
93 # ---------------------------------------------------------------------------
94 # build_mpack tests
95 # ---------------------------------------------------------------------------
96
97
98 class TestBuildPack:
99 def test_single_commit_no_history(self, repo: pathlib.Path) -> None:
100 content = b"hello world"
101 oid = _make_object(repo, content)
102 snap_id = _make_snapshot(repo, {"file.txt": oid})
103 c1_id = _make_commit(repo, snap_id)
104
105 bundle = build_mpack(repo, [c1_id])
106
107 assert len(bundle.get("commits") or []) == 1
108 assert len(bundle.get("snapshots") or []) == 1
109 assert len(bundle.get("objects") or []) == 1
110 assert (bundle.get("objects") or [{}])[0]["object_id"] == oid
111
112 def test_object_content_is_raw_bytes(self, repo: pathlib.Path) -> None:
113 content = b"\x00\x01\x02\x03"
114 oid = _make_object(repo, content)
115 snap_id = _make_snapshot(repo, {"bin.dat": oid})
116 c1_id = _make_commit(repo, snap_id)
117
118 bundle = build_mpack(repo, [c1_id])
119
120 objs = bundle.get("objects") or []
121 assert len(objs) == 1
122 assert objs[0]["content"] == content
123
124 def test_multi_commit_chain(self, repo: pathlib.Path) -> None:
125 oid1 = _make_object(repo, b"v1")
126 oid2 = _make_object(repo, b"v2")
127 snap1_id = _make_snapshot(repo, {"f.txt": oid1})
128 snap2_id = _make_snapshot(repo, {"f.txt": oid2})
129 c1_id = _make_commit(repo, snap1_id)
130 c2_id = _make_commit(repo, snap2_id, parent=c1_id)
131
132 bundle = build_mpack(repo, [c2_id])
133
134 assert len(bundle.get("commits") or []) == 2
135 assert len(bundle.get("snapshots") or []) == 2
136 assert len(bundle.get("objects") or []) == 2
137
138 def test_have_excludes_ancestor_commits(self, repo: pathlib.Path) -> None:
139 oid1 = _make_object(repo, b"v1")
140 oid2 = _make_object(repo, b"v2")
141 snap1_id = _make_snapshot(repo, {"f.txt": oid1})
142 snap2_id = _make_snapshot(repo, {"f.txt": oid2})
143 c1_id = _make_commit(repo, snap1_id)
144 c2_id = _make_commit(repo, snap2_id, parent=c1_id)
145
146 bundle = build_mpack(repo, [c2_id], have=[c1_id])
147
148 # Only c2 should be in the bundle; c1 is in have.
149 commit_ids = [c["commit_id"] for c in (bundle.get("commits") or [])]
150 assert c2_id in commit_ids
151 assert c1_id not in commit_ids
152
153 def test_deduplicates_shared_objects(self, repo: pathlib.Path) -> None:
154 shared_oid = _make_object(repo, b"shared")
155 snap1_id = _make_snapshot(repo, {"a.txt": shared_oid})
156 snap2_id = _make_snapshot(repo, {"b.txt": shared_oid})
157 c1_id = _make_commit(repo, snap1_id)
158 c2_id = _make_commit(repo, snap2_id, parent=c1_id)
159
160 bundle = build_mpack(repo, [c2_id])
161
162 # Shared object should appear only once.
163 object_ids = [o["object_id"] for o in (bundle.get("objects") or [])]
164 assert object_ids.count(shared_oid) == 1
165
166 def test_empty_commit_ids_returns_empty_bundle(self, repo: pathlib.Path) -> None:
167 bundle = build_mpack(repo, [])
168 assert (bundle.get("commits") or []) == []
169 assert (bundle.get("objects") or []) == []
170
171 def test_missing_commit_skipped_gracefully(self, repo: pathlib.Path) -> None:
172 # Should not raise even if a commit_id does not exist.
173 bundle = build_mpack(repo, [fake_id("nonexistent")])
174 assert (bundle.get("commits") or []) == []
175
176 def test_snapshot_always_included_for_every_commit(self, repo: pathlib.Path) -> None:
177 """Every commit in the pack must have its snapshot included.
178
179 This is the data-integrity invariant that prevents the corruption
180 pattern where commits arrive on the remote without their snapshots,
181 making them permanently unreadable after a local .muse wipe.
182 """
183 oid = _make_object(repo, b"content")
184 snap_id = _make_snapshot(repo, {"a.txt": oid})
185 c_id = _make_commit(repo, snap_id)
186
187 bundle = build_mpack(repo, [c_id])
188
189 commit_snap_ids = {c["snapshot_id"] for c in (bundle.get("commits") or [])}
190 bundled_snap_ids = {s["snapshot_id"] for s in (bundle.get("snapshots") or [])}
191
192 assert commit_snap_ids == bundled_snap_ids, (
193 "Every commit's snapshot_id must appear in the bundle's snapshots list"
194 )
195
196 def test_missing_snapshot_raises_not_skips(self, repo: pathlib.Path) -> None:
197 """build_mpack must raise ValueError when a commit's snapshot is absent.
198
199 Silently skipping was the root cause of the recurring snapshot
200 corruption: commits reached the remote without their snapshots, and
201 subsequent pulls restored commits but not snapshots.
202 """
203 # Write commit record directly — no snapshot written
204 import datetime
205 from muse.core.snapshot import compute_commit_id
206 snap_id = "ab" * 32 # valid hex, but no snapshot file exists
207 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
208 c_id = compute_commit_id(
209 repo_id="test-repo",
210 parent_ids=[],
211 snapshot_id=snap_id,
212 message="orphan",
213 committed_at_iso=committed_at.isoformat(),
214 )
215 write_commit(repo, CommitRecord(
216 commit_id=c_id, repo_id="test-repo", created_on_branch="main",
217 snapshot_id=snap_id, message="orphan", committed_at=committed_at,
218 ))
219
220 with pytest.raises(ValueError, match="Push aborted"):
221 build_mpack(repo, [c_id])
222
223 def test_merge_commit_includes_both_parents(self, repo: pathlib.Path) -> None:
224 oid_a = _make_object(repo, b"branch-a")
225 oid_b = _make_object(repo, b"branch-b")
226 snap_a_id = _make_snapshot(repo, {"a.txt": oid_a})
227 snap_b_id = _make_snapshot(repo, {"b.txt": oid_b})
228 snap_m_id = _make_snapshot(repo, {"a.txt": oid_a, "b.txt": oid_b})
229 c_a_id = _make_commit(repo, snap_a_id)
230 c_b_id = _make_commit(repo, snap_b_id)
231 # Merge commit with two parents — compute its ID from both parent hashes.
232 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
233 c_merge_id = compute_commit_id(
234 repo_id="test-repo",
235 parent_ids=[c_a_id, c_b_id],
236 snapshot_id=snap_m_id,
237 message="merge",
238 committed_at_iso=committed_at.isoformat(),
239 )
240 c_merge = CommitRecord(
241 commit_id=c_merge_id,
242 repo_id="test-repo",
243 created_on_branch="main",
244 snapshot_id=snap_m_id,
245 message="merge",
246 committed_at=committed_at,
247 parent_commit_id=c_a_id,
248 parent2_commit_id=c_b_id,
249 )
250 write_commit(repo, c_merge)
251
252 bundle = build_mpack(repo, [c_merge_id])
253 commit_ids = {c["commit_id"] for c in (bundle.get("commits") or [])}
254 assert {c_merge_id, c_a_id, c_b_id}.issubset(commit_ids)
255
256
257 # ---------------------------------------------------------------------------
258 # apply_mpack tests
259 # ---------------------------------------------------------------------------
260
261
262 class TestApplyPack:
263 def test_round_trip(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
264 """build_mpack → apply_mpack in a fresh repo produces identical data."""
265 content = b"round trip"
266 oid = _make_object(repo, content)
267 snap_id = _make_snapshot(repo, {"f.txt": oid})
268 c1_id = _make_commit(repo, snap_id, message="initial")
269
270 bundle = build_mpack(repo, [c1_id])
271
272 # Apply into a fresh repo.
273 dest = tmp_path / "dest"
274 muse_dir = dest / ".muse"
275 (muse_dir / "commits").mkdir(parents=True)
276 (muse_dir / "snapshots").mkdir(parents=True)
277 (muse_dir / "objects").mkdir(parents=True)
278
279 result = apply_mpack(dest, bundle)
280
281 assert result["objects_written"] == 1
282 assert has_object(dest, oid)
283 assert read_object(dest, oid) == content
284 assert read_snapshot(dest, snap_id) is not None
285 assert read_commit(dest, c1_id) is not None
286
287 def test_idempotent_apply(self, repo: pathlib.Path) -> None:
288 """Applying the same bundle twice does not raise and new_count = 0."""
289 content = b"idempotent"
290 oid = _make_object(repo, content)
291 snap_id = _make_snapshot(repo, {"f.txt": oid})
292 c1_id = _make_commit(repo, snap_id)
293
294 bundle = build_mpack(repo, [c1_id])
295 apply_mpack(repo, bundle)
296 result = apply_mpack(repo, bundle)
297
298 assert result["objects_written"] == 0 # All already present.
299
300 def test_malformed_object_skipped(self, repo: pathlib.Path) -> None:
301 # content must be bytes; passing wrong type is caught gracefully
302 bundle: MPackBundle = {
303 "commits": [],
304 "snapshots": [],
305 "objects": [ObjectPayload(object_id="abc123", content=b"")],
306 }
307 result = apply_mpack(repo, bundle)
308 assert result["objects_written"] == 0
309
310 def test_empty_bundle_is_noop(self, repo: pathlib.Path) -> None:
311 bundle: MPackBundle = {}
312 result = apply_mpack(repo, bundle)
313 assert result["objects_written"] == 0
314
315 def test_apply_preserves_commit_metadata(
316 self, repo: pathlib.Path, tmp_path: pathlib.Path
317 ) -> None:
318 oid = _make_object(repo, b"data")
319 snap_id = _make_snapshot(repo, {"data.bin": oid})
320 c1_id = _make_commit(repo, snap_id, message="preserve me")
321
322 bundle = build_mpack(repo, [c1_id])
323
324 dest = tmp_path / "d"
325 (dest / ".muse" / "commits").mkdir(parents=True)
326 (dest / ".muse" / "snapshots").mkdir(parents=True)
327 (dest / ".muse" / "objects").mkdir(parents=True)
328 apply_mpack(dest, bundle)
329
330 commit = read_commit(dest, c1_id)
331 assert commit is not None
332 assert commit.message == "preserve me"
333 assert commit.snapshot_id == snap_id
334
335 def test_apply_returns_new_object_count(
336 self, repo: pathlib.Path, tmp_path: pathlib.Path
337 ) -> None:
338 oid1 = _make_object(repo, b"obj1")
339 oid2 = _make_object(repo, b"obj2")
340 snap_id = _make_snapshot(repo, {"a": oid1, "b": oid2})
341 c1_id = _make_commit(repo, snap_id)
342
343 bundle = build_mpack(repo, [c1_id])
344 dest = tmp_path / "d"
345 (dest / ".muse" / "commits").mkdir(parents=True)
346 (dest / ".muse" / "snapshots").mkdir(parents=True)
347 (dest / ".muse" / "objects").mkdir(parents=True)
348
349 result = apply_mpack(dest, bundle)
350 assert result["objects_written"] == 2
File History 2 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