prune.py
python
sha256:6ed6b11aceb96bef850842a7aaac54c04843fa9fba5d1200e4bc3845e12d4142
docs: add muse-tag-guide mist (complete idiomatic guide wit…
Sonnet 4.6
19 days ago
| 1 | """``muse prune [--expire <seconds>]`` — surgical removal of unreachable objects. |
| 2 | |
| 3 | Removes objects from the local store that are not reachable from any live ref |
| 4 | (branch tip, tag, shelf entry, or snapshot on disk) without the full overhead |
| 5 | of ``muse gc``. |
| 6 | |
| 7 | ``muse prune`` is the scalpel; ``muse gc`` is the full surgery. Use prune |
| 8 | after an aborted commit, a reset, or an abandoned experiment leaves orphaned |
| 9 | blob objects in the store. |
| 10 | |
| 11 | Reachability |
| 12 | ------------ |
| 13 | An object is **reachable** if it appears as a value in any snapshot manifest |
| 14 | stored in ``.muse/objects/sha256/``. This is a conservative definition: |
| 15 | even orphaned snapshots (not referenced by any live commit) keep their objects |
| 16 | alive. This mirrors the conservative walk in ``muse gc`` and prevents data |
| 17 | loss when refs are being rewritten. |
| 18 | |
| 19 | Shelf entries in ``.muse/shelf.json`` are also walked — objects written during |
| 20 | a ``muse shelf save`` push are always reachable until explicitly dropped. |
| 21 | |
| 22 | Safety guarantees |
| 23 | ----------------- |
| 24 | - ``--dry-run`` (default for agent use) never deletes anything; always preview |
| 25 | first. |
| 26 | - ``--expire <seconds>`` keeps objects newer than the threshold — a safety net |
| 27 | for objects that are mid-flight (written but not yet referenced by a snapshot). |
| 28 | - Refuses to prune if a merge is in progress (conflict-resolution objects must |
| 29 | not be deleted mid-merge). |
| 30 | - Only deletes files under ``.muse/objects/``; never touches the working tree, |
| 31 | commits, snapshots, or any other repo state. |
| 32 | |
| 33 | JSON schema (``--json``) |
| 34 | ------------------------ |
| 35 | |
| 36 | Dry-run:: |
| 37 | |
| 38 | { |
| 39 | "pruned": 42, |
| 40 | "bytes_freed": 18432, |
| 41 | "dry_run": true, |
| 42 | "reachable_count": 100, |
| 43 | "candidates": [{"object_id": "sha256:...", "size": 1024}], |
| 44 | "duration_ms": 1.234, |
| 45 | "exit_code": 0 |
| 46 | } |
| 47 | |
| 48 | Live:: |
| 49 | |
| 50 | { |
| 51 | "pruned": 42, |
| 52 | "bytes_freed": 18432, |
| 53 | "dry_run": false, |
| 54 | "reachable_count": 100, |
| 55 | "duration_ms": 1.234, |
| 56 | "exit_code": 0 |
| 57 | } |
| 58 | |
| 59 | Exit codes:: |
| 60 | |
| 61 | 0 — success (including zero pruned) |
| 62 | 1 — user error (merge in progress) |
| 63 | 2 — not a Muse repository |
| 64 | 3 — I/O error |
| 65 | |
| 66 | Examples:: |
| 67 | |
| 68 | muse prune --dry-run # preview without deleting |
| 69 | muse prune --dry-run --json # machine-readable preview |
| 70 | muse prune # prune unreachable objects |
| 71 | muse prune --expire 3600 # only prune objects older than 1 hour |
| 72 | muse prune --json # prune + JSON result |
| 73 | """ |
| 74 | |
| 75 | import argparse |
| 76 | import json as _json |
| 77 | import logging |
| 78 | import os |
| 79 | import pathlib |
| 80 | import sys |
| 81 | import time |
| 82 | |
| 83 | from typing import TypedDict |
| 84 | |
| 85 | from muse.core.types import long_id |
| 86 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 87 | from muse.core.errors import ExitCode |
| 88 | from muse.core.gc import _collect_reachable_objects |
| 89 | from muse.core.object_store import iter_stored_objects, objects_dir |
| 90 | from muse.core.repo import require_repo |
| 91 | from muse.core.timing import start_timer |
| 92 | |
| 93 | logger = logging.getLogger(__name__) |
| 94 | |
| 95 | # --------------------------------------------------------------------------- |
| 96 | # Wire-format TypedDicts |
| 97 | # --------------------------------------------------------------------------- |
| 98 | |
| 99 | class _PruneCandidateEntry(TypedDict): |
| 100 | object_id: str |
| 101 | size: int |
| 102 | |
| 103 | class _PruneResultBase(EnvelopeJson): |
| 104 | """Common fields for both dry-run and live ``muse prune --json`` output.""" |
| 105 | pruned: int |
| 106 | bytes_freed: int |
| 107 | dry_run: bool |
| 108 | reachable_count: int |
| 109 | |
| 110 | class _PruneResultJson(_PruneResultBase, total=False): |
| 111 | """Full prune result envelope; ``candidates`` present only in dry-run.""" |
| 112 | candidates: list[_PruneCandidateEntry] |
| 113 | |
| 114 | # --------------------------------------------------------------------------- |
| 115 | # Internal helpers |
| 116 | # --------------------------------------------------------------------------- |
| 117 | |
| 118 | def _collect_all_reachable_ids(root: pathlib.Path) -> set[str]: |
| 119 | """Collect all object IDs reachable from any snapshot or shelf entry. |
| 120 | |
| 121 | Delegates to the GC module's conservative walk: every snapshot on disk |
| 122 | (not just those referenced by live commits) is included to prevent |
| 123 | accidental data loss during ref rewrites. |
| 124 | |
| 125 | Args: |
| 126 | root: Absolute repo root. |
| 127 | |
| 128 | Returns: |
| 129 | Set of ``sha256:``-prefixed object IDs reachable from any snapshot or |
| 130 | shelf entry. |
| 131 | """ |
| 132 | return _collect_reachable_objects(root) |
| 133 | |
| 134 | def _find_prune_candidates( |
| 135 | root: pathlib.Path, |
| 136 | reachable: set[str], |
| 137 | expire_before: float | None, |
| 138 | ) -> list[dict]: |
| 139 | """Find unreachable objects that are candidates for pruning. |
| 140 | |
| 141 | Args: |
| 142 | root: Absolute repo root. |
| 143 | reachable: Set of reachable object IDs (will not be deleted). |
| 144 | expire_before: Only include objects whose mtime is older than this |
| 145 | Unix timestamp. ``None`` means include all unreachable |
| 146 | objects regardless of age. |
| 147 | |
| 148 | Returns: |
| 149 | List of ``{"object_id": str, "size": int, "path": str}`` dicts for |
| 150 | each candidate object, sorted by object_id. |
| 151 | """ |
| 152 | candidates: list[_PruneCandidateEntry] = [] |
| 153 | |
| 154 | for oid, obj_file in iter_stored_objects(root): |
| 155 | if oid in reachable: |
| 156 | continue |
| 157 | try: |
| 158 | st = obj_file.stat() |
| 159 | if expire_before is not None and st.st_mtime >= expire_before: |
| 160 | # Object is too recent — skip it. |
| 161 | continue |
| 162 | candidates.append({ |
| 163 | "object_id": oid, |
| 164 | "size": st.st_size, |
| 165 | "path": str(obj_file), |
| 166 | }) |
| 167 | except OSError: |
| 168 | pass |
| 169 | |
| 170 | candidates.sort(key=lambda c: c["object_id"]) |
| 171 | return candidates |
| 172 | |
| 173 | # --------------------------------------------------------------------------- |
| 174 | # Registration |
| 175 | # --------------------------------------------------------------------------- |
| 176 | |
| 177 | def register( |
| 178 | subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]", |
| 179 | ) -> None: |
| 180 | """Register the ``muse prune`` subcommand.""" |
| 181 | parser = subparsers.add_parser( |
| 182 | "prune", |
| 183 | help="Remove unreachable objects from the local store.", |
| 184 | description=__doc__, |
| 185 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 186 | ) |
| 187 | parser.add_argument( |
| 188 | "-n", "--dry-run", |
| 189 | action="store_true", |
| 190 | dest="dry_run", |
| 191 | help=( |
| 192 | "List what would be pruned without deleting anything. " |
| 193 | "Recommended for agent use; always preview before pruning." |
| 194 | ), |
| 195 | ) |
| 196 | parser.add_argument( |
| 197 | "--expire", |
| 198 | type=float, |
| 199 | default=None, |
| 200 | metavar="SECONDS", |
| 201 | help=( |
| 202 | "Only prune objects older than SECONDS ago. " |
| 203 | "Safety net for in-flight objects that are mid-write. " |
| 204 | "E.g. --expire 3600 keeps anything written in the last hour." |
| 205 | ), |
| 206 | ) |
| 207 | parser.add_argument( |
| 208 | "--json", "-j", |
| 209 | action="store_true", |
| 210 | dest="json_out", |
| 211 | help="Emit machine-readable JSON on stdout.", |
| 212 | ) |
| 213 | parser.set_defaults(func=run) |
| 214 | |
| 215 | # --------------------------------------------------------------------------- |
| 216 | # Run |
| 217 | # --------------------------------------------------------------------------- |
| 218 | |
| 219 | def run(args: argparse.Namespace) -> None: |
| 220 | """Prune unreachable objects from the local store. |
| 221 | |
| 222 | Exit codes:: |
| 223 | |
| 224 | 0 — success (including zero pruned) |
| 225 | 1 — merge in progress; prune refused |
| 226 | 2 — not inside a Muse repo |
| 227 | 3 — I/O error |
| 228 | """ |
| 229 | elapsed = start_timer() |
| 230 | dry_run: bool = args.dry_run |
| 231 | expire_secs: float | None = args.expire |
| 232 | json_out: bool = args.json_out |
| 233 | |
| 234 | root = require_repo() |
| 235 | |
| 236 | # ── Refuse if a merge is in progress ───────────────────────────────────── |
| 237 | try: |
| 238 | from muse.core.merge_engine import read_merge_state |
| 239 | if read_merge_state(root) is not None: |
| 240 | print( |
| 241 | "❌ Merge in progress — prune refused. " |
| 242 | "Resolve or abort the merge first.", |
| 243 | file=sys.stderr, |
| 244 | ) |
| 245 | raise SystemExit(ExitCode.USER_ERROR) |
| 246 | except ImportError: |
| 247 | pass # merge_engine optional dep |
| 248 | |
| 249 | # ── Compute expiry threshold ────────────────────────────────────────────── |
| 250 | expire_before: float | None = None |
| 251 | if expire_secs is not None: |
| 252 | expire_before = time.time() - expire_secs |
| 253 | |
| 254 | # ── Collect reachable IDs ───────────────────────────────────────────────── |
| 255 | reachable = _collect_all_reachable_ids(root) |
| 256 | |
| 257 | # ── Find candidates ─────────────────────────────────────────────────────── |
| 258 | candidates = _find_prune_candidates(root, reachable, expire_before) |
| 259 | |
| 260 | # ── Dry-run: report without deleting ───────────────────────────────────── |
| 261 | if dry_run: |
| 262 | if json_out: |
| 263 | print(_json.dumps(_PruneResultJson( |
| 264 | **make_envelope(elapsed), |
| 265 | pruned=len(candidates), |
| 266 | bytes_freed=sum(c["size"] for c in candidates), |
| 267 | dry_run=True, |
| 268 | reachable_count=len(reachable), |
| 269 | candidates=[ |
| 270 | {"object_id": c["object_id"], "size": c["size"]} |
| 271 | for c in candidates |
| 272 | ], |
| 273 | ))) |
| 274 | else: |
| 275 | for c in candidates: |
| 276 | print(f"[dry-run] would prune: {c['object_id']}") |
| 277 | print( |
| 278 | f"[dry-run] {len(candidates)} object(s) " |
| 279 | f"({sum(c['size'] for c in candidates)} bytes) would be pruned." |
| 280 | ) |
| 281 | return |
| 282 | |
| 283 | # ── Delete candidates ───────────────────────────────────────────────────── |
| 284 | pruned = 0 |
| 285 | bytes_freed = 0 |
| 286 | |
| 287 | for candidate in candidates: |
| 288 | try: |
| 289 | os.unlink(candidate["path"]) |
| 290 | pruned += 1 |
| 291 | bytes_freed += candidate["size"] |
| 292 | except FileNotFoundError: |
| 293 | # Concurrent prune — already gone, that's fine. |
| 294 | pass |
| 295 | except OSError as exc: |
| 296 | logger.warning("⚠️ Could not prune %s: %s", candidate["object_id"], exc) |
| 297 | |
| 298 | # ── Output ──────────────────────────────────────────────────────────────── |
| 299 | if json_out: |
| 300 | print(_json.dumps(_PruneResultJson( |
| 301 | **make_envelope(elapsed), |
| 302 | pruned=pruned, |
| 303 | bytes_freed=bytes_freed, |
| 304 | dry_run=False, |
| 305 | reachable_count=len(reachable), |
| 306 | ))) |
| 307 | else: |
| 308 | print(f"Pruned {pruned} object(s), freed {bytes_freed} bytes.") |
File History
1 commit
sha256:6ed6b11aceb96bef850842a7aaac54c04843fa9fba5d1200e4bc3845e12d4142
docs: add muse-tag-guide mist (complete idiomatic guide wit…
Sonnet 4.6
19 days ago