gabriel / muse public
gc.py python
358 lines 13.7 KB
Raw
sha256:c0eba5ad689cec79f4a3fcdc4f5da78556cb4b8cb7b330f944634356c10379ed chore: pivot to nightly channel — bump version to 0.2.0.dev… Sonnet 5 patch 12 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 import argparse
29 import json
30 import logging
31 import sys
32 from typing import TypedDict
33
34 from muse.core.envelope import EnvelopeJson, make_envelope
35 from muse.core.paths import symbol_cache_path as _symbol_cache_path
36 from muse.core.errors import ExitCode
37 from muse.core.gc import _DEFAULT_GRACE_PERIOD_SECONDS, _DEFAULT_REFLOG_EXPIRE_DAYS, _DEFAULT_SYMLOG_EXPIRE_DAYS, run_gc, prune_stale_remote_refs
38 from muse.core.repo import require_repo
39 from muse.core.timing import start_timer
40 from muse.core.validation import sanitize_display
41
42 logger = logging.getLogger(__name__)
43
44 # ---------------------------------------------------------------------------
45 # Typed JSON schema
46 # ---------------------------------------------------------------------------
47
48 class _GcJson(EnvelopeJson):
49 """JSON output for ``muse gc --json``.
50
51 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
52 GC warnings (symlink-skip notices, corrupt snapshot alerts, etc.) are surfaced
53 via the envelope ``warnings`` list rather than a separate field.
54
55 Fields
56 ------
57 status "ok" on success; "error" on bad args or internal error.
58 error Empty on success; error message on failure.
59 mode "conservative" (default) or "tight" (--full).
60 collected_count Total objects collected.
61 collected_bytes Total bytes freed.
62 reachable_count Total objects still reachable after GC.
63 commits_reachable Reachable commits.
64 commits_collected Commits collected.
65 commits_collected_bytes Bytes freed from commit objects.
66 snapshots_reachable Reachable snapshots.
67 snapshots_collected Snapshots collected.
68 snapshots_collected_bytes Bytes freed from snapshot objects.
69 grace_period_seconds Grace period used (objects newer than this are kept).
70 dry_run True when no objects were actually deleted.
71 full True when --full (tight mode) was used.
72 collected_ids Sorted list of all collected object IDs.
73 collected_commit_ids Sorted list of collected commit IDs.
74 collected_snapshot_ids Sorted list of collected snapshot IDs.
75 symbol_cache_cleared True when the symbol cache was cleared.
76 symbol_cache_bytes Bytes freed from the symbol cache.
77 """
78
79 status: str
80 error: str
81 mode: str
82 collected_count: int
83 collected_bytes: int
84 reachable_count: int
85 commits_reachable: int
86 commits_collected: int
87 commits_collected_bytes: int
88 snapshots_reachable: int
89 snapshots_collected: int
90 snapshots_collected_bytes: int
91 grace_period_seconds: int
92 dry_run: bool
93 full: bool
94 collected_ids: list[str]
95 collected_commit_ids: list[str]
96 collected_snapshot_ids: list[str]
97 symbol_cache_cleared: bool
98 symbol_cache_bytes: int
99 stale_remote_refs_collected: int
100 stale_remote_refs_bytes: int
101 reflog_expired: int
102 symlog_expired: int
103
104 class _GcErrorJson(EnvelopeJson):
105 """JSON error output for ``muse gc --json`` on invalid arguments."""
106
107 status: str
108 error: str
109
110 # ---------------------------------------------------------------------------
111 # Helpers
112 # ---------------------------------------------------------------------------
113
114 def _fmt_bytes(n: int) -> str:
115 """Human-readable byte count (B / KiB / MiB)."""
116 if n < 1024:
117 return f"{n} B"
118 if n < 1024 * 1024:
119 return f"{n / 1024:.1f} KiB"
120 return f"{n / (1024 * 1024):.1f} MiB"
121
122 # ---------------------------------------------------------------------------
123 # Registration
124 # ---------------------------------------------------------------------------
125
126 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
127 """Register the ``gc`` subcommand."""
128 parser = subparsers.add_parser(
129 "gc",
130 help="Remove unreachable objects from the Muse object store.",
131 description=__doc__,
132 formatter_class=argparse.RawDescriptionHelpFormatter,
133 )
134 parser.add_argument(
135 "--dry-run", "-n",
136 action="store_true",
137 help="Show what would be removed without deleting anything.",
138 )
139 parser.add_argument(
140 "--verbose", "-v",
141 action="store_true",
142 help="Print each object ID that is (or would be) removed.",
143 )
144 parser.add_argument(
145 "--grace-period",
146 type=int,
147 default=_DEFAULT_GRACE_PERIOD_SECONDS,
148 dest="grace_period",
149 metavar="SECONDS",
150 help=(
151 "Skip objects written within the last SECONDS seconds even if "
152 "unreachable (protects concurrent commits). Default: "
153 f"{_DEFAULT_GRACE_PERIOD_SECONDS}."
154 ),
155 )
156 parser.add_argument(
157 "--json", "-j", action="store_true", dest="json_out",
158 help="Emit machine-readable JSON instead of human text.",
159 )
160 parser.add_argument(
161 "--full",
162 action="store_true",
163 help=(
164 "Also prune orphaned commit and snapshot objects from "
165 ".muse/objects/sha256/, mirroring git prune. Reachability "
166 "is computed from live branch refs, not all files in the store."
167 ),
168 )
169 parser.add_argument(
170 "--clear-symbol-cache",
171 action="store_true",
172 dest="clear_symbol_cache",
173 help=(
174 "Delete the symbol cache (.muse/cache/symbols.json). "
175 "Forces a full re-parse on the next ``muse code`` invocation. "
176 "Use this when the cache is stale — e.g. after installing a new "
177 "language grammar (tree-sitter-markdown, etc.)."
178 ),
179 )
180 parser.set_defaults(func=run)
181
182 # ---------------------------------------------------------------------------
183 # Command implementation
184 # ---------------------------------------------------------------------------
185
186 def run(args: argparse.Namespace) -> None:
187 """Remove unreachable objects from the Muse object store.
188
189 Identifies and removes blobs no longer referenced by any commit, snapshot,
190 branch, or tag. The reachability walk always completes before any deletion
191 occurs. Use ``--full`` to also prune orphaned commits and snapshots;
192 ``--dry-run`` to preview without writing.
193
194 Agent quickstart
195 ----------------
196 ::
197
198 muse gc --json
199 muse gc --dry-run --json
200 muse gc --full --json
201 muse gc --grace-period 0 --json
202
203 JSON fields
204 -----------
205 status ``"ok"`` on success.
206 mode ``"tight"`` (``--full``) or ``"conservative"``.
207 collected_count Number of blobs deleted.
208 collected_bytes Bytes reclaimed from blobs.
209 reachable_count Blobs retained as reachable.
210 commits_collected Commits pruned (``--full`` only).
211 snapshots_collected Snapshots pruned (``--full`` only).
212 dry_run ``true`` if no objects were actually deleted.
213
214 Exit codes
215 ----------
216 0 Success.
217 1 Invalid arguments.
218 2 Not inside a Muse repository.
219 3 I/O error reading or writing the object store.
220 """
221 elapsed = start_timer()
222 dry_run: bool = args.dry_run
223 verbose: bool = args.verbose
224 json_out: bool = args.json_out
225 grace_period: int = args.grace_period
226 full: bool = args.full
227 clear_symbol_cache: bool = args.clear_symbol_cache
228
229 if grace_period < 0:
230 if json_out:
231 print(json.dumps(_GcErrorJson(
232 **make_envelope(elapsed, exit_code=ExitCode.USER_ERROR),
233 status="error",
234 error=f"--grace-period must be \u2265 0 (got {grace_period})",
235 )))
236 else:
237 print(
238 f"❌ --grace-period must be ≥ 0 (got {grace_period}).",
239 file=sys.stderr,
240 )
241 raise SystemExit(ExitCode.USER_ERROR)
242
243 repo_root = require_repo()
244
245 # --- Reflog TTL — read from config, fall back to 90 days -----------------
246 from muse.cli.config import get_config_value as _get_config_value
247 _ttl_raw = _get_config_value("reflog.expire-days", repo_root)
248 try:
249 reflog_expire_days: int = int(_ttl_raw) if _ttl_raw is not None else _DEFAULT_REFLOG_EXPIRE_DAYS
250 except (ValueError, TypeError):
251 reflog_expire_days = _DEFAULT_REFLOG_EXPIRE_DAYS
252
253 # --- Symlog TTL — read from config, fall back to 90 days -----------------
254 _sl_ttl_raw = _get_config_value("symlog.expire-days", repo_root)
255 try:
256 symlog_expire_days: int = int(_sl_ttl_raw) if _sl_ttl_raw is not None else _DEFAULT_SYMLOG_EXPIRE_DAYS
257 except (ValueError, TypeError):
258 symlog_expire_days = _DEFAULT_SYMLOG_EXPIRE_DAYS
259
260 # --- Symbol cache clear --------------------------------------------------
261 symbol_cache_cleared = False
262 symbol_cache_bytes = 0
263 if clear_symbol_cache:
264 cache_path = _symbol_cache_path(repo_root)
265 if cache_path.is_file():
266 symbol_cache_bytes = cache_path.stat().st_size
267 if not dry_run:
268 cache_path.unlink()
269 symbol_cache_cleared = True
270
271 result = run_gc(
272 repo_root,
273 dry_run=dry_run,
274 grace_period_seconds=grace_period,
275 full=full,
276 reflog_expire_days=reflog_expire_days,
277 symlog_expire_days=symlog_expire_days,
278 )
279
280 # --- Stale remote tracking ref pruning (--full only) ---------------------
281 if full:
282 from muse.cli.config import list_remotes
283 configured_names = {r["name"] for r in list_remotes(repo_root)}
284 prune_stale_remote_refs(repo_root, configured_names, result, dry_run=dry_run)
285
286 if json_out:
287 print(json.dumps(_GcJson(
288 **make_envelope(elapsed, warnings=result.warnings),
289 status="ok",
290 error="",
291 mode="tight" if full else "conservative",
292 collected_count=result.collected_count,
293 collected_bytes=result.collected_bytes,
294 reachable_count=result.reachable_count,
295 commits_reachable=result.commits_reachable,
296 commits_collected=result.commits_collected,
297 commits_collected_bytes=result.commits_collected_bytes,
298 snapshots_reachable=result.snapshots_reachable,
299 snapshots_collected=result.snapshots_collected,
300 snapshots_collected_bytes=result.snapshots_collected_bytes,
301 grace_period_seconds=result.grace_period_seconds,
302 dry_run=result.dry_run,
303 full=result.full,
304 collected_ids=sorted(result.collected_ids),
305 collected_commit_ids=sorted(result.collected_commit_ids),
306 collected_snapshot_ids=sorted(result.collected_snapshot_ids),
307 symbol_cache_cleared=symbol_cache_cleared,
308 symbol_cache_bytes=symbol_cache_bytes,
309 stale_remote_refs_collected=result.stale_remote_refs_collected,
310 stale_remote_refs_bytes=result.stale_remote_refs_bytes,
311 reflog_expired=result.reflog_expired,
312 symlog_expired=result.symlog_expired,
313 )))
314 return
315
316 prefix = "[dry-run] " if dry_run else ""
317 action = "Would remove" if dry_run else "Removed"
318
319 if verbose and result.collected_ids:
320 print(f"{prefix}Unreachable objects:")
321 for oid in sorted(result.collected_ids):
322 print(f" {sanitize_display(oid)}")
323
324 print(
325 f"{prefix}{action} {result.collected_count} object(s) "
326 f"({_fmt_bytes(result.collected_bytes)}) "
327 f"in {result.duration_ms:.1f}ms "
328 f"[{result.reachable_count} reachable]"
329 )
330
331 if full:
332 print(
333 f"{prefix}{action} {result.commits_collected} commit(s) "
334 f"({_fmt_bytes(result.commits_collected_bytes)}) "
335 f"[{result.commits_reachable} reachable]"
336 )
337 print(
338 f"{prefix}{action} {result.snapshots_collected} snapshot(s) "
339 f"({_fmt_bytes(result.snapshots_collected_bytes)}) "
340 f"[{result.snapshots_reachable} reachable]"
341 )
342 if result.stale_remote_refs_collected:
343 print(
344 f"{prefix}{action} {result.stale_remote_refs_collected} stale remote "
345 f"tracking ref(s) ({_fmt_bytes(result.stale_remote_refs_bytes)})"
346 )
347
348 if symbol_cache_cleared:
349 cache_action = "Would clear" if dry_run else "Cleared"
350 print(f"{prefix}{cache_action} symbol cache ({_fmt_bytes(symbol_cache_bytes)})")
351
352 if result.reflog_expired:
353 reflog_action = "Would expire" if dry_run else "Expired"
354 print(f"{prefix}{reflog_action} {result.reflog_expired} reflog entry/entries")
355
356 if result.symlog_expired:
357 symlog_action = "Would expire" if dry_run else "Expired"
358 print(f"{prefix}{symlog_action} {result.symlog_expired} symlog entry/entries")
File History 10 commits
sha256:c287f599c5429903a139eadf3c5db5d930520e57cb0c3c575d9570e953c3b2d6 chore: bump version to 0.2.0.dev2 — nightly.2 Sonnet 4.6 patch 11 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 15 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9 docs: add issue docs for push have-negotiation bug (#55) an… Sonnet 4.6 18 days ago
sha256:d90d175cded68aae1d4ffcf4858917854195d0cd8ce1fe73cee4dbc02541cb74 chore: trigger push to surface null-OID paths Sonnet 4.6 23 days ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8 chore: prebuild timing test Sonnet 4.6 34 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1 merge: pull staging/dev — advance to 0.2.0rc12 Sonnet 4.6 patch 35 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 47 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 50 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 56 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 56 days ago