gabriel / muse public
test_mpack_bundle.py python
411 lines 16.2 KB
Raw
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago
1 """Tests for MPack — the MPack wire format replacing PackBundle.
2
3 Coverage tiers
4 --------------
5 - MPack TypedDict: fields, total=False (partial bundles valid)
6 - MPackSummary TypedDict: all advisory summary fields
7 - build_mpack: produces MPack with commits, snapshots, objects
8 - build_mpack: have-set exclusion (only sends delta)
9 - build_mpack: summary field populated correctly
10 - build_mpack: raises on missing snapshot
11 - apply_mpack: writes objects → snapshots → commits in order
12 - apply_mpack: idempotent (safe to call twice)
13 - apply_mpack: pack-bomb guard respected
14 - apply_mpack: returns MPackApplyResult with counts
15 - Round-trip: build_mpack → apply_mpack recovers all data
16 - MPack replaces PackBundle everywhere — PackBundle no longer exists
17 - Phase 2 — MPackMeta: mode, base_commits, created_at always present
18 """
19 from __future__ import annotations
20 from collections.abc import Mapping
21
22 import datetime
23 import json
24 import pathlib
25
26 import pytest
27
28 from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id
29 from muse.core.commits import (
30 CommitRecord,
31 write_commit,
32 )
33 from muse.core.snapshots import (
34 SnapshotRecord,
35 write_snapshot,
36 )
37 from muse.core.object_store import object_path, write_object
38 from muse.core.types import blob_id, long_id
39 from muse.core.paths import heads_dir, muse_dir, snapshots_dir
40 from muse.core.commits import read_commit
41
42 _DT = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
43
44
45
46
47 def _init_repo(tmp_path: pathlib.Path) -> pathlib.Path:
48 muse = muse_dir(tmp_path)
49 for d in ("commits", "snapshots", "objects", "refs/heads"):
50 (muse / d).mkdir(parents=True, exist_ok=True)
51 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
52 (muse / "repo.json").write_text(
53 json.dumps({"repo_id": "mpack-mpack-test", "domain": "code"}),
54 encoding="utf-8",
55 )
56 return tmp_path
57
58
59 def _commit(root: pathlib.Path, files: Mapping[str, bytes]) -> str:
60 manifest = {}
61 for path, content in files.items():
62 oid = blob_id(content)
63 write_object(root, oid, content)
64 manifest[path] = oid
65 snap_id = compute_snapshot_id(manifest)
66 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest, created_at=_DT))
67 parent_ref = heads_dir(root) / "main"
68 parent = parent_ref.read_text().strip() if parent_ref.exists() else None
69 parent_ids = [parent] if parent else []
70 cid = compute_commit_id( parent_ids=parent_ids,
71 snapshot_id=snap_id,
72 message="test",
73 committed_at_iso=_DT.isoformat(),
74 )
75 write_commit(root, CommitRecord(
76 commit_id=cid, branch="main",
77 snapshot_id=snap_id, message="test", committed_at=_DT,
78 parent_commit_id=parent,
79 ))
80 parent_ref.write_text(cid, encoding="utf-8")
81 return cid
82
83
84 # ---------------------------------------------------------------------------
85 # MPack TypedDict
86 # ---------------------------------------------------------------------------
87
88
89 class TestMPackBundleTypedDict:
90 def test_exists(self) -> None:
91 from muse.core.mpack import MPack
92 assert MPack is not None
93
94 def test_has_commits_field(self) -> None:
95 from muse.core.mpack import MPack
96 from typing import get_type_hints
97 hints = get_type_hints(MPack)
98 assert "commits" in hints
99
100 def test_has_snapshots_field(self) -> None:
101 from muse.core.mpack import MPack
102 from typing import get_type_hints
103 hints = get_type_hints(MPack)
104 assert "snapshots" in hints
105
106 def test_has_objects_field(self) -> None:
107 from muse.core.mpack import MPack
108 from typing import get_type_hints
109 hints = get_type_hints(MPack)
110 assert "blobs" in hints
111
112 def test_has_tags_field(self) -> None:
113 from muse.core.mpack import MPack
114 from typing import get_type_hints
115 hints = get_type_hints(MPack)
116 assert "tags" in hints
117
118 def test_pack_bundle_no_longer_exists(self) -> None:
119 """PackBundle name is gone — MPack is the only public type."""
120 with pytest.raises(ImportError):
121 from muse.core.mpack import PackBundle # noqa: F401
122
123
124 # ---------------------------------------------------------------------------
125 # MPackSummary TypedDict
126 # ---------------------------------------------------------------------------
127
128
129 class TestMPackSummary:
130 def test_exists(self) -> None:
131 from muse.core.mpack import MPackSummary
132 assert MPackSummary is not None
133
134 def test_has_required_advisory_fields(self) -> None:
135 from muse.core.mpack import MPackSummary
136 from typing import get_type_hints
137 hints = get_type_hints(MPackSummary)
138 for field in ("commits_count", "blobs_count", "blobs_bytes", "agent_ids"):
139 assert field in hints, f"MPackSummary missing field {field!r}"
140
141 def test_has_branch_fields(self) -> None:
142 from muse.core.mpack import MPackSummary
143 from typing import get_type_hints
144 hints = get_type_hints(MPackSummary)
145 assert "branches" in hints
146
147
148 # ---------------------------------------------------------------------------
149 # build_mpack
150 # ---------------------------------------------------------------------------
151
152
153 class TestBuildMpack:
154 def test_single_commit(self, tmp_path: pathlib.Path) -> None:
155 from muse.core.mpack import build_mpack
156 root = _init_repo(tmp_path)
157 cid = _commit(root, {"a.py": b"# a\n"})
158 mpack = build_mpack(root, [cid])
159 assert len(mpack["commits"]) == 1
160 assert len(mpack["snapshots"]) >= 1
161 assert len(mpack["blobs"]) >= 1
162
163 def test_have_exclusion(self, tmp_path: pathlib.Path) -> None:
164 from muse.core.mpack import build_mpack
165 root = _init_repo(tmp_path)
166 c1 = _commit(root, {"a.py": b"# a\n"})
167 c2 = _commit(root, {"b.py": b"# b\n"})
168 mpack = build_mpack(root, [c2], have=[c1])
169 commit_ids = [c["commit_id"] for c in mpack["commits"]]
170 assert c2 in commit_ids
171 assert c1 not in commit_ids
172
173 def test_raises_on_missing_snapshot(self, tmp_path: pathlib.Path) -> None:
174 from muse.core.mpack import build_mpack
175 root = _init_repo(tmp_path)
176 cid = _commit(root, {"a.py": b"# a\n"})
177 # Delete the snapshot from the unified object store to simulate corruption
178 rec = read_commit(root, cid)
179 assert rec is not None
180 snap_file = object_path(root, rec.snapshot_id)
181 if snap_file.exists():
182 snap_file.unlink()
183 with pytest.raises(ValueError, match="snapshot"):
184 build_mpack(root, [cid])
185
186 def test_empty_commit_ids_returns_empty_bundle(self, tmp_path: pathlib.Path) -> None:
187 from muse.core.mpack import build_mpack
188 root = _init_repo(tmp_path)
189 mpack = build_mpack(root, [])
190 assert mpack.get("commits", []) == []
191 assert mpack.get("blobs", []) == []
192
193 def test_objects_have_sha256_prefixed_ids(self, tmp_path: pathlib.Path) -> None:
194 from muse.core.mpack import build_mpack
195 root = _init_repo(tmp_path)
196 cid = _commit(root, {"main.py": b"print('hello')\n"})
197 mpack = build_mpack(root, [cid])
198 for obj in mpack["blobs"]:
199 assert obj["object_id"].startswith("sha256:"), (
200 f"object_id not sha256-prefixed: {obj['object_id']!r}"
201 )
202
203 def test_multi_commit_chain(self, tmp_path: pathlib.Path) -> None:
204 from muse.core.mpack import build_mpack
205 root = _init_repo(tmp_path)
206 c1 = _commit(root, {"a.py": b"v1\n"})
207 c2 = _commit(root, {"a.py": b"v2\n"})
208 c3 = _commit(root, {"a.py": b"v3\n"})
209 mpack = build_mpack(root, [c3])
210 commit_ids = {c["commit_id"] for c in mpack["commits"]}
211 assert c1 in commit_ids
212 assert c2 in commit_ids
213 assert c3 in commit_ids
214
215 def test_summary_populated(self, tmp_path: pathlib.Path) -> None:
216 from muse.core.mpack import build_mpack
217 root = _init_repo(tmp_path)
218 _commit(root, {"a.py": b"# a\n"})
219 cid = _commit(root, {"b.py": b"# b\n"})
220 mpack = build_mpack(root, [cid])
221 summary = mpack.get("summary")
222 assert summary is not None
223 assert summary["commits_count"] >= 1
224 assert summary["blobs_count"] >= 1
225 assert summary["blobs_bytes"] >= 0
226
227
228 # ---------------------------------------------------------------------------
229 # apply_mpack
230 # ---------------------------------------------------------------------------
231
232
233 class TestApplyMpack:
234 def test_round_trip(self, tmp_path: pathlib.Path) -> None:
235 from muse.core.mpack import build_mpack, apply_mpack
236 src = _init_repo(tmp_path / "src")
237 dst = _init_repo(tmp_path / "dst")
238 cid = _commit(src, {"a.py": b"# hello\n"})
239 mpack = build_mpack(src, [cid])
240 result = apply_mpack(dst, mpack)
241 assert result["commits_written"] >= 1
242 assert result["blobs_written"] >= 1
243
244 def test_idempotent(self, tmp_path: pathlib.Path) -> None:
245 from muse.core.mpack import build_mpack, apply_mpack
246 root = _init_repo(tmp_path)
247 cid = _commit(root, {"x.py": b"# x\n"})
248 mpack = build_mpack(root, [cid])
249 apply_mpack(root, mpack)
250 result2 = apply_mpack(root, mpack)
251 assert result2["commits_written"] == 0
252 assert result2["blobs_skipped"] >= 1
253
254 def test_empty_bundle_is_noop(self, tmp_path: pathlib.Path) -> None:
255 from muse.core.mpack import MPack, apply_mpack
256 root = _init_repo(tmp_path)
257 empty: MPack = {}
258 result = apply_mpack(root, empty)
259 assert result["commits_written"] == 0
260 assert result["blobs_written"] == 0
261
262 def test_pack_bomb_rejected(self, tmp_path: pathlib.Path) -> None:
263 from muse.core.mpack import MPack, apply_mpack
264 root = _init_repo(tmp_path)
265 # Create a mpack claiming 100k objects (far exceeds MAX_PACK_OBJECTS)
266 fake_objects = [{"object_id": long_id('a'*64), "content": b"x"}] * 100_001
267 mpack: MPack = {"blobs": fake_objects} # type: ignore[typeddict-item]
268 with pytest.raises(ValueError, match="limit"):
269 apply_mpack(root, mpack)
270
271 def test_returns_apply_result(self, tmp_path: pathlib.Path) -> None:
272 from muse.core.mpack import build_mpack, apply_mpack
273 src = _init_repo(tmp_path / "src")
274 dst = _init_repo(tmp_path / "dst")
275 cid = _commit(src, {"f.py": b"# f\n"})
276 mpack = build_mpack(src, [cid])
277 result = apply_mpack(dst, mpack)
278 for field in ("commits_written", "snapshots_written", "blobs_written", "blobs_skipped"):
279 assert field in result, f"apply_mpack result missing {field!r}"
280
281
282 # ---------------------------------------------------------------------------
283 # Phase 2 — MPackMeta
284 # ---------------------------------------------------------------------------
285
286 class TestMPackMeta:
287 """build_mpack always writes a self-describing ``meta`` field."""
288
289 # -----------------------------------------------------------------
290 # MPackMeta TypedDict exists and is annotated
291 # -----------------------------------------------------------------
292
293 def test_mpack_meta_typeddict_exists(self) -> None:
294 from muse.core.mpack import MPackMeta
295 assert MPackMeta is not None
296
297 def test_mpack_meta_has_mode_annotation(self) -> None:
298 from muse.core.mpack import MPackMeta
299 from typing import get_type_hints
300 hints = get_type_hints(MPackMeta)
301 assert "mode" in hints
302
303 def test_mpack_meta_has_base_commits_annotation(self) -> None:
304 from muse.core.mpack import MPackMeta
305 from typing import get_type_hints
306 hints = get_type_hints(MPackMeta)
307 assert "base_commits" in hints
308
309 def test_mpack_meta_has_created_at_annotation(self) -> None:
310 from muse.core.mpack import MPackMeta
311 from typing import get_type_hints
312 hints = get_type_hints(MPackMeta)
313 assert "created_at" in hints
314
315 def test_mpack_bundle_has_meta_field(self) -> None:
316 from muse.core.mpack import MPack
317 from typing import get_type_hints
318 hints = get_type_hints(MPack)
319 assert "meta" in hints
320
321 # -----------------------------------------------------------------
322 # Full mpack (no have) — mode == "full", base_commits == []
323 # -----------------------------------------------------------------
324
325 def test_full_mpack_has_meta(self, tmp_path: pathlib.Path) -> None:
326 from muse.core.mpack import build_mpack
327 repo = _init_repo(tmp_path)
328 cid = _commit(repo, {"a.py": b"content"})
329 mpack = build_mpack(repo, [cid])
330 assert "meta" in mpack
331
332 def test_full_mpack_mode_is_full(self, tmp_path: pathlib.Path) -> None:
333 from muse.core.mpack import build_mpack
334 repo = _init_repo(tmp_path)
335 cid = _commit(repo, {"a.py": b"content"})
336 mpack = build_mpack(repo, [cid])
337 assert mpack["meta"]["mode"] == "full"
338
339 def test_full_mpack_base_commits_empty(self, tmp_path: pathlib.Path) -> None:
340 from muse.core.mpack import build_mpack
341 repo = _init_repo(tmp_path)
342 cid = _commit(repo, {"a.py": b"content"})
343 mpack = build_mpack(repo, [cid])
344 assert mpack["meta"]["base_commits"] == []
345
346 def test_full_mpack_created_at_is_str(self, tmp_path: pathlib.Path) -> None:
347 from muse.core.mpack import build_mpack
348 repo = _init_repo(tmp_path)
349 cid = _commit(repo, {"a.py": b"content"})
350 mpack = build_mpack(repo, [cid])
351 assert isinstance(mpack["meta"]["created_at"], str)
352 assert len(mpack["meta"]["created_at"]) > 10 # not empty
353
354 def test_full_mpack_created_at_is_iso(self, tmp_path: pathlib.Path) -> None:
355 from muse.core.mpack import build_mpack
356 import datetime
357 repo = _init_repo(tmp_path)
358 cid = _commit(repo, {"a.py": b"content"})
359 mpack = build_mpack(repo, [cid])
360 # Must be parseable as ISO 8601
361 dt = datetime.datetime.fromisoformat(mpack["meta"]["created_at"].rstrip("Z"))
362 assert dt.year >= 2024
363
364 # -----------------------------------------------------------------
365 # Incremental mpack (have set) — mode == "incremental"
366 # -----------------------------------------------------------------
367
368 def test_incremental_mpack_mode_is_incremental(self, tmp_path: pathlib.Path) -> None:
369 from muse.core.mpack import build_mpack
370 repo = _init_repo(tmp_path)
371 base_cid = _commit(repo, {"a.py": b"v1"})
372 tip_cid = _commit(repo, {"a.py": b"v2"})
373 mpack = build_mpack(repo, [tip_cid], have=[base_cid])
374 assert mpack["meta"]["mode"] == "incremental"
375
376 def test_incremental_mpack_base_commits_populated(self, tmp_path: pathlib.Path) -> None:
377 from muse.core.mpack import build_mpack
378 repo = _init_repo(tmp_path)
379 base_cid = _commit(repo, {"a.py": b"v1"})
380 tip_cid = _commit(repo, {"a.py": b"v2"})
381 mpack = build_mpack(repo, [tip_cid], have=[base_cid])
382 assert base_cid in mpack["meta"]["base_commits"]
383
384 def test_incremental_mpack_multiple_have(self, tmp_path: pathlib.Path) -> None:
385 from muse.core.mpack import build_mpack
386 repo = _init_repo(tmp_path)
387 c1 = _commit(repo, {"a.py": b"v1"})
388 c2 = _commit(repo, {"a.py": b"v2"})
389 c3 = _commit(repo, {"a.py": b"v3"})
390 mpack = build_mpack(repo, [c3], have=[c1, c2])
391 assert set(mpack["meta"]["base_commits"]) == {c1, c2}
392
393 def test_incremental_mpack_has_created_at(self, tmp_path: pathlib.Path) -> None:
394 from muse.core.mpack import build_mpack
395 repo = _init_repo(tmp_path)
396 base = _commit(repo, {"a.py": b"base"})
397 tip = _commit(repo, {"a.py": b"tip"})
398 mpack = build_mpack(repo, [tip], have=[base])
399 assert isinstance(mpack["meta"]["created_at"], str)
400
401 # -----------------------------------------------------------------
402 # Empty have list treated as full
403 # -----------------------------------------------------------------
404
405 def test_empty_have_list_means_full(self, tmp_path: pathlib.Path) -> None:
406 from muse.core.mpack import build_mpack
407 repo = _init_repo(tmp_path)
408 cid = _commit(repo, {"f.py": b"data"})
409 mpack = build_mpack(repo, [cid], have=[])
410 assert mpack["meta"]["mode"] == "full"
411 assert mpack["meta"]["base_commits"] == []
File History 1 commit
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago