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