gabriel / muse public
test_core_gc.py python
240 lines 8.5 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 121 days ago
1 """Tests for muse/core/gc.py — garbage collection."""
2
3 from __future__ import annotations
4
5 import json
6 import pathlib
7 from collections.abc import Mapping
8
9 import msgpack
10 import pytest
11
12 from muse.core.gc import GcResult, run_gc
13 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
14 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
15 from muse.core.types import Manifest, blob_id, split_id
16 from muse.core.object_store import object_path
17 from muse.core.paths import heads_dir, muse_dir, objects_dir, shelf_dir
18
19
20 # ---------------------------------------------------------------------------
21 # Helpers
22 # ---------------------------------------------------------------------------
23
24
25 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
26 """Create a minimal .muse repo structure."""
27 muse = muse_dir(tmp_path)
28 for d in ("objects", "commits", "snapshots", "refs/heads"):
29 (muse / d).mkdir(parents=True, exist_ok=True)
30 (muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo"}))
31 (muse / "HEAD").write_text("ref: refs/heads/main\n")
32 return tmp_path
33
34
35 def _write_shelf_entry(repo: pathlib.Path, snapshot: Mapping[str, str]) -> None:
36 """Write a single shelf entry as msgpack under .muse/shelf/sha256/<hex>.msgpack."""
37 data = {"snapshot": snapshot, "branch": "main", "created_at": "2026-01-01T00:00:00+00:00"}
38 packed = msgpack.packb(data, use_bin_type=True)
39 _, hex_id = split_id(blob_id(packed))
40 s_dir = shelf_dir(repo) / "sha256"
41 s_dir.mkdir(parents=True, exist_ok=True)
42 (s_dir / f"{hex_id}.msgpack").write_bytes(packed)
43
44
45 def _write_object(repo: pathlib.Path, content: bytes) -> str:
46 oid = blob_id(content)
47 obj_file = object_path(repo, oid)
48 obj_file.parent.mkdir(parents=True, exist_ok=True)
49 obj_file.write_bytes(content)
50 return oid
51
52
53 def _write_snapshot(repo: pathlib.Path, manifest: Manifest) -> str:
54 """Write a snapshot with a valid content-hash snapshot_id. Returns the snapshot_id."""
55 snap_id = compute_snapshot_id(manifest)
56 write_snapshot(repo, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
57 return snap_id
58
59
60 def _write_commit(repo: pathlib.Path, snapshot_id: str) -> str:
61 """Write a commit record with a valid content-hash commit_id. Returns the commit_id."""
62 import datetime
63
64 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
65 commit_id = compute_commit_id(
66 parent_ids=[],
67 snapshot_id=snapshot_id,
68 message="test",
69 committed_at_iso=committed_at.isoformat(),
70 )
71 write_commit(repo, CommitRecord(
72 repo_id="test-repo",
73 commit_id=commit_id,
74 branch="main",
75 snapshot_id=snapshot_id,
76 message="test",
77 committed_at=committed_at,
78 ))
79 r_path = heads_dir(repo) / "main"
80 r_path.parent.mkdir(parents=True, exist_ok=True)
81 r_path.write_text(commit_id)
82 return commit_id
83
84
85 # ---------------------------------------------------------------------------
86 # Tests
87 # ---------------------------------------------------------------------------
88
89
90 def test_gc_empty_repo(tmp_path: pathlib.Path) -> None:
91 """GC on an empty repo should report 0 collected."""
92 repo = _make_repo(tmp_path)
93 result = run_gc(repo, grace_period_seconds=0)
94 assert isinstance(result, GcResult)
95 assert result.collected_count == 0
96
97
98 def test_gc_removes_unreachable_object(tmp_path: pathlib.Path) -> None:
99 repo = _make_repo(tmp_path)
100 # Write an object but don't reference it in any commit.
101 orphan_id = _write_object(repo, b"orphan data")
102 obj_path = object_path(repo, orphan_id)
103 assert obj_path.exists()
104
105 result = run_gc(repo, grace_period_seconds=0)
106 assert result.collected_count == 1
107 assert orphan_id in result.collected_ids
108 assert not obj_path.exists()
109
110
111 def test_gc_preserves_reachable_object(tmp_path: pathlib.Path) -> None:
112 repo = _make_repo(tmp_path)
113 content = b"reachable file content"
114 obj_id = _write_object(repo, content)
115 snap_id = _write_snapshot(repo, {"file.txt": obj_id})
116 _write_commit(repo, snap_id)
117
118 result = run_gc(repo, grace_period_seconds=0)
119 assert result.collected_count == 0
120 obj_path = object_path(repo, obj_id)
121 assert obj_path.exists()
122
123
124 def test_gc_dry_run_does_not_delete(tmp_path: pathlib.Path) -> None:
125 repo = _make_repo(tmp_path)
126 orphan_id = _write_object(repo, b"orphan")
127 obj_path = object_path(repo, orphan_id)
128
129 result = run_gc(repo, dry_run=True, grace_period_seconds=0)
130 assert result.dry_run is True
131 assert result.collected_count == 1
132 # File should still exist.
133 assert obj_path.exists()
134
135
136 def test_gc_collected_bytes(tmp_path: pathlib.Path) -> None:
137 repo = _make_repo(tmp_path)
138 content = b"x" * 1000
139 _write_object(repo, content)
140 result = run_gc(repo, grace_period_seconds=0)
141 assert result.collected_bytes >= 1000
142
143
144 def test_gc_multiple_orphans(tmp_path: pathlib.Path) -> None:
145 repo = _make_repo(tmp_path)
146 for i in range(5):
147 _write_object(repo, f"orphan {i}".encode())
148 result = run_gc(repo, grace_period_seconds=0)
149 assert result.collected_count == 5
150
151
152 def test_gc_mixed_reachable_and_orphans(tmp_path: pathlib.Path) -> None:
153 repo = _make_repo(tmp_path)
154 # One reachable object.
155 reachable_id = _write_object(repo, b"reachable")
156 snap_id = _write_snapshot(repo, {"file.txt": reachable_id})
157 _write_commit(repo, snap_id)
158 # Two orphans.
159 _write_object(repo, b"orphan A")
160 _write_object(repo, b"orphan B")
161
162 result = run_gc(repo, grace_period_seconds=0)
163 assert result.collected_count == 2
164 assert result.reachable_count == 1
165
166
167 def test_gc_elapsed_time_positive(tmp_path: pathlib.Path) -> None:
168 repo = _make_repo(tmp_path)
169 result = run_gc(repo, grace_period_seconds=0)
170 assert result.duration_ms >= 0.0
171
172
173 # ---------------------------------------------------------------------------
174 # Stress test
175 # ---------------------------------------------------------------------------
176
177
178 def test_gc_preserves_shelf_objects(tmp_path: pathlib.Path) -> None:
179 """Objects referenced only by shelf.json must NOT be GCed.
180
181 This is the critical safety case: `muse shelf save` writes file blobs to the
182 object store and records their IDs in shelf.json. Without walking the
183 shelf, a subsequent `muse gc` would delete those blobs and make
184 `muse shelf pop` fail with missing objects.
185 """
186 repo = _make_repo(tmp_path)
187 # Simulate shelf save writing two objects.
188 shelf_obj_a = _write_object(repo, b"shelved file A")
189 shelf_obj_b = _write_object(repo, b"shelved file B")
190
191 _write_shelf_entry(repo, {"a.py": shelf_obj_a, "b.py": shelf_obj_b})
192
193 result = run_gc(repo, grace_period_seconds=0)
194 assert result.collected_count == 0, "Shelf objects must not be GCed"
195
196 # The blobs must still exist.
197 assert object_path(repo, shelf_obj_a).exists()
198 assert object_path(repo, shelf_obj_b).exists()
199
200
201 def test_gc_collects_objects_not_on_shelf(tmp_path: pathlib.Path) -> None:
202 """Objects that are neither committed nor shelved ARE unreachable and must be GCed."""
203 repo = _make_repo(tmp_path)
204 shelf_obj = _write_object(repo, b"shelved")
205 orphan_obj = _write_object(repo, b"truly orphaned")
206
207 _write_shelf_entry(repo, {"a.py": shelf_obj})
208
209 result = run_gc(repo, grace_period_seconds=0)
210 assert result.collected_count == 1
211 assert orphan_obj in result.collected_ids
212 assert shelf_obj not in result.collected_ids
213
214
215 def test_gc_ignores_stray_non_hex_files_in_objects_dir(tmp_path: pathlib.Path) -> None:
216 """Non-hex filenames in .muse/objects/ are skipped, not mistakenly deleted."""
217 repo = _make_repo(tmp_path)
218 # Create a stray file that should be ignored.
219 stray_dir = objects_dir(repo) / "ab"
220 stray_dir.mkdir(parents=True, exist_ok=True)
221 stray = stray_dir / ".DS_Store"
222 stray.write_bytes(b"stray")
223
224 result = run_gc(repo, grace_period_seconds=0)
225 assert result.collected_count == 0
226 assert stray.exists(), ".DS_Store should survive GC"
227
228
229 def test_gc_stress_many_orphans(tmp_path: pathlib.Path) -> None:
230 """GC should handle 200 orphaned objects efficiently."""
231 repo = _make_repo(tmp_path)
232 for i in range(200):
233 _write_object(repo, f"orphan-{i:04d}".encode())
234 result = run_gc(repo, grace_period_seconds=0)
235 assert result.collected_count == 200
236 # Verify the objects directory is clean.
237 obj_dir = objects_dir(repo)
238 remaining = list(obj_dir.rglob("*"))
239 remaining_files = [p for p in remaining if p.is_file()]
240 assert remaining_files == []
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 121 days ago