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