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