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