gabriel / muse public
test_core_gc.py python
235 lines 8.3 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 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
14
15
16 # ---------------------------------------------------------------------------
17 # Helpers
18 # ---------------------------------------------------------------------------
19
20
21 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
22 """Create a minimal .muse repo structure."""
23 muse = tmp_path / ".muse"
24 for d in ("objects", "commits", "snapshots", "refs/heads"):
25 (muse / d).mkdir(parents=True, exist_ok=True)
26 (muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo"}))
27 (muse / "HEAD").write_text("ref: refs/heads/main\n")
28 return tmp_path
29
30
31 def _write_object(repo: pathlib.Path, content: bytes) -> str:
32 import hashlib
33
34 sha = hashlib.sha256(content).hexdigest()
35 obj_dir = repo / ".muse" / "objects" / sha[:2]
36 obj_dir.mkdir(parents=True, exist_ok=True)
37 (obj_dir / sha[2:]).write_bytes(content)
38 return sha
39
40
41 def _write_snapshot(repo: pathlib.Path, manifest: Manifest) -> str:
42 """Write a snapshot with a valid content-hash snapshot_id. Returns the snapshot_id."""
43 snap_id = compute_snapshot_id(manifest)
44 write_snapshot(repo, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
45 return snap_id
46
47
48 def _write_commit(repo: pathlib.Path, snapshot_id: str) -> str:
49 """Write a commit record with a valid content-hash commit_id. Returns the commit_id."""
50 import datetime
51
52 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
53 commit_id = compute_commit_id([], snapshot_id, "test", committed_at.isoformat())
54 write_commit(repo, CommitRecord(
55 commit_id=commit_id,
56 repo_id="test-repo",
57 branch="main",
58 snapshot_id=snapshot_id,
59 message="test",
60 committed_at=committed_at,
61 ))
62 ref_path = repo / ".muse" / "refs" / "heads" / "main"
63 ref_path.parent.mkdir(parents=True, exist_ok=True)
64 ref_path.write_text(commit_id)
65 return commit_id
66
67
68 # ---------------------------------------------------------------------------
69 # Tests
70 # ---------------------------------------------------------------------------
71
72
73 def test_gc_empty_repo(tmp_path: pathlib.Path) -> None:
74 """GC on an empty repo should report 0 collected."""
75 repo = _make_repo(tmp_path)
76 result = run_gc(repo, grace_period_seconds=0)
77 assert isinstance(result, GcResult)
78 assert result.collected_count == 0
79
80
81 def test_gc_removes_unreachable_object(tmp_path: pathlib.Path) -> None:
82 repo = _make_repo(tmp_path)
83 # Write an object but don't reference it in any commit.
84 orphan_id = _write_object(repo, b"orphan data")
85 obj_path = repo / ".muse" / "objects" / orphan_id[:2] / orphan_id[2:]
86 assert obj_path.exists()
87
88 result = run_gc(repo, grace_period_seconds=0)
89 assert result.collected_count == 1
90 assert orphan_id in result.collected_ids
91 assert not obj_path.exists()
92
93
94 def test_gc_preserves_reachable_object(tmp_path: pathlib.Path) -> None:
95 repo = _make_repo(tmp_path)
96 content = b"reachable file content"
97 obj_id = _write_object(repo, content)
98 snap_id = _write_snapshot(repo, {"file.txt": obj_id})
99 _write_commit(repo, snap_id)
100
101 result = run_gc(repo, grace_period_seconds=0)
102 assert result.collected_count == 0
103 obj_path = repo / ".muse" / "objects" / obj_id[:2] / obj_id[2:]
104 assert obj_path.exists()
105
106
107 def test_gc_dry_run_does_not_delete(tmp_path: pathlib.Path) -> None:
108 repo = _make_repo(tmp_path)
109 orphan_id = _write_object(repo, b"orphan")
110 obj_path = repo / ".muse" / "objects" / orphan_id[:2] / orphan_id[2:]
111
112 result = run_gc(repo, dry_run=True, grace_period_seconds=0)
113 assert result.dry_run is True
114 assert result.collected_count == 1
115 # File should still exist.
116 assert obj_path.exists()
117
118
119 def test_gc_collected_bytes(tmp_path: pathlib.Path) -> None:
120 repo = _make_repo(tmp_path)
121 content = b"x" * 1000
122 _write_object(repo, content)
123 result = run_gc(repo, grace_period_seconds=0)
124 assert result.collected_bytes >= 1000
125
126
127 def test_gc_multiple_orphans(tmp_path: pathlib.Path) -> None:
128 repo = _make_repo(tmp_path)
129 for i in range(5):
130 _write_object(repo, f"orphan {i}".encode())
131 result = run_gc(repo, grace_period_seconds=0)
132 assert result.collected_count == 5
133
134
135 def test_gc_mixed_reachable_and_orphans(tmp_path: pathlib.Path) -> None:
136 repo = _make_repo(tmp_path)
137 # One reachable object.
138 reachable_id = _write_object(repo, b"reachable")
139 snap_id = _write_snapshot(repo, {"file.txt": reachable_id})
140 _write_commit(repo, snap_id)
141 # Two orphans.
142 _write_object(repo, b"orphan A")
143 _write_object(repo, b"orphan B")
144
145 result = run_gc(repo, grace_period_seconds=0)
146 assert result.collected_count == 2
147 assert result.reachable_count == 1
148
149
150 def test_gc_elapsed_time_positive(tmp_path: pathlib.Path) -> None:
151 repo = _make_repo(tmp_path)
152 result = run_gc(repo, grace_period_seconds=0)
153 assert result.elapsed_seconds >= 0.0
154
155
156 # ---------------------------------------------------------------------------
157 # Stress test
158 # ---------------------------------------------------------------------------
159
160
161 def test_gc_preserves_stash_objects(tmp_path: pathlib.Path) -> None:
162 """Objects referenced only by stash.json must NOT be GCed.
163
164 This is the critical safety case: `muse stash` writes file blobs to the
165 object store and records their IDs in stash.json. Without walking the
166 stash, a subsequent `muse gc` would delete those blobs and make
167 `muse stash pop` fail with missing objects.
168 """
169 repo = _make_repo(tmp_path)
170 # Simulate stash writing two objects.
171 stash_obj_a = _write_object(repo, b"stashed file A")
172 stash_obj_b = _write_object(repo, b"stashed file B")
173
174 stash_path = repo / ".muse" / "stash.json"
175 stash_path.write_text(json.dumps([{
176 "snapshot_id": "s" * 64,
177 "branch": "main",
178 "stashed_at": "2026-01-01T00:00:00+00:00",
179 "manifest": {"a.py": stash_obj_a, "b.py": stash_obj_b},
180 }]))
181
182 result = run_gc(repo, grace_period_seconds=0)
183 assert result.collected_count == 0, "Stash objects must not be GCed"
184
185 # The blobs must still exist.
186 assert (repo / ".muse" / "objects" / stash_obj_a[:2] / stash_obj_a[2:]).exists()
187 assert (repo / ".muse" / "objects" / stash_obj_b[:2] / stash_obj_b[2:]).exists()
188
189
190 def test_gc_collects_objects_not_in_stash(tmp_path: pathlib.Path) -> None:
191 """Objects that are neither committed nor stashed ARE unreachable and must be GCed."""
192 repo = _make_repo(tmp_path)
193 stash_obj = _write_object(repo, b"stashed")
194 orphan_obj = _write_object(repo, b"truly orphaned")
195
196 stash_path = repo / ".muse" / "stash.json"
197 stash_path.write_text(json.dumps([{
198 "snapshot_id": "s" * 64,
199 "branch": "main",
200 "stashed_at": "2026-01-01T00:00:00+00:00",
201 "manifest": {"a.py": stash_obj},
202 }]))
203
204 result = run_gc(repo, grace_period_seconds=0)
205 assert result.collected_count == 1
206 assert orphan_obj in result.collected_ids
207 assert stash_obj not in result.collected_ids
208
209
210 def test_gc_ignores_stray_non_hex_files_in_objects_dir(tmp_path: pathlib.Path) -> None:
211 """Non-hex filenames in .muse/objects/ are skipped, not mistakenly deleted."""
212 repo = _make_repo(tmp_path)
213 # Create a stray file that should be ignored.
214 stray_dir = repo / ".muse" / "objects" / "ab"
215 stray_dir.mkdir(parents=True, exist_ok=True)
216 stray = stray_dir / ".DS_Store"
217 stray.write_bytes(b"stray")
218
219 result = run_gc(repo, grace_period_seconds=0)
220 assert result.collected_count == 0
221 assert stray.exists(), ".DS_Store should survive GC"
222
223
224 def test_gc_stress_many_orphans(tmp_path: pathlib.Path) -> None:
225 """GC should handle 200 orphaned objects efficiently."""
226 repo = _make_repo(tmp_path)
227 for i in range(200):
228 _write_object(repo, f"orphan-{i:04d}".encode())
229 result = run_gc(repo, grace_period_seconds=0)
230 assert result.collected_count == 200
231 # Verify the objects directory is clean.
232 obj_dir = repo / ".muse" / "objects"
233 remaining = list(obj_dir.rglob("*"))
234 remaining_files = [p for p in remaining if p.is_file()]
235 assert remaining_files == []
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago