gabriel / muse public
test_core_gc.py python
240 lines 8.3 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 135 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
8 import pytest
9
10 from muse.core.gc import GcResult, run_gc
11 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
12 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
13 from muse.core._types import Manifest, blob_id
14 from muse.core.object_store import object_path
15
16
17 # ---------------------------------------------------------------------------
18 # Helpers
19 # ---------------------------------------------------------------------------
20
21
22 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
23 """Create a minimal .muse repo structure."""
24 muse = tmp_path / ".muse"
25 for d in ("objects", "commits", "snapshots", "refs/heads"):
26 (muse / d).mkdir(parents=True, exist_ok=True)
27 (muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo"}))
28 (muse / "HEAD").write_text("ref: refs/heads/main\n")
29 return tmp_path
30
31
32 def _write_object(repo: pathlib.Path, content: bytes) -> str:
33 oid = blob_id(content)
34 obj_file = object_path(repo, oid)
35 obj_file.parent.mkdir(parents=True, exist_ok=True)
36 obj_file.write_bytes(content)
37 return oid
38
39
40 def _write_snapshot(repo: pathlib.Path, manifest: Manifest) -> str:
41 """Write a snapshot with a valid content-hash snapshot_id. Returns the snapshot_id."""
42 snap_id = compute_snapshot_id(manifest)
43 write_snapshot(repo, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
44 return snap_id
45
46
47 def _write_commit(repo: pathlib.Path, snapshot_id: str) -> str:
48 """Write a commit record with a valid content-hash commit_id. Returns the commit_id."""
49 import datetime
50
51 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
52 commit_id = compute_commit_id(
53 repo_id="test-repo",
54 parent_ids=[],
55 snapshot_id=snapshot_id,
56 message="test",
57 committed_at_iso=committed_at.isoformat(),
58 )
59 write_commit(repo, CommitRecord(
60 commit_id=commit_id,
61 repo_id="test-repo",
62 created_on_branch="main",
63 snapshot_id=snapshot_id,
64 message="test",
65 committed_at=committed_at,
66 ))
67 ref_path = repo / ".muse" / "refs" / "heads" / "main"
68 ref_path.parent.mkdir(parents=True, exist_ok=True)
69 ref_path.write_text(commit_id)
70 return commit_id
71
72
73 # ---------------------------------------------------------------------------
74 # Tests
75 # ---------------------------------------------------------------------------
76
77
78 def test_gc_empty_repo(tmp_path: pathlib.Path) -> None:
79 """GC on an empty repo should report 0 collected."""
80 repo = _make_repo(tmp_path)
81 result = run_gc(repo, grace_period_seconds=0)
82 assert isinstance(result, GcResult)
83 assert result.collected_count == 0
84
85
86 def test_gc_removes_unreachable_object(tmp_path: pathlib.Path) -> None:
87 repo = _make_repo(tmp_path)
88 # Write an object but don't reference it in any commit.
89 orphan_id = _write_object(repo, b"orphan data")
90 obj_path = object_path(repo, orphan_id)
91 assert obj_path.exists()
92
93 result = run_gc(repo, grace_period_seconds=0)
94 assert result.collected_count == 1
95 assert orphan_id in result.collected_ids
96 assert not obj_path.exists()
97
98
99 def test_gc_preserves_reachable_object(tmp_path: pathlib.Path) -> None:
100 repo = _make_repo(tmp_path)
101 content = b"reachable file content"
102 obj_id = _write_object(repo, content)
103 snap_id = _write_snapshot(repo, {"file.txt": obj_id})
104 _write_commit(repo, snap_id)
105
106 result = run_gc(repo, grace_period_seconds=0)
107 assert result.collected_count == 0
108 obj_path = object_path(repo, obj_id)
109 assert obj_path.exists()
110
111
112 def test_gc_dry_run_does_not_delete(tmp_path: pathlib.Path) -> None:
113 repo = _make_repo(tmp_path)
114 orphan_id = _write_object(repo, b"orphan")
115 obj_path = object_path(repo, orphan_id)
116
117 result = run_gc(repo, dry_run=True, grace_period_seconds=0)
118 assert result.dry_run is True
119 assert result.collected_count == 1
120 # File should still exist.
121 assert obj_path.exists()
122
123
124 def test_gc_collected_bytes(tmp_path: pathlib.Path) -> None:
125 repo = _make_repo(tmp_path)
126 content = b"x" * 1000
127 _write_object(repo, content)
128 result = run_gc(repo, grace_period_seconds=0)
129 assert result.collected_bytes >= 1000
130
131
132 def test_gc_multiple_orphans(tmp_path: pathlib.Path) -> None:
133 repo = _make_repo(tmp_path)
134 for i in range(5):
135 _write_object(repo, f"orphan {i}".encode())
136 result = run_gc(repo, grace_period_seconds=0)
137 assert result.collected_count == 5
138
139
140 def test_gc_mixed_reachable_and_orphans(tmp_path: pathlib.Path) -> None:
141 repo = _make_repo(tmp_path)
142 # One reachable object.
143 reachable_id = _write_object(repo, b"reachable")
144 snap_id = _write_snapshot(repo, {"file.txt": reachable_id})
145 _write_commit(repo, snap_id)
146 # Two orphans.
147 _write_object(repo, b"orphan A")
148 _write_object(repo, b"orphan B")
149
150 result = run_gc(repo, grace_period_seconds=0)
151 assert result.collected_count == 2
152 assert result.reachable_count == 1
153
154
155 def test_gc_elapsed_time_positive(tmp_path: pathlib.Path) -> None:
156 repo = _make_repo(tmp_path)
157 result = run_gc(repo, grace_period_seconds=0)
158 assert result.duration_ms >= 0.0
159
160
161 # ---------------------------------------------------------------------------
162 # Stress test
163 # ---------------------------------------------------------------------------
164
165
166 def test_gc_preserves_shelf_objects(tmp_path: pathlib.Path) -> None:
167 """Objects referenced only by shelf.json must NOT be GCed.
168
169 This is the critical safety case: `muse shelf save` writes file blobs to the
170 object store and records their IDs in shelf.json. Without walking the
171 shelf, a subsequent `muse gc` would delete those blobs and make
172 `muse shelf pop` fail with missing objects.
173 """
174 repo = _make_repo(tmp_path)
175 # Simulate shelf save writing two objects.
176 shelf_obj_a = _write_object(repo, b"shelved file A")
177 shelf_obj_b = _write_object(repo, b"shelved file B")
178
179 shelf_path = repo / ".muse" / "shelf.json"
180 shelf_path.write_text(json.dumps([{
181 "snapshot_id": "s" * 64,
182 "branch": "main",
183 "created_at": "2026-01-01T00:00:00+00:00",
184 "snapshot": {"a.py": shelf_obj_a, "b.py": shelf_obj_b},
185 }]))
186
187 result = run_gc(repo, grace_period_seconds=0)
188 assert result.collected_count == 0, "Shelf objects must not be GCed"
189
190 # The blobs must still exist.
191 assert object_path(repo, shelf_obj_a).exists()
192 assert object_path(repo, shelf_obj_b).exists()
193
194
195 def test_gc_collects_objects_not_on_shelf(tmp_path: pathlib.Path) -> None:
196 """Objects that are neither committed nor shelved ARE unreachable and must be GCed."""
197 repo = _make_repo(tmp_path)
198 shelf_obj = _write_object(repo, b"shelved")
199 orphan_obj = _write_object(repo, b"truly orphaned")
200
201 shelf_path = repo / ".muse" / "shelf.json"
202 shelf_path.write_text(json.dumps([{
203 "snapshot_id": "s" * 64,
204 "branch": "main",
205 "created_at": "2026-01-01T00:00:00+00:00",
206 "snapshot": {"a.py": shelf_obj},
207 }]))
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 = repo / ".muse" / "objects" / "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 = repo / ".muse" / "objects"
238 remaining = list(obj_dir.rglob("*"))
239 remaining_files = [p for p in remaining if p.is_file()]
240 assert remaining_files == []
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 135 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 141 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 144 days ago