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