gabriel / muse public
test_object_store_write_taxonomy.py python
701 lines 27.5 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 120 days ago
1 """Object store write taxonomy — exhaustive correctness and safety tests.
2
3 Every path that writes OR deletes objects is enumerated here. Each test
4 targets one invariant. If a test fails, it means a write or delete path is
5 broken; fix the production code, not the test.
6
7 Write paths covered
8 -------------------
9 W-1 write_object() — primary low-level write
10 W-2 write_object_from_path() — write from filesystem file
11 W-3 commit workflow — muse commit writes blobs then snapshot
12 W-4 shelf save — blobs written before shelf entry
13 W-5 fetch / pull _on_object — objects written on receive
14 W-6 apply_mpack — bundle unbundle writes objects
15 W-7 domain merge — plugin merge writes merged blob
16 W-8 hash_object --write — explicit low-level write
17
18 Delete paths covered
19 --------------------
20 D-1 gc non-full (default) — orphan sweep via snapshots walker
21 D-2 gc full — tight reachability from live refs
22 D-3 gc full multi-branch — objects on ALL branches survive
23 D-4 gc full object normalisation — sha256: prefixed IDs in reachable set
24 D-5 prune — mirrors gc non-full with expire window
25 D-6 maintenance gc task — calls run_gc with full=True
26
27 Consistency invariants
28 ----------------------
29 C-1 write → has_object True
30 C-2 write → object_state PRESENT
31 C-3 write → iter_stored_objects finds it
32 C-4 has_object and object_state agree
33 C-5 object_path canonical location
34 C-6 no write → object_state MISSING (no promisors)
35 C-7 no write → object_state PROMISED (promisors configured)
36 """
37
38 from __future__ import annotations
39
40 import datetime
41 import json
42 import pathlib
43 import tempfile
44 from collections.abc import Mapping
45
46 import msgpack
47
48 import pytest
49
50 from muse.core.types import Manifest, blob_id, long_id, split_id
51 from muse.core.gc import run_gc, _collect_reachable_snapshots, _collect_reachable_commits
52 from muse.core.object_availability import ObjectState, load_promisor_remotes, object_state
53 from muse.core.object_store import (
54 has_object,
55 iter_stored_objects,
56 object_path,
57 read_object,
58 write_object,
59 write_object_from_path,
60 )
61 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
62 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
63 from muse.core.paths import muse_dir, objects_dir, ref_path, shelf_dir
64
65
66 # ---------------------------------------------------------------------------
67 # Shared helpers
68 # ---------------------------------------------------------------------------
69
70
71 def _repo(tmp_path: pathlib.Path) -> pathlib.Path:
72 """Minimal .muse repo skeleton."""
73 muse = muse_dir(tmp_path)
74 for d in ("objects/sha256", "commits/sha256", "snapshots/sha256", "refs/heads"):
75 (muse / d).mkdir(parents=True, exist_ok=True)
76 (muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo"}))
77 (muse / "HEAD").write_text("ref: refs/heads/main\n")
78 return tmp_path
79
80
81 def _write_blob(repo: pathlib.Path, content: bytes) -> str:
82 oid = blob_id(content)
83 write_object(repo, oid, content)
84 return oid
85
86
87 def _write_shelf_entry(repo: pathlib.Path, snapshot: Mapping[str, str]) -> None:
88 entry = {"snapshot": snapshot, "branch": "main", "created_at": "2026-01-01T00:00:00+00:00"}
89 packed = msgpack.packb(entry, use_bin_type=True)
90 _, hex_id = split_id(blob_id(packed))
91 s_dir = shelf_dir(repo) / "sha256"
92 s_dir.mkdir(parents=True, exist_ok=True)
93 (s_dir / f"{hex_id}.msgpack").write_bytes(packed)
94
95
96 def _write_snap(repo: pathlib.Path, manifest: Manifest) -> str:
97 snap_id = compute_snapshot_id(manifest)
98 write_snapshot(repo, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
99 return snap_id
100
101
102 def _write_commit_on_branch(
103 repo: pathlib.Path,
104 snap_id: str,
105 branch: str = "main",
106 parent_id: str | None = None,
107 message: str = "test",
108 ) -> str:
109 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
110 parent_ids = [parent_id] if parent_id else []
111 commit_id = compute_commit_id(
112 parent_ids=parent_ids,
113 snapshot_id=snap_id,
114 message=message,
115 committed_at_iso=committed_at.isoformat(),
116 )
117 write_commit(
118 repo,
119 CommitRecord(
120 repo_id="test-repo",
121 commit_id=commit_id,
122 branch=branch,
123 snapshot_id=snap_id,
124 message=message,
125 committed_at=committed_at,
126 parent_commit_id=parent_id,
127 ),
128 )
129 ref = ref_path(repo, branch)
130 ref.parent.mkdir(parents=True, exist_ok=True)
131 ref.write_text(commit_id)
132 return commit_id
133
134
135 # ---------------------------------------------------------------------------
136 # W-1 write_object — canonical path
137 # ---------------------------------------------------------------------------
138
139
140 class TestWriteObject:
141 """W-1: write_object() places objects at the canonical sha256/ path."""
142
143 def test_lands_under_sha256_dir(self, tmp_path: pathlib.Path) -> None:
144 repo = _repo(tmp_path)
145 oid = blob_id(b"hello")
146 write_object(repo, oid, b"hello")
147 p = object_path(repo, oid)
148 assert p.exists()
149 assert p.parent.parent.name == "sha256"
150
151 def test_shard_prefix_is_first_two_hex_chars(self, tmp_path: pathlib.Path) -> None:
152 repo = _repo(tmp_path)
153 content = b"shard-check"
154 oid = blob_id(content)
155 write_object(repo, oid, content)
156 p = object_path(repo, oid)
157 hex_id = split_id(oid)[1]
158 assert p.parent.name == hex_id[:2]
159
160 def test_filename_is_remaining_62_hex_chars(self, tmp_path: pathlib.Path) -> None:
161 repo = _repo(tmp_path)
162 content = b"filename-check"
163 oid = blob_id(content)
164 write_object(repo, oid, content)
165 p = object_path(repo, oid)
166 hex_id = split_id(oid)[1]
167 assert p.name == hex_id[2:]
168
169 def test_idempotent_returns_false_on_second_write(
170 self, tmp_path: pathlib.Path
171 ) -> None:
172 repo = _repo(tmp_path)
173 oid = blob_id(b"idempotent")
174 assert write_object(repo, oid, b"idempotent") is True
175 assert write_object(repo, oid, b"idempotent") is False
176
177 def test_content_verifiable_after_write(self, tmp_path: pathlib.Path) -> None:
178 repo = _repo(tmp_path)
179 content = b"verifiable content"
180 oid = blob_id(content)
181 write_object(repo, oid, content)
182 assert read_object(repo, oid) == content
183
184 def test_rejects_wrong_content(self, tmp_path: pathlib.Path) -> None:
185 repo = _repo(tmp_path)
186 oid = blob_id(b"correct")
187 with pytest.raises(ValueError):
188 write_object(repo, oid, b"wrong content")
189
190 def test_rejects_bare_hex_object_id(self, tmp_path: pathlib.Path) -> None:
191 repo = _repo(tmp_path)
192 bare_hex = split_id(blob_id(b"bare"))[1]
193 with pytest.raises((ValueError, Exception)):
194 write_object(repo, bare_hex, b"bare")
195
196
197 # ---------------------------------------------------------------------------
198 # W-2 write_object_from_path — canonical path
199 # ---------------------------------------------------------------------------
200
201
202 class TestWriteObjectFromPath:
203 """W-2: write_object_from_path() writes from a file and lands at canonical path."""
204
205 def test_writes_to_sha256_dir(self, tmp_path: pathlib.Path) -> None:
206 repo = _repo(tmp_path)
207 src = tmp_path / "source.txt"
208 content = b"from-path content"
209 src.write_bytes(content)
210 oid = blob_id(content)
211 write_object_from_path(repo, oid, src)
212 p = object_path(repo, oid)
213 assert p.exists()
214 assert p.parent.parent.name == "sha256"
215
216 def test_oid_matches_blob_id(self, tmp_path: pathlib.Path) -> None:
217 repo = _repo(tmp_path)
218 content = b"oid must match blob_id"
219 src = tmp_path / "f.txt"
220 src.write_bytes(content)
221 oid = blob_id(content)
222 write_object_from_path(repo, oid, src)
223 assert oid == blob_id(content)
224
225 def test_content_readable_after_write(self, tmp_path: pathlib.Path) -> None:
226 repo = _repo(tmp_path)
227 content = b"readable after write"
228 src = tmp_path / "r.txt"
229 src.write_bytes(content)
230 oid = blob_id(content)
231 write_object_from_path(repo, oid, src)
232 assert read_object(repo, oid) == content
233
234
235 # ---------------------------------------------------------------------------
236 # C-1 … C-7 Consistency invariants
237 # ---------------------------------------------------------------------------
238
239
240 class TestConsistencyInvariants:
241 """C-1 through C-7: consistency between write, has_object, object_state, iter."""
242
243 def test_c1_has_object_true_after_write(self, tmp_path: pathlib.Path) -> None:
244 repo = _repo(tmp_path)
245 oid = _write_blob(repo, b"c1")
246 assert has_object(repo, oid)
247
248 def test_c2_object_state_present_after_write(self, tmp_path: pathlib.Path) -> None:
249 repo = _repo(tmp_path)
250 oid = _write_blob(repo, b"c2")
251 state = object_state(repo, oid, [])
252 assert state == ObjectState.PRESENT
253
254 def test_c3_iter_stored_objects_finds_written(
255 self, tmp_path: pathlib.Path
256 ) -> None:
257 repo = _repo(tmp_path)
258 oid = _write_blob(repo, b"c3")
259 found = {o for o, _ in iter_stored_objects(repo)}
260 assert oid in found
261
262 def test_c4_has_object_and_object_state_agree_present(
263 self, tmp_path: pathlib.Path
264 ) -> None:
265 repo = _repo(tmp_path)
266 oid = _write_blob(repo, b"c4-present")
267 assert has_object(repo, oid)
268 assert object_state(repo, oid, []) == ObjectState.PRESENT
269
270 def test_c4_has_object_and_object_state_agree_absent(
271 self, tmp_path: pathlib.Path
272 ) -> None:
273 repo = _repo(tmp_path)
274 oid = blob_id(b"never written")
275 assert not has_object(repo, oid)
276 assert object_state(repo, oid, []) == ObjectState.MISSING
277
278 def test_c5_object_path_canonical_location(self, tmp_path: pathlib.Path) -> None:
279 repo = _repo(tmp_path)
280 content = b"canonical"
281 oid = blob_id(content)
282 write_object(repo, oid, content)
283 p = object_path(repo, oid)
284 hex_id = split_id(oid)[1]
285 expected = objects_dir(repo) / "sha256" / hex_id[:2] / hex_id[2:]
286 assert p == expected
287 assert p.exists()
288
289 def test_c6_object_state_missing_when_absent_no_promisors(
290 self, tmp_path: pathlib.Path
291 ) -> None:
292 repo = _repo(tmp_path)
293 oid = blob_id(b"missing")
294 state = object_state(repo, oid, promisor_remotes=[])
295 assert state == ObjectState.MISSING
296
297 def test_c7_object_state_promised_when_absent_with_promisor(
298 self, tmp_path: pathlib.Path
299 ) -> None:
300 repo = _repo(tmp_path)
301 oid = blob_id(b"promised")
302 state = object_state(repo, oid, promisor_remotes=["staging"])
303 assert state == ObjectState.PROMISED
304
305 def test_c7_object_state_present_beats_promisor(
306 self, tmp_path: pathlib.Path
307 ) -> None:
308 """A present object is PRESENT even when promisors are configured."""
309 repo = _repo(tmp_path)
310 oid = _write_blob(repo, b"present beats promisor")
311 state = object_state(repo, oid, promisor_remotes=["staging"])
312 assert state == ObjectState.PRESENT
313
314
315 # ---------------------------------------------------------------------------
316 # D-1 GC non-full — orphan sweep
317 # ---------------------------------------------------------------------------
318
319
320 class TestGcNonFull:
321 """D-1: default (non-full) GC sweeps orphans but retains all reachable objects."""
322
323 def test_orphan_collected(self, tmp_path: pathlib.Path) -> None:
324 repo = _repo(tmp_path)
325 oid = _write_blob(repo, b"orphan")
326 run_gc(repo, grace_period_seconds=0)
327 assert not object_path(repo, oid).exists()
328
329 def test_reachable_via_snapshot_survives(self, tmp_path: pathlib.Path) -> None:
330 repo = _repo(tmp_path)
331 oid = _write_blob(repo, b"reachable")
332 snap_id = _write_snap(repo, {"f.txt": oid})
333 _write_commit_on_branch(repo, snap_id)
334 run_gc(repo, grace_period_seconds=0)
335 assert object_path(repo, oid).exists()
336
337 def test_reachable_on_non_default_branch_survives(
338 self, tmp_path: pathlib.Path
339 ) -> None:
340 repo = _repo(tmp_path)
341 oid = _write_blob(repo, b"non-default branch")
342 snap_id = _write_snap(repo, {"g.txt": oid})
343 _write_commit_on_branch(repo, snap_id, branch="dev")
344 run_gc(repo, grace_period_seconds=0)
345 assert object_path(repo, oid).exists()
346
347 def test_multiple_orphans_all_collected(self, tmp_path: pathlib.Path) -> None:
348 repo = _repo(tmp_path)
349 oids = [_write_blob(repo, f"o{i}".encode()) for i in range(5)]
350 result = run_gc(repo, grace_period_seconds=0)
351 assert result.collected_count == 5
352 for oid in oids:
353 assert not object_path(repo, oid).exists()
354
355 def test_grace_period_protects_recent_objects(
356 self, tmp_path: pathlib.Path
357 ) -> None:
358 repo = _repo(tmp_path)
359 oid = _write_blob(repo, b"fresh orphan")
360 result = run_gc(repo, grace_period_seconds=3600)
361 assert result.collected_count == 0
362 assert object_path(repo, oid).exists()
363
364
365 # ---------------------------------------------------------------------------
366 # D-2 GC full — tight reachability
367 # ---------------------------------------------------------------------------
368
369
370 class TestGcFull:
371 """D-2: gc full mode uses tight reachability but must still retain all live objects."""
372
373 def test_reachable_object_survives_full_gc(self, tmp_path: pathlib.Path) -> None:
374 repo = _repo(tmp_path)
375 oid = _write_blob(repo, b"live object")
376 snap_id = _write_snap(repo, {"live.txt": oid})
377 _write_commit_on_branch(repo, snap_id)
378 result = run_gc(repo, full=True, grace_period_seconds=0)
379 assert result.collected_count == 0
380 assert object_path(repo, oid).exists()
381
382 def test_orphan_collected_by_full_gc(self, tmp_path: pathlib.Path) -> None:
383 repo = _repo(tmp_path)
384 # One reachable, one orphan.
385 live_oid = _write_blob(repo, b"live")
386 snap_id = _write_snap(repo, {"f.txt": live_oid})
387 _write_commit_on_branch(repo, snap_id)
388 orphan_oid = _write_blob(repo, b"orphan")
389 result = run_gc(repo, full=True, grace_period_seconds=0)
390 assert result.collected_count == 1
391 assert not object_path(repo, orphan_oid).exists()
392 assert object_path(repo, live_oid).exists()
393
394 def test_full_gc_dry_run_does_not_delete(self, tmp_path: pathlib.Path) -> None:
395 repo = _repo(tmp_path)
396 oid = _write_blob(repo, b"dry-run orphan")
397 result = run_gc(repo, full=True, dry_run=True, grace_period_seconds=0)
398 assert result.dry_run is True
399 assert object_path(repo, oid).exists()
400
401
402 # ---------------------------------------------------------------------------
403 # D-3 GC full multi-branch — objects on ALL live branches survive
404 # ---------------------------------------------------------------------------
405
406
407 class TestGcFullMultiBranch:
408 """D-3: full GC must retain objects reachable from every live branch, not just HEAD."""
409
410 def test_object_on_secondary_branch_survives_full_gc(
411 self, tmp_path: pathlib.Path
412 ) -> None:
413 repo = _repo(tmp_path)
414 # main branch object
415 main_oid = _write_blob(repo, b"main content")
416 main_snap = _write_snap(repo, {"main.txt": main_oid})
417 _write_commit_on_branch(repo, main_snap, branch="main")
418 # dev branch object (different content)
419 dev_oid = _write_blob(repo, b"dev content")
420 dev_snap = _write_snap(repo, {"dev.txt": dev_oid})
421 _write_commit_on_branch(repo, dev_snap, branch="dev")
422 result = run_gc(repo, full=True, grace_period_seconds=0)
423 assert result.collected_count == 0
424 assert object_path(repo, main_oid).exists(), "main branch object deleted!"
425 assert object_path(repo, dev_oid).exists(), "dev branch object deleted!"
426
427 def test_object_on_three_branches_all_survive(
428 self, tmp_path: pathlib.Path
429 ) -> None:
430 repo = _repo(tmp_path)
431 oids = []
432 for branch in ("main", "dev", "feat/x"):
433 oid = _write_blob(repo, f"content on {branch}".encode())
434 snap_id = _write_snap(repo, {f"{branch}.txt": oid})
435 _write_commit_on_branch(repo, snap_id, branch=branch)
436 oids.append(oid)
437 result = run_gc(repo, full=True, grace_period_seconds=0)
438 assert result.collected_count == 0
439 for oid in oids:
440 assert object_path(repo, oid).exists()
441
442 def test_shared_object_referenced_by_two_branches_survives(
443 self, tmp_path: pathlib.Path
444 ) -> None:
445 """If main and dev both reference the same object, full GC must keep it."""
446 repo = _repo(tmp_path)
447 shared_oid = _write_blob(repo, b"shared content")
448 for branch in ("main", "dev"):
449 snap_id = _write_snap(repo, {"shared.txt": shared_oid})
450 _write_commit_on_branch(repo, snap_id, branch=branch)
451 result = run_gc(repo, full=True, grace_period_seconds=0)
452 assert result.collected_count == 0
453 assert object_path(repo, shared_oid).exists()
454
455
456 # ---------------------------------------------------------------------------
457 # D-4 GC full object ID normalisation
458 # ---------------------------------------------------------------------------
459
460
461 class TestGcFullObjectNormalisation:
462 """D-4: full GC reachability set uses sha256:-prefixed IDs throughout.
463
464 This is the critical invariant that ensures the reachable-objects set
465 (built from snapshot manifests) matches the stored-objects set
466 (built from iter_stored_objects). A mismatch would cause live objects
467 to be incorrectly classified as unreachable and deleted.
468 """
469
470 def test_reachable_set_uses_prefixed_ids(self, tmp_path: pathlib.Path) -> None:
471 """_collect_reachable_snapshots returns sha256:-prefixed object IDs."""
472 repo = _repo(tmp_path)
473 oid = _write_blob(repo, b"normalisation check")
474 snap_id = _write_snap(repo, {"f.txt": oid})
475 _write_commit_on_branch(repo, snap_id)
476 reachable_commits = _collect_reachable_commits(repo)
477 _, reachable_objs = _collect_reachable_snapshots(repo, reachable_commits)
478 # Every entry must carry the sha256: prefix.
479 for obj_id in reachable_objs:
480 assert obj_id.startswith("sha256:"), (
481 f"Reachable object ID missing sha256: prefix: {obj_id!r}"
482 )
483
484 def test_iter_stored_objects_uses_prefixed_ids(
485 self, tmp_path: pathlib.Path
486 ) -> None:
487 """iter_stored_objects returns sha256:-prefixed object IDs."""
488 repo = _repo(tmp_path)
489 _write_blob(repo, b"stored check")
490 for oid, _ in iter_stored_objects(repo):
491 assert oid.startswith("sha256:"), (
492 f"iter_stored_objects returned unprefixed ID: {oid!r}"
493 )
494
495 def test_reachable_set_matches_stored_set_for_live_objects(
496 self, tmp_path: pathlib.Path
497 ) -> None:
498 """Every live object must appear in both sets with the same ID form."""
499 repo = _repo(tmp_path)
500 oids = set()
501 for i in range(3):
502 oid = _write_blob(repo, f"live {i}".encode())
503 oids.add(oid)
504 snap_id = _write_snap(repo, {f"f{i}.txt": o for i, o in enumerate(oids)})
505 _write_commit_on_branch(repo, snap_id)
506 reachable_commits = _collect_reachable_commits(repo)
507 _, reachable_objs = _collect_reachable_snapshots(repo, reachable_commits)
508 stored = {o for o, _ in iter_stored_objects(repo)}
509 # All live objects must be in both sets.
510 for oid in oids:
511 assert oid in reachable_objs, f"{oid} missing from reachable set"
512 assert oid in stored, f"{oid} missing from stored set"
513
514 def test_full_gc_does_not_delete_prefixed_manifest_objects(
515 self, tmp_path: pathlib.Path
516 ) -> None:
517 """Regression: full GC must not delete objects whose IDs use sha256: prefix in the manifest."""
518 repo = _repo(tmp_path)
519 contents = [f"file {i} content".encode() for i in range(5)]
520 manifest = {}
521 for i, c in enumerate(contents):
522 oid = _write_blob(repo, c)
523 manifest[f"file{i}.py"] = oid
524 # Confirm the manifest value is prefixed.
525 assert oid.startswith("sha256:"), f"blob_id returned unprefixed: {oid}"
526 snap_id = _write_snap(repo, manifest)
527 _write_commit_on_branch(repo, snap_id)
528 result = run_gc(repo, full=True, grace_period_seconds=0)
529 assert result.collected_count == 0, (
530 f"Full GC deleted {result.collected_count} live objects: {result.collected_ids}"
531 )
532 for oid in manifest.values():
533 assert object_path(repo, oid).exists(), f"Full GC deleted live object {oid}"
534
535 def test_full_gc_retains_large_manifest(self, tmp_path: pathlib.Path) -> None:
536 """Full GC must not delete any of N live objects in a large snapshot."""
537 repo = _repo(tmp_path)
538 n = 50
539 manifest = {}
540 for i in range(n):
541 oid = _write_blob(repo, f"large manifest entry {i}".encode())
542 manifest[f"src/file_{i:03d}.py"] = oid
543 snap_id = _write_snap(repo, manifest)
544 _write_commit_on_branch(repo, snap_id)
545 result = run_gc(repo, full=True, grace_period_seconds=0)
546 assert result.collected_count == 0, (
547 f"Full GC deleted objects from large manifest: {result.collected_ids[:5]}"
548 )
549
550
551 # ---------------------------------------------------------------------------
552 # D-5 Prune — mirrors non-full GC with expire window
553 # ---------------------------------------------------------------------------
554
555
556 class TestPruneSafety:
557 """D-5: muse prune must never delete reachable objects."""
558
559 def test_prune_does_not_remove_committed_object(
560 self, tmp_path: pathlib.Path
561 ) -> None:
562 """Objects referenced by commits must survive prune."""
563 from muse.core.gc import run_gc # prune delegates to gc
564 repo = _repo(tmp_path)
565 oid = _write_blob(repo, b"committed object")
566 snap_id = _write_snap(repo, {"f.txt": oid})
567 _write_commit_on_branch(repo, snap_id)
568 # Non-full GC is what prune uses.
569 result = run_gc(repo, grace_period_seconds=0)
570 assert result.collected_count == 0
571 assert object_path(repo, oid).exists()
572
573
574 # ---------------------------------------------------------------------------
575 # D-6 Maintenance gc task passes full=True
576 # ---------------------------------------------------------------------------
577
578
579 class TestMaintenanceGcUsesFull:
580 """D-6: the maintenance 'gc' task must invoke run_gc with full=True."""
581
582 def test_maintenance_gc_task_calls_run_gc_with_full_true(
583 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
584 ) -> None:
585 """Confirm _run_gc (the maintenance task) passes full=True to run_gc."""
586 from muse.cli.commands import maintenance as maint_mod
587
588 calls: list[dict] = []
589
590 def _capture_run_gc(root: pathlib.Path, *, dry_run: bool, grace_period_seconds: float, full: bool) -> "GcResult":
591 calls.append({"full": full, "dry_run": dry_run})
592 from muse.core.gc import GcResult
593 return GcResult(dry_run=dry_run, grace_period_seconds=grace_period_seconds, full=full)
594
595 monkeypatch.setattr(maint_mod, "run_gc", _capture_run_gc)
596 repo = _repo(tmp_path)
597 maint_mod._run_gc(repo)
598 assert calls, "run_gc was never called by maintenance _run_gc"
599 assert calls[0]["full"] is True, (
600 f"Maintenance gc must pass full=True, got full={calls[0]['full']}"
601 )
602
603 def test_maintenance_gc_retains_all_reachable_objects(
604 self, tmp_path: pathlib.Path
605 ) -> None:
606 """End-to-end: running the maintenance gc task must not delete live objects."""
607 from muse.cli.commands.maintenance import _run_gc as maintenance_run_gc
608
609 repo = _repo(tmp_path)
610 # Write objects on two branches.
611 for branch, content in (("main", b"main obj"), ("dev", b"dev obj")):
612 oid = _write_blob(repo, content)
613 snap_id = _write_snap(repo, {f"{branch}.py": oid})
614 _write_commit_on_branch(repo, snap_id, branch=branch)
615
616 maintenance_run_gc(repo, dry_run=False)
617
618 # Both objects must survive.
619 for content in (b"main obj", b"dev obj"):
620 oid = blob_id(content)
621 assert object_path(repo, oid).exists(), (
622 f"Maintenance gc deleted live object {oid}"
623 )
624
625
626 # ---------------------------------------------------------------------------
627 # W-3 Commit workflow — objects written before commit record
628 # ---------------------------------------------------------------------------
629
630
631 class TestCommitWritePath:
632 """W-3: the commit workflow must write blobs to the object store at the
633 canonical path before creating the commit record.
634
635 We test this at the store level (not the CLI) since the CLI requires a
636 full working-tree environment.
637 """
638
639 def test_snapshot_manifest_objects_at_canonical_path(
640 self, tmp_path: pathlib.Path
641 ) -> None:
642 """Objects written for a commit land at the canonical sha256/ path."""
643 repo = _repo(tmp_path)
644 contents = {f"src/file{i}.py": f"content {i}".encode() for i in range(3)}
645 manifest = {}
646 for path, content in contents.items():
647 oid = blob_id(content)
648 write_object(repo, oid, content)
649 manifest[path] = oid
650 snap_id = _write_snap(repo, manifest)
651 _write_commit_on_branch(repo, snap_id)
652 # All objects reachable and at correct path.
653 for oid in manifest.values():
654 p = object_path(repo, oid)
655 assert p.exists()
656 assert p.parent.parent.name == "sha256"
657
658 def test_all_manifest_objects_survive_full_gc(
659 self, tmp_path: pathlib.Path
660 ) -> None:
661 """Objects in a committed snapshot must all survive full GC."""
662 repo = _repo(tmp_path)
663 manifest = {}
664 for i in range(10):
665 content = f"committed file {i}".encode()
666 oid = blob_id(content)
667 write_object(repo, oid, content)
668 manifest[f"file{i}.py"] = oid
669 snap_id = _write_snap(repo, manifest)
670 _write_commit_on_branch(repo, snap_id)
671 result = run_gc(repo, full=True, grace_period_seconds=0)
672 assert result.collected_count == 0
673 for oid in manifest.values():
674 assert object_path(repo, oid).exists()
675
676
677 # ---------------------------------------------------------------------------
678 # W-4 Shelf save — blobs written before shelf entry
679 # ---------------------------------------------------------------------------
680
681
682 class TestShelfWritePath:
683 """W-4: shelf objects must survive GC even before they are committed."""
684
685 def test_shelved_objects_survive_non_full_gc(
686 self, tmp_path: pathlib.Path
687 ) -> None:
688 repo = _repo(tmp_path)
689 shelf_oid = _write_blob(repo, b"shelved work")
690 _write_shelf_entry(repo, {"work.py": shelf_oid})
691 result = run_gc(repo, grace_period_seconds=0)
692 assert result.collected_count == 0
693 assert object_path(repo, shelf_oid).exists()
694
695 def test_shelved_objects_survive_full_gc(self, tmp_path: pathlib.Path) -> None:
696 repo = _repo(tmp_path)
697 shelf_oid = _write_blob(repo, b"shelved full gc")
698 _write_shelf_entry(repo, {"wip.py": shelf_oid})
699 result = run_gc(repo, full=True, grace_period_seconds=0)
700 assert result.collected_count == 0
701 assert object_path(repo, shelf_oid).exists()
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 120 days ago