gabriel / muse public
prune.py python
308 lines 10.8 KB
Raw
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