gabriel / muse public
test_gc_path_helpers_and_remote_refs.py python
386 lines 15.9 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 126 days ago
1 """Tests for GC path-helper correctness and stale remote tracking ref pruning.
2
3 Coverage
4 --------
5
6 Path helpers
7 ~~~~~~~~~~~~
8 - _collect_shelf_objects reads shelf at _shelf_json_path (canonical location)
9 - _collect_reachable_commits reads tags from _tags_dir (canonical location)
10
11 Stale remote tracking ref pruning (prune_stale_remote_refs)
12 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
13 - Stale remote dir (not in configured names) → files deleted, dir removed
14 - Configured remote dir → preserved
15 - Multiple remotes: only stale ones removed
16 - dry_run=True → counted but not deleted
17 - Symlinked remote dir → skipped (not deleted)
18 - Empty stale dir → removed without error
19 - Nested ref layout (remote/branch subdirs) → all files counted and removed
20 - GcResult fields updated correctly (stale_remote_refs_collected, stale_remote_refs_bytes)
21
22 CLI integration
23 ~~~~~~~~~~~~~~~
24 - muse gc --full removes stale remote tracking refs
25 - muse gc --full --json includes stale_remote_refs_collected / stale_remote_refs_bytes
26 - muse gc --full --dry-run counts but does not delete
27 - muse gc (no --full) does NOT remove stale remote refs
28 - muse gc --full --json schema: new fields present even when nothing collected
29 """
30
31 from __future__ import annotations
32
33 import json
34 import pathlib
35
36 import msgpack
37 import pytest
38
39 from muse.core.gc import GcResult, prune_stale_remote_refs, run_gc
40
41 type _EnvDict = dict[str, str]
42 from muse.core.paths import heads_dir, muse_dir, remotes_dir as _remotes_dir, shelf_dir as _shelf_dir, tags_dir as _tags_dir
43 from muse.core.types import blob_id, fake_id, long_id, split_id
44 from muse.core.object_store import write_object as _write_obj
45 from muse.core.snapshot import compute_snapshot_id, compute_commit_id
46 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
47
48 from tests.cli_test_helper import CliRunner, InvokeResult
49
50 cli = None
51 runner = CliRunner()
52
53
54 # ---------------------------------------------------------------------------
55 # Repo fixture helpers
56 # ---------------------------------------------------------------------------
57
58
59 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
60 muse = muse_dir(tmp_path)
61 for sub in ("objects", "commits", "snapshots", "refs/heads", "remotes", "tags"):
62 (muse / sub).mkdir(parents=True, exist_ok=True)
63 (muse / "repo.json").write_text(
64 json.dumps({"repo_id": fake_id("repo"), "domain": "code"}),
65 encoding="utf-8",
66 )
67 (muse / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
68 return tmp_path
69
70
71 def _env(root: pathlib.Path) -> _EnvDict:
72 return {"MUSE_REPO_ROOT": str(root)}
73
74
75 def _write_remote_ref(root: pathlib.Path, remote: str, branch: str, commit_id: str) -> pathlib.Path:
76 """Write a tracking ref file under .muse/remotes/<remote>/<branch>."""
77 ref_dir = _remotes_dir(root) / remote
78 ref_dir.mkdir(parents=True, exist_ok=True)
79 ref_file = ref_dir / branch
80 ref_file.write_text(commit_id, encoding="utf-8")
81 return ref_file
82
83
84 def _make_one_commit(root: pathlib.Path) -> str:
85 """Write a minimal commit and return its commit_id."""
86 import datetime
87 content = b"hello"
88 oid = blob_id(content)
89 _write_obj(root, oid, content)
90 manifest = {"a.py": oid}
91 snap_id = compute_snapshot_id(manifest)
92 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
93 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
94 commit_id = compute_commit_id( parent_ids=[],
95 snapshot_id=snap_id,
96 message="base",
97 committed_at_iso=committed_at.isoformat(),
98 )
99 write_commit(root, CommitRecord(
100 commit_id=commit_id,
101 repo_id=fake_id("repo"),
102 parent_commit_id=None,
103 parent2_commit_id=None,
104 snapshot_id=snap_id,
105 message="base",
106 committed_at=committed_at,
107 branch="main",
108 ))
109 (heads_dir(root) / "main").write_text(commit_id, encoding="utf-8")
110 return commit_id
111
112
113 # ---------------------------------------------------------------------------
114 # Unit — path helper correctness
115 # ---------------------------------------------------------------------------
116
117
118 class TestPathHelpers:
119 def test_shelf_dir_path_is_canonical(self, tmp_path: pathlib.Path) -> None:
120 """_collect_shelf_objects reads entries from _shelf_dir, not a hardcoded path."""
121 root = _make_repo(tmp_path)
122 content = b"shelf-object"
123 oid = blob_id(content)
124 _write_obj(root, oid, content)
125
126 entry = {"snapshot": {"a.txt": oid}, "branch": "main", "created_at": "2026-01-01T00:00:00+00:00"}
127 packed = msgpack.packb(entry, use_bin_type=True)
128 _, hex_id = split_id(blob_id(packed))
129 shelf_entry_dir = _shelf_dir(root) / "sha256"
130 shelf_entry_dir.mkdir(parents=True, exist_ok=True)
131 (shelf_entry_dir / f"{hex_id}.msgpack").write_bytes(packed)
132
133 from muse.core.gc import _collect_reachable_objects
134 reachable = _collect_reachable_objects(root)
135 assert oid in reachable, "Object referenced in shelf entry must be reachable"
136
137 def test_tags_dir_is_canonical(self, tmp_path: pathlib.Path) -> None:
138 """_collect_reachable_commits finds tags under _tags_dir, not a hardcoded path."""
139 root = _make_repo(tmp_path)
140 commit_id = _make_one_commit(root)
141
142 # Remove the branch ref so the commit is only reachable via the tag.
143 (heads_dir(root) / "main").write_text("", encoding="utf-8")
144
145 # Write a tag at the canonical tags dir location.
146 tag_path = _tags_dir(root) / "2026" / "01" / "01" / "my-tag.msgpack"
147 tag_path.parent.mkdir(parents=True, exist_ok=True)
148 tag_path.write_bytes(msgpack.packb({"commit_id": commit_id}, use_bin_type=True))
149
150 from muse.core.gc import _collect_reachable_commits
151 reachable = _collect_reachable_commits(root)
152 bare_id = long_id(commit_id, strip=True)
153 assert bare_id in reachable, "Tag-referenced commit must be reachable via _tags_dir"
154
155
156 # ---------------------------------------------------------------------------
157 # Unit — prune_stale_remote_refs
158 # ---------------------------------------------------------------------------
159
160
161 class TestPruneStaleRemoteRefs:
162 def test_stale_remote_dir_deleted(self, tmp_path: pathlib.Path) -> None:
163 root = _make_repo(tmp_path)
164 ref_file = _write_remote_ref(root, "old-remote", "main", long_id("a" * 64))
165
166 result = GcResult()
167 prune_stale_remote_refs(root, configured_remote_names=set(), result=result, dry_run=False)
168
169 assert not ref_file.exists()
170 assert not (_remotes_dir(root) / "old-remote").exists()
171 assert result.stale_remote_refs_collected == 1
172 assert result.stale_remote_refs_bytes > 0
173
174 def test_configured_remote_preserved(self, tmp_path: pathlib.Path) -> None:
175 root = _make_repo(tmp_path)
176 ref_file = _write_remote_ref(root, "local", "dev", long_id("b" * 64))
177
178 result = GcResult()
179 prune_stale_remote_refs(root, configured_remote_names={"local"}, result=result, dry_run=False)
180
181 assert ref_file.exists(), "Configured remote's tracking ref must be preserved"
182 assert result.stale_remote_refs_collected == 0
183
184 def test_only_stale_remotes_removed(self, tmp_path: pathlib.Path) -> None:
185 root = _make_repo(tmp_path)
186 _write_remote_ref(root, "local", "main", long_id("a" * 64)) # configured
187 _write_remote_ref(root, "staging", "main", long_id("b" * 64)) # configured
188 stale_ref = _write_remote_ref(root, "old", "main", long_id("c" * 64)) # stale
189
190 result = GcResult()
191 prune_stale_remote_refs(
192 root,
193 configured_remote_names={"local", "staging"},
194 result=result,
195 dry_run=False,
196 )
197
198 assert stale_ref.exists() is False
199 assert (_remotes_dir(root) / "local" / "main").exists()
200 assert (_remotes_dir(root) / "staging" / "main").exists()
201 assert result.stale_remote_refs_collected == 1
202
203 def test_dry_run_counts_but_does_not_delete(self, tmp_path: pathlib.Path) -> None:
204 root = _make_repo(tmp_path)
205 ref_file = _write_remote_ref(root, "gone", "main", long_id("d" * 64))
206
207 result = GcResult()
208 prune_stale_remote_refs(root, configured_remote_names=set(), result=result, dry_run=True)
209
210 assert ref_file.exists(), "dry_run must not delete files"
211 assert result.stale_remote_refs_collected == 1
212
213 def test_symlinked_remote_dir_skipped(self, tmp_path: pathlib.Path) -> None:
214 root = _make_repo(tmp_path)
215 real_dir = tmp_path / "real-remote-dir"
216 real_dir.mkdir()
217 (real_dir / "main").write_text(long_id("e" * 64), encoding="utf-8")
218
219 symlink = _remotes_dir(root) / "linked-remote"
220 symlink.symlink_to(real_dir)
221
222 result = GcResult()
223 prune_stale_remote_refs(root, configured_remote_names=set(), result=result, dry_run=False)
224
225 assert symlink.exists(), "Symlinked dir must not be followed or deleted"
226 assert result.stale_remote_refs_collected == 0
227
228 def test_empty_stale_dir_removed(self, tmp_path: pathlib.Path) -> None:
229 root = _make_repo(tmp_path)
230 empty_dir = _remotes_dir(root) / "empty-remote"
231 empty_dir.mkdir()
232
233 result = GcResult()
234 prune_stale_remote_refs(root, configured_remote_names=set(), result=result, dry_run=False)
235
236 assert not empty_dir.exists()
237 assert result.stale_remote_refs_collected == 0
238
239 def test_nested_refs_all_counted(self, tmp_path: pathlib.Path) -> None:
240 """Remote with multiple branches (nested layout) — all ref files counted."""
241 root = _make_repo(tmp_path)
242 remote_dir = _remotes_dir(root) / "old-remote"
243 remote_dir.mkdir()
244 for branch in ("main", "dev", "feat/x"):
245 ref = remote_dir / branch
246 ref.parent.mkdir(parents=True, exist_ok=True)
247 ref.write_text(long_id("f" * 64), encoding="utf-8")
248
249 result = GcResult()
250 prune_stale_remote_refs(root, configured_remote_names=set(), result=result, dry_run=False)
251
252 assert result.stale_remote_refs_collected == 3
253 assert not remote_dir.exists()
254
255 def test_no_remotes_dir_is_noop(self, tmp_path: pathlib.Path) -> None:
256 """Repo with no .muse/remotes/ — prune is a no-op."""
257 root = _make_repo(tmp_path)
258 remotes_root = _remotes_dir(root)
259 if remotes_root.exists():
260 remotes_root.rmdir()
261
262 result = GcResult()
263 prune_stale_remote_refs(root, configured_remote_names=set(), result=result, dry_run=False)
264
265 assert result.stale_remote_refs_collected == 0
266 assert result.stale_remote_refs_bytes == 0
267
268 def test_bytes_counted_correctly(self, tmp_path: pathlib.Path) -> None:
269 root = _make_repo(tmp_path)
270 content = long_id("a" * 64) # known length
271 ref_file = _write_remote_ref(root, "gone", "main", content)
272 expected_bytes = ref_file.stat().st_size
273
274 result = GcResult()
275 prune_stale_remote_refs(root, configured_remote_names=set(), result=result, dry_run=False)
276
277 assert result.stale_remote_refs_bytes == expected_bytes
278
279
280 # ---------------------------------------------------------------------------
281 # CLI integration
282 # ---------------------------------------------------------------------------
283
284
285 class TestCliStaleRemoteRefs:
286 def test_full_removes_stale_remote_refs(
287 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
288 ) -> None:
289 monkeypatch.chdir(tmp_path)
290 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
291
292 from unittest.mock import patch
293 with patch("muse.cli.commands.init.resolve_default_handle", return_value=None):
294 runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
295 runner.invoke(cli, ["commit", "-m", "base", "--allow-empty"], env=_env(tmp_path))
296
297 # Manually plant a stale remote dir (not in config).
298 _write_remote_ref(tmp_path, "deleted-remote", "main", long_id("a" * 64))
299
300 r = runner.invoke(cli, ["gc", "--full", "--grace-period", "0"], env=_env(tmp_path))
301 assert r.exit_code == 0
302 assert not (_remotes_dir(tmp_path) / "deleted-remote").exists()
303
304 def test_full_json_includes_stale_remote_refs_fields(
305 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
306 ) -> None:
307 monkeypatch.chdir(tmp_path)
308 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
309
310 from unittest.mock import patch
311 with patch("muse.cli.commands.init.resolve_default_handle", return_value=None):
312 runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
313 runner.invoke(cli, ["commit", "-m", "base", "--allow-empty"], env=_env(tmp_path))
314 _write_remote_ref(tmp_path, "gone", "main", long_id("b" * 64))
315
316 r = runner.invoke(
317 cli, ["gc", "--full", "--json", "--grace-period", "0"], env=_env(tmp_path)
318 )
319 assert r.exit_code == 0
320 data = json.loads(r.output)
321 assert "stale_remote_refs_collected" in data
322 assert "stale_remote_refs_bytes" in data
323 assert data["stale_remote_refs_collected"] == 1
324
325 def test_full_dry_run_counts_stale_refs(
326 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
327 ) -> None:
328 monkeypatch.chdir(tmp_path)
329 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
330
331 from unittest.mock import patch
332 with patch("muse.cli.commands.init.resolve_default_handle", return_value=None):
333 runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
334 runner.invoke(cli, ["commit", "-m", "base", "--allow-empty"], env=_env(tmp_path))
335 ref_file = _write_remote_ref(tmp_path, "stale", "main", long_id("c" * 64))
336
337 r = runner.invoke(
338 cli,
339 ["gc", "--full", "--dry-run", "--json", "--grace-period", "0"],
340 env=_env(tmp_path),
341 )
342 assert r.exit_code == 0
343 data = json.loads(r.output)
344 assert data["stale_remote_refs_collected"] == 1
345 assert ref_file.exists(), "dry_run must not delete files"
346
347 def test_no_full_does_not_prune_stale_refs(
348 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
349 ) -> None:
350 monkeypatch.chdir(tmp_path)
351 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
352
353 from unittest.mock import patch
354 with patch("muse.cli.commands.init.resolve_default_handle", return_value=None):
355 runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
356 runner.invoke(cli, ["commit", "-m", "base", "--allow-empty"], env=_env(tmp_path))
357 ref_file = _write_remote_ref(tmp_path, "stale", "main", long_id("d" * 64))
358
359 r = runner.invoke(
360 cli, ["gc", "--json", "--grace-period", "0"], env=_env(tmp_path)
361 )
362 assert r.exit_code == 0
363 assert ref_file.exists(), "Without --full, stale refs must not be removed"
364 data = json.loads(r.output)
365 # Fields are present but zero (default GcResult values are not emitted
366 # without --full, so they may be absent — just verify no deletion occurred).
367 assert ref_file.exists()
368
369 def test_full_json_schema_zero_when_nothing_stale(
370 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
371 ) -> None:
372 monkeypatch.chdir(tmp_path)
373 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
374
375 from unittest.mock import patch
376 with patch("muse.cli.commands.init.resolve_default_handle", return_value=None):
377 runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
378 runner.invoke(cli, ["commit", "-m", "base", "--allow-empty"], env=_env(tmp_path))
379
380 r = runner.invoke(
381 cli, ["gc", "--full", "--json", "--grace-period", "0"], env=_env(tmp_path)
382 )
383 assert r.exit_code == 0
384 data = json.loads(r.output)
385 assert data["stale_remote_refs_collected"] == 0
386 assert data["stale_remote_refs_bytes"] == 0
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 126 days ago