gabriel / muse public
test_gc_full.py python
912 lines 38.6 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
1 """Comprehensive tests for ``muse gc --full`` — orphaned commit + snapshot pruning.
2
3 Coverage dimensions
4 -------------------
5
6 Unit
7 ~~~~
8 - ``_collect_reachable_commits``: empty repo, single branch, multi-branch,
9 parent chain traversal, merge commits (2 parents), missing files, corrupt
10 files, symlink guard, cycle resistance, tags included
11 - ``_collect_reachable_snapshots``: snapshot IDs from reachable commits,
12 blob IDs from manifests, shelf objects preserved
13 - ``_list_stored_msgpack``: enumerates files, grace period, symlink guard,
14 non-.msgpack files skipped
15 - ``GcResult``: new fields default to zero
16
17 Integration (run_gc)
18 ~~~~~~~~~~~~~~~~~~~~
19 - Orphaned commit deleted; reachable commit preserved
20 - Orphaned snapshot deleted; reachable snapshot preserved
21 - Orphaned blobs from orphaned commits deleted under --full
22 - dry_run=True never deletes commits or snapshots
23 - Multiple branches: any-branch reachability preserved
24 - Linear commit chain: all intermediates preserved
25 - Merge commit (2 parents): both parent chains preserved
26 - Grace period protects recently-written commits and snapshots
27 - GcResult fields populated correctly
28 - run_gc(full=False) does NOT delete orphaned commits/snapshots
29 - Idempotency: second run collects nothing
30
31 CLI
32 ~~~
33 - ``muse gc --full`` text output has commits + snapshots lines
34 - ``muse gc --full --dry-run`` text output prefixed with [dry-run]
35 - ``muse gc --full --json`` output includes all new fields with correct types
36 - ``muse gc --full --json --dry-run`` dry_run field is True
37 - ``muse gc --full`` without orphans reports 0 collected
38 - ``muse gc --json`` (no --full) schema unchanged — new fields present at 0
39
40 E2E
41 ~~~
42 - Full lifecycle: orphaned commits/snapshots accumulate, gc --full reclaims them
43 - After branch deletion, unique commits/snapshots GCed under --full
44 - Shelf blob objects protected under --full
45 - Rewritten history: old commits removed, new commits preserved
46
47 Security
48 ~~~~~~~~
49 - Symlinked commit file not deleted by --full
50 - Symlinked snapshot file not deleted by --full
51 - Non-.msgpack file in commits dir skipped (not deleted)
52 - Non-.msgpack file in snapshots dir skipped (not deleted)
53
54 Stress
55 ~~~~~~
56 - 200 orphaned commits + 200 orphaned snapshots collected correctly
57 - Deep 100-commit chain: all commits preserved under --full
58 """
59
60 from __future__ import annotations
61
62 type _FileStore = dict[str, bytes]
63
64 import datetime
65 import hashlib
66 import json
67 import os
68 import pathlib
69 import uuid
70
71 import msgpack
72 import pytest
73
74 from muse.core.gc import (
75 GcResult,
76 _collect_reachable_commits,
77 _collect_reachable_snapshots,
78 _list_stored_msgpack,
79 run_gc,
80 )
81 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
82 from muse.core.store import CommitRecord, SnapshotRecord, _commit_path, _snapshot_path, write_commit, write_snapshot
83 from muse.core._types import Manifest, blob_id, long_id
84 from muse.core.object_store import write_object as _write_obj_atomic
85 from muse.core.object_store import object_path
86
87 from tests.cli_test_helper import CliRunner, InvokeResult
88
89 cli = None
90 runner = CliRunner()
91
92 _EPOCH = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
93
94
95 # ---------------------------------------------------------------------------
96 # Helpers
97 # ---------------------------------------------------------------------------
98
99
100 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
101 muse = tmp_path / ".muse"
102 for sub in ("objects", "commits", "snapshots", "refs/heads"):
103 (muse / sub).mkdir(parents=True, exist_ok=True)
104 (muse / "repo.json").write_text(
105 json.dumps({"repo_id": str(uuid.uuid4()), "domain": "code"}),
106 encoding="utf-8",
107 )
108 (muse / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
109 return tmp_path
110
111
112 def _write_object(root: pathlib.Path, content: bytes) -> str:
113 oid = blob_id(content)
114 _write_obj_atomic(root, oid, content)
115 return oid
116
117
118 def _write_snapshot_with_objects(
119 root: pathlib.Path, files: _FileStore
120 ) -> tuple[str, dict[str, str]]:
121 """Write objects + snapshot. Returns (snapshot_id, manifest)."""
122 manifest: Manifest = {}
123 for name, content in files.items():
124 manifest[name] = _write_object(root, content)
125 snap_id = compute_snapshot_id(manifest)
126 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
127 return snap_id, manifest
128
129
130 def _write_commit_record(
131 root: pathlib.Path,
132 snapshot_id: str,
133 *,
134 parent1: str | None = None,
135 parent2: str | None = None,
136 message: str = "test",
137 ts_offset: int = 0,
138 ) -> str:
139 """Write a commit msgpack and return its commit_id."""
140 parent_ids = [p for p in [parent1, parent2] if p]
141 ts = (_EPOCH + datetime.timedelta(seconds=ts_offset)).isoformat()
142 commit_id = compute_commit_id(parent_ids, snapshot_id, message, ts)
143 data = {
144 "commit_id": commit_id,
145 "repo_id": "test-repo",
146 "branch": "main",
147 "snapshot_id": snapshot_id,
148 "message": message,
149 "committed_at": ts,
150 "parent_commit_id": parent1,
151 "parent2_commit_id": parent2,
152 "author": "test",
153 "metadata": {},
154 "format_version": 1,
155 }
156 _commit_path(root, commit_id).write_bytes(msgpack.packb(data, use_bin_type=True))
157 return commit_id
158
159
160 def _set_branch(root: pathlib.Path, branch: str, commit_id: str) -> None:
161 ref_path = root / ".muse" / "refs" / "heads" / branch
162 ref_path.parent.mkdir(parents=True, exist_ok=True)
163 ref_path.write_text(commit_id, encoding="utf-8")
164
165
166 def _make_linear_chain(
167 root: pathlib.Path,
168 length: int,
169 branch: str = "main",
170 ) -> list[str]:
171 """Create a linear chain of *length* commits on *branch*. Returns all commit IDs."""
172 snap_id, _ = _write_snapshot_with_objects(root, {f"f{i}.txt": f"v{i}".encode() for i in range(length)})
173 commit_ids: list[str] = []
174 parent: str | None = None
175 for i in range(length):
176 cid = _write_commit_record(root, snap_id, parent1=parent, message=f"commit {i}", ts_offset=i)
177 commit_ids.append(cid)
178 parent = cid
179 _set_branch(root, branch, commit_ids[-1])
180 return commit_ids
181
182
183 def _env(root: pathlib.Path) -> Manifest:
184 return {"MUSE_REPO_ROOT": str(root)}
185
186
187 def _invoke_gc(root: pathlib.Path, *extra_args: str) -> InvokeResult:
188 args = list(extra_args)
189 if "--grace-period" not in args:
190 args = ["--grace-period", "0"] + args
191 return runner.invoke(cli, ["gc"] + args, env=_env(root), catch_exceptions=False)
192
193
194 # ---------------------------------------------------------------------------
195 # Unit — GcResult new fields default to zero
196 # ---------------------------------------------------------------------------
197
198
199 class TestGcResultDefaults:
200 def test_commits_fields_default_to_zero(self) -> None:
201 r = GcResult()
202 assert r.commits_reachable == 0
203 assert r.commits_collected == 0
204 assert r.commits_collected_bytes == 0
205
206 def test_snapshots_fields_default_to_zero(self) -> None:
207 r = GcResult()
208 assert r.snapshots_reachable == 0
209 assert r.snapshots_collected == 0
210 assert r.snapshots_collected_bytes == 0
211
212 def test_full_field_defaults_to_false(self) -> None:
213 assert GcResult().full is False
214
215
216 # ---------------------------------------------------------------------------
217 # Unit — _collect_reachable_commits
218 # ---------------------------------------------------------------------------
219
220
221 class TestCollectReachableCommits:
222 def test_empty_repo_returns_empty_set(self, tmp_path: pathlib.Path) -> None:
223 root = _make_repo(tmp_path)
224 assert _collect_reachable_commits(root) == set()
225
226 def test_single_branch_single_commit(self, tmp_path: pathlib.Path) -> None:
227 root = _make_repo(tmp_path)
228 snap_id, _ = _write_snapshot_with_objects(root, {"a.py": b"x"})
229 cid = _write_commit_record(root, snap_id)
230 _set_branch(root, "main", cid)
231 assert _collect_reachable_commits(root) == {cid[len("sha256:"):]}
232
233 def test_traverses_parent_chain(self, tmp_path: pathlib.Path) -> None:
234 root = _make_repo(tmp_path)
235 snap_id, _ = _write_snapshot_with_objects(root, {})
236 c1 = _write_commit_record(root, snap_id, message="c1", ts_offset=0)
237 c2 = _write_commit_record(root, snap_id, parent1=c1, message="c2", ts_offset=1)
238 c3 = _write_commit_record(root, snap_id, parent1=c2, message="c3", ts_offset=2)
239 _set_branch(root, "main", c3)
240 reachable = _collect_reachable_commits(root)
241 assert {c1[len("sha256:"):], c2[len("sha256:"):], c3[len("sha256:"):]} == reachable
242
243 def test_merge_commit_both_parents_reachable(self, tmp_path: pathlib.Path) -> None:
244 root = _make_repo(tmp_path)
245 snap_id, _ = _write_snapshot_with_objects(root, {})
246 base = _write_commit_record(root, snap_id, message="base", ts_offset=0)
247 feat = _write_commit_record(root, snap_id, parent1=base, message="feat", ts_offset=1)
248 merge = _write_commit_record(root, snap_id, parent1=base, parent2=feat, message="merge", ts_offset=2)
249 _set_branch(root, "main", merge)
250 reachable = _collect_reachable_commits(root)
251 assert {base[len("sha256:"):], feat[len("sha256:"):], merge[len("sha256:"):]} == reachable
252
253 def test_multiple_branches_union(self, tmp_path: pathlib.Path) -> None:
254 root = _make_repo(tmp_path)
255 snap_id, _ = _write_snapshot_with_objects(root, {})
256 c1 = _write_commit_record(root, snap_id, message="c1", ts_offset=0)
257 c2 = _write_commit_record(root, snap_id, message="c2", ts_offset=1)
258 _set_branch(root, "main", c1)
259 _set_branch(root, "dev", c2)
260 reachable = _collect_reachable_commits(root)
261 assert {c1[len("sha256:"):], c2[len("sha256:"):]} == reachable
262
263 def test_nested_branch_ref_traversed(self, tmp_path: pathlib.Path) -> None:
264 root = _make_repo(tmp_path)
265 snap_id, _ = _write_snapshot_with_objects(root, {})
266 cid = _write_commit_record(root, snap_id)
267 _set_branch(root, "feat/my-feature", cid)
268 reachable = _collect_reachable_commits(root)
269 assert cid[len("sha256:"):] in reachable
270
271 def test_missing_commit_file_skipped(self, tmp_path: pathlib.Path) -> None:
272 root = _make_repo(tmp_path)
273 # Write a branch ref pointing to a commit that doesn't exist in store.
274 ghost_id = "a" * 64
275 _set_branch(root, "main", ghost_id)
276 # Should not raise; ghost_id counted as reachable (it's the tip).
277 reachable = _collect_reachable_commits(root)
278 assert ghost_id in reachable
279
280 def test_corrupt_commit_file_skipped_gracefully(self, tmp_path: pathlib.Path) -> None:
281 root = _make_repo(tmp_path)
282 snap_id, _ = _write_snapshot_with_objects(root, {})
283 cid = _write_commit_record(root, snap_id)
284 _set_branch(root, "main", cid)
285 # Corrupt the commit file.
286 _commit_path(root, cid).write_bytes(b"not msgpack")
287 # Should not raise.
288 reachable = _collect_reachable_commits(root)
289 assert cid[len("sha256:"):] in reachable # tip is still reachable; parents can't be walked
290
291 def test_symlinked_ref_file_skipped(self, tmp_path: pathlib.Path) -> None:
292 root = _make_repo(tmp_path)
293 # Create a symlinked ref file — should be ignored.
294 ref_dir = root / ".muse" / "refs" / "heads"
295 link = ref_dir / "evil"
296 target = tmp_path / "target.txt"
297 target.write_text("a" * 64)
298 link.symlink_to(target)
299 # Shouldn't crash; symlinked ref is not followed.
300 reachable = _collect_reachable_commits(root)
301 assert len(reachable) == 0
302
303 def test_commit_only_in_orphaned_file_not_reachable(self, tmp_path: pathlib.Path) -> None:
304 root = _make_repo(tmp_path)
305 snap_id, _ = _write_snapshot_with_objects(root, {})
306 orphan = _write_commit_record(root, snap_id, message="orphan")
307 # No branch ref points to orphan.
308 reachable = _collect_reachable_commits(root)
309 assert orphan[len("sha256:"):] not in reachable
310
311 def test_diamond_dag_no_duplicate_walk(self, tmp_path: pathlib.Path) -> None:
312 root = _make_repo(tmp_path)
313 snap_id, _ = _write_snapshot_with_objects(root, {})
314 base = _write_commit_record(root, snap_id, message="base", ts_offset=0)
315 left = _write_commit_record(root, snap_id, parent1=base, message="left", ts_offset=1)
316 right = _write_commit_record(root, snap_id, parent1=base, message="right", ts_offset=2)
317 tip = _write_commit_record(root, snap_id, parent1=left, parent2=right, message="tip", ts_offset=3)
318 _set_branch(root, "main", tip)
319 reachable = _collect_reachable_commits(root)
320 assert reachable == {base[len("sha256:"):], left[len("sha256:"):], right[len("sha256:"):], tip[len("sha256:"):]}
321
322
323 # ---------------------------------------------------------------------------
324 # Unit — _collect_reachable_snapshots
325 # ---------------------------------------------------------------------------
326
327
328 class TestCollectReachableSnapshots:
329 def test_returns_snapshot_ids_from_reachable_commits(self, tmp_path: pathlib.Path) -> None:
330 root = _make_repo(tmp_path)
331 snap_id, manifest = _write_snapshot_with_objects(root, {"f.py": b"code"})
332 cid = _write_commit_record(root, snap_id)
333 snaps, objs = _collect_reachable_snapshots(root, {cid[len("sha256:"):]})
334 assert snap_id[len("sha256:"):] in snaps
335
336 def test_returns_blob_ids_from_manifest(self, tmp_path: pathlib.Path) -> None:
337 root = _make_repo(tmp_path)
338 obj_id = _write_object(root, b"file content")
339 snap_id = compute_snapshot_id({"f.py": obj_id})
340 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest={"f.py": obj_id}))
341 cid = _write_commit_record(root, snap_id)
342 _, objs = _collect_reachable_snapshots(root, {cid[len("sha256:"):]})
343 assert obj_id in objs
344
345 def test_empty_reachable_commits_returns_empty(self, tmp_path: pathlib.Path) -> None:
346 root = _make_repo(tmp_path)
347 snaps, objs = _collect_reachable_snapshots(root, set())
348 assert snaps == set()
349 assert objs == set()
350
351 def test_multiple_commits_same_snapshot_deduplicated(self, tmp_path: pathlib.Path) -> None:
352 root = _make_repo(tmp_path)
353 snap_id, _ = _write_snapshot_with_objects(root, {"f": b"x"})
354 c1 = _write_commit_record(root, snap_id, message="c1", ts_offset=0)
355 c2 = _write_commit_record(root, snap_id, message="c2", ts_offset=1)
356 snaps, _ = _collect_reachable_snapshots(root, {c1[len("sha256:"):], c2[len("sha256:"):]})
357 assert snaps == {snap_id[len("sha256:"):]}
358
359 def test_shelf_blobs_included(self, tmp_path: pathlib.Path) -> None:
360 root = _make_repo(tmp_path)
361 shelf_obj = _write_object(root, b"shelved content")
362 shelf_path = root / ".muse" / "shelf.json"
363 shelf_path.write_text(json.dumps([{
364 "snapshot_id": "s" * 64,
365 "branch": "main",
366 "created_at": "2026-01-01T00:00:00+00:00",
367 "snapshot": {"file.py": shelf_obj},
368 }]))
369 _, objs = _collect_reachable_snapshots(root, set())
370 assert shelf_obj in objs
371
372 def test_missing_snapshot_file_skipped(self, tmp_path: pathlib.Path) -> None:
373 root = _make_repo(tmp_path)
374 ghost_snap_id = "b" * 64
375 cid = _write_commit_record(root, ghost_snap_id)
376 # No snapshot file on disk — should not crash.
377 snaps, objs = _collect_reachable_snapshots(root, {cid[len("sha256:"):]})
378 # ghost snapshot is in snaps set but its blobs can't be collected
379 assert ghost_snap_id in snaps
380 assert len(objs) == 0
381
382
383 # ---------------------------------------------------------------------------
384 # Unit — _list_stored_msgpack
385 # ---------------------------------------------------------------------------
386
387
388 class TestListStoredMsgpack:
389 def test_returns_msgpack_files(self, tmp_path: pathlib.Path) -> None:
390 d = tmp_path / "store"
391 d.mkdir()
392 (d / "abc123.msgpack").write_bytes(b"data")
393 (d / "def456.msgpack").write_bytes(b"data2")
394 pairs = _list_stored_msgpack(d, grace_period_seconds=0)
395 stems = {stem for stem, _ in pairs}
396 assert stems == {"abc123", "def456"}
397
398 def test_non_msgpack_files_excluded(self, tmp_path: pathlib.Path) -> None:
399 d = tmp_path / "store"
400 d.mkdir()
401 (d / "abc.json").write_bytes(b"data")
402 (d / "abc.txt").write_bytes(b"data")
403 pairs = _list_stored_msgpack(d, grace_period_seconds=0)
404 assert pairs == []
405
406 def test_symlinked_file_excluded(self, tmp_path: pathlib.Path) -> None:
407 d = tmp_path / "store"
408 d.mkdir()
409 real = tmp_path / "real.msgpack"
410 real.write_bytes(b"data")
411 (d / "linked.msgpack").symlink_to(real)
412 pairs = _list_stored_msgpack(d, grace_period_seconds=0)
413 assert pairs == []
414
415 def test_grace_period_protects_recent_files(self, tmp_path: pathlib.Path) -> None:
416 d = tmp_path / "store"
417 d.mkdir()
418 (d / "recent.msgpack").write_bytes(b"data")
419 pairs = _list_stored_msgpack(d, grace_period_seconds=9999)
420 assert pairs == []
421
422 def test_grace_period_zero_includes_all(self, tmp_path: pathlib.Path) -> None:
423 d = tmp_path / "store"
424 d.mkdir()
425 (d / "old.msgpack").write_bytes(b"data")
426 pairs = _list_stored_msgpack(d, grace_period_seconds=0)
427 assert len(pairs) == 1
428
429 def test_nonexistent_directory_returns_empty(self, tmp_path: pathlib.Path) -> None:
430 pairs = _list_stored_msgpack(tmp_path / "does_not_exist", grace_period_seconds=0)
431 assert pairs == []
432
433
434 # ---------------------------------------------------------------------------
435 # Integration — run_gc(full=True)
436 # ---------------------------------------------------------------------------
437
438
439 class TestRunGcFull:
440 def test_orphaned_commit_deleted(self, tmp_path: pathlib.Path) -> None:
441 root = _make_repo(tmp_path)
442 snap_id, _ = _write_snapshot_with_objects(root, {})
443 orphan = _write_commit_record(root, snap_id, message="orphan")
444 orphan_path = _commit_path(root, orphan)
445 assert orphan_path.exists()
446
447 result = run_gc(root, full=True, grace_period_seconds=0)
448 assert result.commits_collected == 1
449 assert not orphan_path.exists()
450
451 def test_reachable_commit_preserved(self, tmp_path: pathlib.Path) -> None:
452 root = _make_repo(tmp_path)
453 snap_id, _ = _write_snapshot_with_objects(root, {})
454 cid = _write_commit_record(root, snap_id)
455 _set_branch(root, "main", cid)
456 commit_path = _commit_path(root, cid)
457
458 result = run_gc(root, full=True, grace_period_seconds=0)
459 assert result.commits_collected == 0
460 assert commit_path.exists()
461
462 def test_orphaned_snapshot_deleted(self, tmp_path: pathlib.Path) -> None:
463 root = _make_repo(tmp_path)
464 snap_id, _ = _write_snapshot_with_objects(root, {})
465 snap_path = _snapshot_path(root, snap_id)
466 assert snap_path.exists()
467 # No commit references this snapshot.
468
469 result = run_gc(root, full=True, grace_period_seconds=0)
470 assert result.snapshots_collected == 1
471 assert not snap_path.exists()
472
473 def test_reachable_snapshot_preserved(self, tmp_path: pathlib.Path) -> None:
474 root = _make_repo(tmp_path)
475 snap_id, _ = _write_snapshot_with_objects(root, {"f.py": b"code"})
476 cid = _write_commit_record(root, snap_id)
477 _set_branch(root, "main", cid)
478 snap_path = _snapshot_path(root, snap_id)
479
480 result = run_gc(root, full=True, grace_period_seconds=0)
481 assert result.snapshots_collected == 0
482 assert snap_path.exists()
483
484 def test_orphaned_blob_from_orphaned_commit_deleted(self, tmp_path: pathlib.Path) -> None:
485 root = _make_repo(tmp_path)
486 orphan_blob = _write_object(root, b"only in orphaned commit")
487 snap_id = compute_snapshot_id({"f": orphan_blob})
488 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest={"f": orphan_blob}))
489 _write_commit_record(root, snap_id, message="orphan")
490 # No branch ref → orphan commit + snapshot + blob all unreachable.
491 blob_path = object_path(root, orphan_blob)
492
493 result = run_gc(root, full=True, grace_period_seconds=0)
494 assert result.commits_collected == 1
495 assert result.snapshots_collected == 1
496 assert result.collected_count == 1
497 assert not blob_path.exists()
498
499 def test_dry_run_never_deletes(self, tmp_path: pathlib.Path) -> None:
500 root = _make_repo(tmp_path)
501 snap_id, _ = _write_snapshot_with_objects(root, {})
502 orphan = _write_commit_record(root, snap_id, message="orphan")
503
504 result = run_gc(root, full=True, dry_run=True, grace_period_seconds=0)
505 assert result.dry_run is True
506 assert result.commits_collected == 1
507 assert _commit_path(root, orphan).exists()
508 assert _snapshot_path(root, snap_id).exists()
509
510 def test_commit_reachable_from_any_branch_preserved(self, tmp_path: pathlib.Path) -> None:
511 root = _make_repo(tmp_path)
512 snap_id, _ = _write_snapshot_with_objects(root, {})
513 shared_base = _write_commit_record(root, snap_id, message="base", ts_offset=0)
514 tip_main = _write_commit_record(root, snap_id, parent1=shared_base, message="main-tip", ts_offset=1)
515 tip_dev = _write_commit_record(root, snap_id, parent1=shared_base, message="dev-tip", ts_offset=2)
516 _set_branch(root, "main", tip_main)
517 _set_branch(root, "dev", tip_dev)
518
519 result = run_gc(root, full=True, grace_period_seconds=0)
520 assert result.commits_collected == 0
521 for cid in (shared_base, tip_main, tip_dev):
522 assert _commit_path(root, cid).exists()
523
524 def test_linear_chain_all_intermediates_preserved(self, tmp_path: pathlib.Path) -> None:
525 root = _make_repo(tmp_path)
526 commit_ids = _make_linear_chain(root, 10)
527
528 result = run_gc(root, full=True, grace_period_seconds=0)
529 assert result.commits_collected == 0
530 for cid in commit_ids:
531 assert _commit_path(root, cid).exists()
532
533 def test_merge_commit_both_parent_chains_preserved(self, tmp_path: pathlib.Path) -> None:
534 root = _make_repo(tmp_path)
535 snap_id, _ = _write_snapshot_with_objects(root, {})
536 base = _write_commit_record(root, snap_id, message="base", ts_offset=0)
537 left = _write_commit_record(root, snap_id, parent1=base, message="left", ts_offset=1)
538 right = _write_commit_record(root, snap_id, parent1=base, message="right", ts_offset=2)
539 merge = _write_commit_record(root, snap_id, parent1=left, parent2=right, message="merge", ts_offset=3)
540 _set_branch(root, "main", merge)
541
542 result = run_gc(root, full=True, grace_period_seconds=0)
543 assert result.commits_collected == 0
544 for cid in (base, left, right, merge):
545 assert _commit_path(root, cid).exists()
546
547 def test_grace_period_protects_recent_commit(self, tmp_path: pathlib.Path) -> None:
548 root = _make_repo(tmp_path)
549 snap_id, _ = _write_snapshot_with_objects(root, {})
550 orphan = _write_commit_record(root, snap_id)
551 # No branch ref; orphan is unreachable — but grace period protects it.
552 result = run_gc(root, full=True, grace_period_seconds=9999)
553 assert result.commits_collected == 0
554 assert _commit_path(root, orphan).exists()
555
556 def test_grace_period_protects_recent_snapshot(self, tmp_path: pathlib.Path) -> None:
557 root = _make_repo(tmp_path)
558 snap_id, _ = _write_snapshot_with_objects(root, {})
559 result = run_gc(root, full=True, grace_period_seconds=9999)
560 assert result.snapshots_collected == 0
561 assert _snapshot_path(root, snap_id).exists()
562
563 def test_gcresult_commits_reachable_count(self, tmp_path: pathlib.Path) -> None:
564 root = _make_repo(tmp_path)
565 snap_id, _ = _write_snapshot_with_objects(root, {})
566 cid = _write_commit_record(root, snap_id)
567 _set_branch(root, "main", cid)
568
569 result = run_gc(root, full=True, grace_period_seconds=0)
570 assert result.commits_reachable == 1
571 assert result.commits_collected == 0
572
573 def test_gcresult_snapshots_reachable_count(self, tmp_path: pathlib.Path) -> None:
574 root = _make_repo(tmp_path)
575 snap_id, _ = _write_snapshot_with_objects(root, {})
576 cid = _write_commit_record(root, snap_id)
577 _set_branch(root, "main", cid)
578
579 result = run_gc(root, full=True, grace_period_seconds=0)
580 assert result.snapshots_reachable == 1
581 assert result.snapshots_collected == 0
582
583 def test_gcresult_full_flag_set(self, tmp_path: pathlib.Path) -> None:
584 root = _make_repo(tmp_path)
585 result = run_gc(root, full=True, grace_period_seconds=0)
586 assert result.full is True
587
588 def test_without_full_flag_orphaned_commits_not_deleted(self, tmp_path: pathlib.Path) -> None:
589 """Default run_gc (full=False) must NOT prune commits or snapshots."""
590 root = _make_repo(tmp_path)
591 snap_id, _ = _write_snapshot_with_objects(root, {})
592 orphan = _write_commit_record(root, snap_id, message="orphan")
593
594 result = run_gc(root, full=False, grace_period_seconds=0)
595 assert result.commits_collected == 0
596 assert result.snapshots_collected == 0
597 assert _commit_path(root, orphan).exists()
598
599 def test_idempotent_second_run_collects_nothing(self, tmp_path: pathlib.Path) -> None:
600 root = _make_repo(tmp_path)
601 snap_id, _ = _write_snapshot_with_objects(root, {})
602 orphan = _write_commit_record(root, snap_id)
603
604 run_gc(root, full=True, grace_period_seconds=0)
605 result2 = run_gc(root, full=True, grace_period_seconds=0)
606 assert result2.commits_collected == 0
607 assert result2.snapshots_collected == 0
608 assert result2.collected_count == 0
609
610 def test_collected_bytes_nonzero_for_deleted_commit(self, tmp_path: pathlib.Path) -> None:
611 root = _make_repo(tmp_path)
612 snap_id, _ = _write_snapshot_with_objects(root, {})
613 _write_commit_record(root, snap_id, message="orphan")
614
615 result = run_gc(root, full=True, grace_period_seconds=0)
616 assert result.commits_collected_bytes > 0
617
618 def test_collected_bytes_nonzero_for_deleted_snapshot(self, tmp_path: pathlib.Path) -> None:
619 root = _make_repo(tmp_path)
620 snap_id, _ = _write_snapshot_with_objects(root, {"f": b"content"})
621
622 result = run_gc(root, full=True, grace_period_seconds=0)
623 assert result.snapshots_collected_bytes > 0
624
625
626 # ---------------------------------------------------------------------------
627 # CLI integration
628 # ---------------------------------------------------------------------------
629
630
631 class TestCliGcFull:
632 def test_full_text_output_has_three_lines(self, tmp_path: pathlib.Path) -> None:
633 root = _make_repo(tmp_path)
634 r = _invoke_gc(root, "--full")
635 assert r.exit_code == 0
636 lines = [ln for ln in r.output.strip().splitlines() if ln.strip()]
637 assert len(lines) == 3 # objects, commits, snapshots
638
639 def test_full_text_output_contains_commit_line(self, tmp_path: pathlib.Path) -> None:
640 root = _make_repo(tmp_path)
641 r = _invoke_gc(root, "--full")
642 assert "commit" in r.output
643
644 def test_full_text_output_contains_snapshot_line(self, tmp_path: pathlib.Path) -> None:
645 root = _make_repo(tmp_path)
646 r = _invoke_gc(root, "--full")
647 assert "snapshot" in r.output
648
649 def test_full_dry_run_prefix_on_all_lines(self, tmp_path: pathlib.Path) -> None:
650 root = _make_repo(tmp_path)
651 r = _invoke_gc(root, "--full", "--dry-run")
652 assert r.exit_code == 0
653 content_lines = [ln for ln in r.output.strip().splitlines() if ln.strip()]
654 assert all("[dry-run]" in ln for ln in content_lines)
655
656 def test_full_json_includes_commits_fields(self, tmp_path: pathlib.Path) -> None:
657 root = _make_repo(tmp_path)
658 r = _invoke_gc(root, "--full", "--json")
659 assert r.exit_code == 0
660 data = json.loads(r.output.strip())
661 assert "commits_reachable" in data
662 assert "commits_collected" in data
663 assert "commits_collected_bytes" in data
664
665 def test_full_json_includes_snapshots_fields(self, tmp_path: pathlib.Path) -> None:
666 root = _make_repo(tmp_path)
667 r = _invoke_gc(root, "--full", "--json")
668 data = json.loads(r.output.strip())
669 assert "snapshots_reachable" in data
670 assert "snapshots_collected" in data
671 assert "snapshots_collected_bytes" in data
672
673 def test_full_json_field_types(self, tmp_path: pathlib.Path) -> None:
674 root = _make_repo(tmp_path)
675 r = _invoke_gc(root, "--full", "--json")
676 data = json.loads(r.output.strip())
677 assert isinstance(data["commits_reachable"], int)
678 assert isinstance(data["commits_collected"], int)
679 assert isinstance(data["commits_collected_bytes"], int)
680 assert isinstance(data["snapshots_reachable"], int)
681 assert isinstance(data["snapshots_collected"], int)
682 assert isinstance(data["snapshots_collected_bytes"], int)
683 assert isinstance(data["full"], bool)
684
685 def test_full_json_full_field_true(self, tmp_path: pathlib.Path) -> None:
686 root = _make_repo(tmp_path)
687 r = _invoke_gc(root, "--full", "--json")
688 data = json.loads(r.output.strip())
689 assert data["full"] is True
690
691 def test_no_full_json_full_field_false(self, tmp_path: pathlib.Path) -> None:
692 root = _make_repo(tmp_path)
693 r = _invoke_gc(root, "--json")
694 data = json.loads(r.output.strip())
695 assert data["full"] is False
696
697 def test_full_json_dry_run_field(self, tmp_path: pathlib.Path) -> None:
698 root = _make_repo(tmp_path)
699 r = _invoke_gc(root, "--full", "--dry-run", "--json")
700 data = json.loads(r.output.strip())
701 assert data["dry_run"] is True
702
703 def test_full_zero_orphans_reports_zeros(self, tmp_path: pathlib.Path) -> None:
704 root = _make_repo(tmp_path)
705 snap_id, _ = _write_snapshot_with_objects(root, {})
706 cid = _write_commit_record(root, snap_id)
707 _set_branch(root, "main", cid)
708 r = _invoke_gc(root, "--full", "--json")
709 data = json.loads(r.output.strip())
710 assert data["commits_collected"] == 0
711 assert data["snapshots_collected"] == 0
712
713 def test_full_reports_correct_collected_counts(self, tmp_path: pathlib.Path) -> None:
714 root = _make_repo(tmp_path)
715 # 3 orphaned commits, 3 orphaned snapshots
716 for i in range(3):
717 snap_id, _ = _write_snapshot_with_objects(root, {f"f{i}": f"v{i}".encode()})
718 _write_commit_record(root, snap_id, message=f"orphan-{i}", ts_offset=i)
719 r = _invoke_gc(root, "--full", "--json")
720 data = json.loads(r.output.strip())
721 assert data["commits_collected"] == 3
722 assert data["snapshots_collected"] == 3
723
724 def test_no_full_json_schema_unchanged(self, tmp_path: pathlib.Path) -> None:
725 """Without --full, the new fields are present but zero."""
726 root = _make_repo(tmp_path)
727 r = _invoke_gc(root, "--json")
728 data = json.loads(r.output.strip())
729 # Old fields still present.
730 assert "collected_count" in data
731 assert "reachable_count" in data
732 # New fields present but zero.
733 assert data["commits_collected"] == 0
734 assert data["snapshots_collected"] == 0
735
736
737 # ---------------------------------------------------------------------------
738 # E2E tests
739 # ---------------------------------------------------------------------------
740
741
742 class TestGcFullE2E:
743 def test_full_lifecycle_orphans_accumulate_then_freed(self, tmp_path: pathlib.Path) -> None:
744 root = _make_repo(tmp_path)
745
746 # Create a live commit on main.
747 live_snap, _ = _write_snapshot_with_objects(root, {"app.py": b"app code"})
748 live_cid = _write_commit_record(root, live_snap)
749 _set_branch(root, "main", live_cid)
750
751 # Simulate abandoned work: write orphaned commits/snapshots.
752 for i in range(5):
753 snap_id, _ = _write_snapshot_with_objects(root, {f"draft{i}.py": f"draft{i}".encode()})
754 _write_commit_record(root, snap_id, message=f"abandoned-{i}", ts_offset=i + 10)
755
756 result = run_gc(root, full=True, grace_period_seconds=0)
757 assert result.commits_collected == 5
758 assert result.snapshots_collected == 5
759 assert result.commits_reachable == 1
760 assert result.snapshots_reachable == 1
761
762 # Live commit and snapshot still intact.
763 assert _commit_path(root, live_cid).exists()
764 assert _snapshot_path(root, live_snap).exists()
765
766 def test_shelf_blobs_protected_under_full(self, tmp_path: pathlib.Path) -> None:
767 root = _make_repo(tmp_path)
768 shelf_obj = _write_object(root, b"shelved work")
769 shelf_path = root / ".muse" / "shelf.json"
770 shelf_path.write_text(json.dumps([{
771 "snapshot_id": long_id("0b" * 32),
772 "branch": "main",
773 "created_at": "2026-01-01T00:00:00+00:00",
774 "snapshot": {"work.py": shelf_obj},
775 }]))
776
777 result = run_gc(root, full=True, grace_period_seconds=0)
778 assert result.collected_count == 0
779 blob_path = object_path(root, shelf_obj)
780 assert blob_path.exists()
781
782 def test_rewrite_history_old_commits_removed(self, tmp_path: pathlib.Path) -> None:
783 """Simulate a history rewrite: old commits orphaned, new commits on branch."""
784 root = _make_repo(tmp_path)
785 snap1, _ = _write_snapshot_with_objects(root, {"v1.py": b"v1"})
786 old_cid = _write_commit_record(root, snap1, message="old")
787
788 snap2, _ = _write_snapshot_with_objects(root, {"v2.py": b"v2"})
789 new_cid = _write_commit_record(root, snap2, message="new (rewrite)")
790 _set_branch(root, "main", new_cid)
791 # old_cid is now orphaned.
792
793 result = run_gc(root, full=True, grace_period_seconds=0)
794 assert result.commits_collected == 1
795 assert result.snapshots_collected == 1
796 assert not _commit_path(root, old_cid).exists()
797 assert _commit_path(root, new_cid).exists()
798
799 def test_two_branches_then_one_deleted_unique_commits_freed(
800 self, tmp_path: pathlib.Path
801 ) -> None:
802 root = _make_repo(tmp_path)
803 shared_snap, _ = _write_snapshot_with_objects(root, {"base.py": b"base"})
804 base_cid = _write_commit_record(root, shared_snap, message="base", ts_offset=0)
805
806 feat_snap, _ = _write_snapshot_with_objects(root, {"feat.py": b"feat"})
807 feat_cid = _write_commit_record(root, feat_snap, parent1=base_cid, message="feat", ts_offset=1)
808
809 _set_branch(root, "main", base_cid)
810 _set_branch(root, "dev", feat_cid)
811
812 # "Delete" feat branch by removing its ref.
813 (root / ".muse" / "refs" / "heads" / "dev").unlink()
814
815 result = run_gc(root, full=True, grace_period_seconds=0)
816 # feat_cid unique to dev is now orphaned.
817 assert result.commits_collected == 1
818 assert not _commit_path(root, feat_cid).exists()
819 # base_cid still on main is preserved.
820 assert _commit_path(root, base_cid).exists()
821
822
823 # ---------------------------------------------------------------------------
824 # Security tests
825 # ---------------------------------------------------------------------------
826
827
828 class TestGcFullSecurity:
829 def test_symlinked_commit_file_not_deleted(self, tmp_path: pathlib.Path) -> None:
830 root = _make_repo(tmp_path)
831 real_file = tmp_path / "real_commit.msgpack"
832 real_file.write_bytes(msgpack.packb({"commit_id": "a" * 64}, use_bin_type=True))
833 link = root / ".muse" / "commits" / "linked.msgpack"
834 link.symlink_to(real_file)
835
836 run_gc(root, full=True, grace_period_seconds=0)
837 assert real_file.exists(), "Target of symlink must not be deleted"
838
839 def test_symlinked_snapshot_file_not_deleted(self, tmp_path: pathlib.Path) -> None:
840 root = _make_repo(tmp_path)
841 real_file = tmp_path / "real_snap.msgpack"
842 real_file.write_bytes(msgpack.packb({"snapshot_id": "b" * 64}, use_bin_type=True))
843 link = root / ".muse" / "snapshots" / "linked.msgpack"
844 link.symlink_to(real_file)
845
846 run_gc(root, full=True, grace_period_seconds=0)
847 assert real_file.exists(), "Target of snapshot symlink must not be deleted"
848
849 def test_non_msgpack_file_in_commits_dir_not_deleted(self, tmp_path: pathlib.Path) -> None:
850 root = _make_repo(tmp_path)
851 stray = root / ".muse" / "commits" / "README.txt"
852 stray.write_text("not a commit")
853
854 run_gc(root, full=True, grace_period_seconds=0)
855 assert stray.exists()
856
857 def test_non_msgpack_file_in_snapshots_dir_not_deleted(self, tmp_path: pathlib.Path) -> None:
858 root = _make_repo(tmp_path)
859 stray = root / ".muse" / "snapshots" / ".DS_Store"
860 stray.write_bytes(b"junk")
861
862 run_gc(root, full=True, grace_period_seconds=0)
863 assert stray.exists()
864
865
866 # ---------------------------------------------------------------------------
867 # Stress tests
868 # ---------------------------------------------------------------------------
869
870
871 class TestGcFullStress:
872 def test_200_orphaned_commits_and_snapshots_collected(self, tmp_path: pathlib.Path) -> None:
873 root = _make_repo(tmp_path)
874 # Live commit that must survive.
875 live_snap, _ = _write_snapshot_with_objects(root, {"live.py": b"live"})
876 live_cid = _write_commit_record(root, live_snap)
877 _set_branch(root, "main", live_cid)
878
879 # 200 orphaned commit+snapshot pairs.
880 for i in range(200):
881 snap_id, _ = _write_snapshot_with_objects(root, {f"f{i}.py": f"v{i}".encode()})
882 _write_commit_record(root, snap_id, message=f"orphan-{i}", ts_offset=i + 1)
883
884 result = run_gc(root, full=True, grace_period_seconds=0)
885 assert result.commits_collected == 200
886 assert result.snapshots_collected == 200
887 assert result.commits_reachable == 1
888 assert result.snapshots_reachable == 1
889 # Live commit and snapshot intact.
890 assert _commit_path(root, live_cid).exists()
891 assert _snapshot_path(root, live_snap).exists()
892
893 def test_deep_100_commit_chain_all_preserved(self, tmp_path: pathlib.Path) -> None:
894 root = _make_repo(tmp_path)
895 commit_ids = _make_linear_chain(root, 100)
896
897 result = run_gc(root, full=True, grace_period_seconds=0)
898 assert result.commits_collected == 0
899 assert result.commits_reachable == 100
900 for cid in commit_ids:
901 assert _commit_path(root, cid).exists()
902
903 def test_50_branches_all_commits_preserved(self, tmp_path: pathlib.Path) -> None:
904 root = _make_repo(tmp_path)
905 snap_id, _ = _write_snapshot_with_objects(root, {})
906 for i in range(50):
907 cid = _write_commit_record(root, snap_id, message=f"branch-{i}", ts_offset=i)
908 _set_branch(root, f"feat/branch-{i:02d}", cid)
909
910 result = run_gc(root, full=True, grace_period_seconds=0)
911 assert result.commits_collected == 0
912 assert result.commits_reachable == 50
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago