gabriel / muse public
gc.py python
270 lines 9.2 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 28 days ago
1 """``muse gc`` — garbage-collect unreachable objects.
2
3 Content-addressed storage accumulates blobs that no live commit can reach.
4 These orphaned objects are safe to delete. ``muse gc`` walks the full commit
5 graph from every live branch and tag, marks every referenced object as
6 reachable, then removes the rest.
7
8 A grace period (``--grace-period``, default 30 s) prevents deleting objects
9 written by a concurrent ``muse commit`` that has not yet created its commit
10 record.
11
12 Usage::
13
14 muse gc # remove unreachable objects (30 s grace)
15 muse gc --full # also prune orphaned commits + snapshots
16 muse gc --clear-symbol-cache # delete the symbol cache (forces re-parse)
17 muse gc --dry-run # show what would be removed, touch nothing
18 muse gc --verbose # print each removed object ID
19 muse gc --grace-period 0 # no grace — useful in tests / CI
20 muse gc --json # machine-readable output
21
22 Exit codes::
23
24 0 — success (even if nothing was collected)
25 1 — bad arguments or internal error
26 """
27
28 from __future__ import annotations
29
30 import argparse
31 import json
32 import logging
33 import sys
34 from typing import TypedDict
35
36 from muse.core.errors import ExitCode
37 from muse.core.gc import _DEFAULT_GRACE_PERIOD_SECONDS, run_gc
38 from muse.core.repo import require_repo
39 from muse.core.validation import sanitize_display
40
41 logger = logging.getLogger(__name__)
42
43
44 # ---------------------------------------------------------------------------
45 # Typed JSON schema
46 # ---------------------------------------------------------------------------
47
48
49 class _GcJson(TypedDict):
50 """Machine-readable output of ``muse gc --json``."""
51
52 collected_count: int
53 collected_bytes: int
54 reachable_count: int
55 commits_reachable: int
56 commits_collected: int
57 commits_collected_bytes: int
58 snapshots_reachable: int
59 snapshots_collected: int
60 snapshots_collected_bytes: int
61 elapsed_seconds: float
62 grace_period_seconds: int
63 dry_run: bool
64 full: bool
65 collected_ids: list[str]
66 symbol_cache_cleared: bool
67 symbol_cache_bytes: int
68
69
70 # ---------------------------------------------------------------------------
71 # Helpers
72 # ---------------------------------------------------------------------------
73
74
75 def _fmt_bytes(n: int) -> str:
76 """Human-readable byte count (B / KiB / MiB)."""
77 if n < 1024:
78 return f"{n} B"
79 if n < 1024 * 1024:
80 return f"{n / 1024:.1f} KiB"
81 return f"{n / (1024 * 1024):.1f} MiB"
82
83
84 # ---------------------------------------------------------------------------
85 # Registration
86 # ---------------------------------------------------------------------------
87
88
89 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
90 """Register the ``gc`` subcommand."""
91 parser = subparsers.add_parser(
92 "gc",
93 help="Remove unreachable objects from the Muse object store.",
94 description=__doc__,
95 formatter_class=argparse.RawDescriptionHelpFormatter,
96 )
97 parser.add_argument(
98 "--dry-run", "-n",
99 action="store_true",
100 help="Show what would be removed without deleting anything.",
101 )
102 parser.add_argument(
103 "--verbose", "-v",
104 action="store_true",
105 help="Print each object ID that is (or would be) removed.",
106 )
107 parser.add_argument(
108 "--grace-period",
109 type=int,
110 default=_DEFAULT_GRACE_PERIOD_SECONDS,
111 dest="grace_period",
112 metavar="SECONDS",
113 help=(
114 "Skip objects written within the last SECONDS seconds even if "
115 "unreachable (protects concurrent commits). Default: "
116 f"{_DEFAULT_GRACE_PERIOD_SECONDS}."
117 ),
118 )
119 parser.add_argument(
120 "--format", "-f",
121 default="text",
122 dest="fmt",
123 choices=("text", "json"),
124 help="Output format: text (default) or json.",
125 )
126 parser.add_argument(
127 "--json",
128 action="store_const",
129 const="json",
130 dest="fmt",
131 help="Shorthand for --format json.",
132 )
133 parser.add_argument(
134 "--full",
135 action="store_true",
136 help=(
137 "Also prune orphaned commit records (.muse/commits/) and snapshot "
138 "manifests (.muse/snapshots/), mirroring git prune. Reachability "
139 "is computed from live branch refs, not all files in the store."
140 ),
141 )
142 parser.add_argument(
143 "--clear-symbol-cache",
144 action="store_true",
145 dest="clear_symbol_cache",
146 help=(
147 "Delete the symbol cache (.muse/symbol_cache.msgpack). "
148 "Forces a full re-parse on the next ``muse code`` invocation. "
149 "Use this when the cache is stale — e.g. after installing a new "
150 "language grammar (tree-sitter-markdown, etc.)."
151 ),
152 )
153 parser.set_defaults(func=run)
154
155
156 # ---------------------------------------------------------------------------
157 # Command implementation
158 # ---------------------------------------------------------------------------
159
160
161 def run(args: argparse.Namespace) -> None:
162 """Remove unreachable objects from the Muse object store.
163
164 Muse stores every tracked file as a content-addressed blob. Blobs that are
165 no longer referenced by any commit, snapshot, branch, or tag are *garbage*.
166 This command identifies and removes them, reclaiming disk space.
167
168 The reachability walk always completes before any deletion occurs. Use
169 ``--dry-run`` to preview the impact. Use ``--grace-period 0`` in
170 fully-controlled environments (tests, CI) where concurrent writes are
171 impossible.
172
173 Agents should pass ``--json`` to receive a machine-readable ``_GcJson``
174 result with counts for blobs, commits, and snapshots.
175
176 Examples::
177
178 muse gc # safe cleanup with 30 s grace period
179 muse gc --full # also prune orphaned commits + snapshots
180 muse gc --dry-run # preview only
181 muse gc --verbose # show every object ID
182 muse gc --grace-period 0 # no grace (tests / CI)
183 muse gc --json # machine-readable
184 """
185 dry_run: bool = args.dry_run
186 verbose: bool = args.verbose
187 fmt: str = args.fmt
188 grace_period: int = args.grace_period
189 full: bool = args.full
190 clear_symbol_cache: bool = args.clear_symbol_cache
191
192 if grace_period < 0:
193 print(
194 f"❌ --grace-period must be ≥ 0 (got {grace_period}).",
195 file=sys.stderr,
196 )
197 raise SystemExit(ExitCode.USER_ERROR)
198
199 repo_root = require_repo()
200
201 # --- Symbol cache clear --------------------------------------------------
202 symbol_cache_cleared = False
203 symbol_cache_bytes = 0
204 if clear_symbol_cache:
205 cache_path = repo_root / ".muse" / "symbol_cache.msgpack"
206 if cache_path.is_file():
207 symbol_cache_bytes = cache_path.stat().st_size
208 if not dry_run:
209 cache_path.unlink()
210 symbol_cache_cleared = True
211
212 result = run_gc(
213 repo_root,
214 dry_run=dry_run,
215 grace_period_seconds=grace_period,
216 full=full,
217 )
218
219 if fmt == "json":
220 payload = _GcJson(
221 collected_count=result.collected_count,
222 collected_bytes=result.collected_bytes,
223 reachable_count=result.reachable_count,
224 commits_reachable=result.commits_reachable,
225 commits_collected=result.commits_collected,
226 commits_collected_bytes=result.commits_collected_bytes,
227 snapshots_reachable=result.snapshots_reachable,
228 snapshots_collected=result.snapshots_collected,
229 snapshots_collected_bytes=result.snapshots_collected_bytes,
230 elapsed_seconds=result.elapsed_seconds,
231 grace_period_seconds=result.grace_period_seconds,
232 dry_run=result.dry_run,
233 full=result.full,
234 collected_ids=sorted(result.collected_ids),
235 symbol_cache_cleared=symbol_cache_cleared,
236 symbol_cache_bytes=symbol_cache_bytes,
237 )
238 print(json.dumps(payload))
239 return
240
241 prefix = "[dry-run] " if dry_run else ""
242 action = "Would remove" if dry_run else "Removed"
243
244 if verbose and result.collected_ids:
245 print(f"{prefix}Unreachable objects:")
246 for oid in sorted(result.collected_ids):
247 print(f" {sanitize_display(oid)}")
248
249 print(
250 f"{prefix}{action} {result.collected_count} object(s) "
251 f"({_fmt_bytes(result.collected_bytes)}) "
252 f"in {result.elapsed_seconds:.3f}s "
253 f"[{result.reachable_count} reachable]"
254 )
255
256 if full:
257 print(
258 f"{prefix}{action} {result.commits_collected} commit(s) "
259 f"({_fmt_bytes(result.commits_collected_bytes)}) "
260 f"[{result.commits_reachable} reachable]"
261 )
262 print(
263 f"{prefix}{action} {result.snapshots_collected} snapshot(s) "
264 f"({_fmt_bytes(result.snapshots_collected_bytes)}) "
265 f"[{result.snapshots_reachable} reachable]"
266 )
267
268 if symbol_cache_cleared:
269 cache_action = "Would clear" if dry_run else "Cleared"
270 print(f"{prefix}{cache_action} symbol cache ({_fmt_bytes(symbol_cache_bytes)})")
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 28 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 28 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 31 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 50 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 102 days ago