gabriel / muse public
gc.py python
674 lines 26.7 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 120 days ago
1 """Garbage collection — prune unreachable objects from the object store.
2
3 Muse uses a content-addressed object store: every file snapshot is stored as a
4 SHA-256-addressed blob under ``.muse/objects/``. Over time, after branch
5 deletions, rebases, and abandoned experiments, objects that are no longer
6 reachable from any live commit accumulate. This module identifies and removes
7 them.
8
9 Reachability
10 ------------
11 An object is *reachable* if it can be reached by following the graph from any
12 live ref (branch HEAD, tag, or the current HEAD):
13
14 branch HEAD → CommitRecord → SnapshotRecord → manifest → object SHA-256
15
16 Any object not in the reachable set is *loose garbage* and is safe to delete.
17
18 ``--full`` mode
19 ---------------
20 By default ``muse gc`` only prunes file-content blobs (``.muse/objects/``).
21 With ``--full`` it also prunes:
22
23 - Orphaned commit records (``.muse/commits/*.msgpack``) — commits not reachable
24 from any live branch ref or tag by following ``parent_commit_id`` links.
25 - Orphaned snapshot manifests (``.muse/snapshots/*.msgpack``) — snapshots not
26 referenced by any reachable commit.
27
28 This mirrors ``git prune`` / ``git gc``, which removes all unreachable loose
29 objects regardless of type.
30
31 Safety
32 ------
33 The GC walk is always performed **before** any deletion. The ``dry_run``
34 option shows what *would* be deleted without touching the store, making it safe
35 to run frequently in CI or by agents to estimate bloat.
36
37 A ``grace_period_seconds`` guard (default 30 s) prevents deleting objects that
38 were written within the last N seconds. This closes the TOCTOU window where a
39 concurrent ``muse commit`` has written a blob to the object store but not yet
40 created the commit record — without the grace period, GC would see the blob as
41 unreachable and delete it mid-commit, causing data loss.
42
43 Symlink safety
44 --------------
45 Every prefix directory and object file is checked with ``is_symlink()`` before
46 being treated as a real object. A crafted symlink inside ``.muse/objects/``
47 cannot cause GC to read or delete files outside the repository.
48
49 Return value
50 ------------
51 ``GcResult`` is a typed dataclass with integer counts and the list of collected
52 IDs. The CLI command renders it and can expose it as JSON.
53 """
54
55 import json
56 import logging
57 import pathlib
58 import time
59 from collections import deque
60 from dataclasses import dataclass, field
61
62 import msgpack
63
64 from muse.core.types import long_id
65 from muse.core.paths import (
66 snapshots_dir as _snapshots_dir,
67 commits_dir as _commits_dir,
68 tags_dir as _tags_dir,
69 heads_dir as _heads_dir,
70 shelf_dir as _shelf_dir,
71 remotes_dir as _remotes_dir,
72 )
73 from muse.core.object_store import iter_stored_objects
74 from muse.core.refs import iter_branch_refs
75 from muse.core.store import MAX_MSGPACK_BYTES, commit_path as _commit_path, get_all_commits, read_snapshot, snapshot_path as _snapshot_path, zstd_decompress_if_needed as _zstd_decompress_if_needed
76 from muse.core.timing import start_timer
77
78 logger = logging.getLogger(__name__)
79
80 _MAX_SHELF_BYTES: int = MAX_MSGPACK_BYTES
81
82 # Default number of seconds to protect recently-written objects from GC.
83 # See the "Safety" section of the module docstring.
84 _DEFAULT_GRACE_PERIOD_SECONDS: int = 30
85
86 # ---------------------------------------------------------------------------
87 # Result type
88 # ---------------------------------------------------------------------------
89
90 @dataclass
91 class GcResult:
92 """Statistics from one garbage-collection pass."""
93
94 # Blob store (.muse/objects/)
95 reachable_count: int = 0
96 collected_count: int = 0
97 collected_bytes: int = 0
98
99 # Commit store (.muse/commits/) — populated only with --full
100 commits_reachable: int = 0
101 commits_collected: int = 0
102 commits_collected_bytes: int = 0
103
104 # Snapshot store (.muse/snapshots/) — populated only with --full
105 snapshots_reachable: int = 0
106 snapshots_collected: int = 0
107 snapshots_collected_bytes: int = 0
108
109 # Remote tracking refs (.muse/remotes/) — populated only with --full
110 stale_remote_refs_collected: int = 0
111 stale_remote_refs_bytes: int = 0
112
113 duration_ms: float = 0.0
114 grace_period_seconds: int = _DEFAULT_GRACE_PERIOD_SECONDS
115 collected_ids: list[str] = field(default_factory=list)
116 collected_commit_ids: list[str] = field(default_factory=list)
117 collected_snapshot_ids: list[str] = field(default_factory=list)
118 warnings: list[str] = field(default_factory=list)
119 dry_run: bool = False
120 full: bool = False
121
122 # ---------------------------------------------------------------------------
123 # Reachability walk — blobs (conservative: all commits in store)
124 # ---------------------------------------------------------------------------
125
126 def _collect_reachable_objects(
127 repo_root: pathlib.Path,
128 ) -> set[str]:
129 """Return the set of all object SHA-256 IDs reachable from any live ref.
130
131 Uses the conservative walk (all commits in the store) so that orphaned
132 commits don't cause their blobs to be deleted when ``--full`` is not set.
133
134 Sources walked:
135
136 - Every snapshot file stored in ``.muse/snapshots/`` — raw manifest values
137 are read without hash verification so that objects are retained even when
138 a commit's ``snapshot_id`` field is corrupt (bit-flip or earlier store bug).
139 This is conservative: orphaned snapshots (not referenced by any commit)
140 keep their objects alive until they themselves are pruned by ``muse gc
141 --full``.
142 - Every entry in ``.muse/shelf.json`` (shelved work writes objects before
143 the shelf entry is committed, so they would otherwise be GCed).
144
145 The shelf file is protected by a symlink guard and a size cap to match the
146 defences in ``muse shelf`` itself.
147 """
148 reachable: set[str] = set()
149
150 # ── All snapshot files (raw, no hash verification) ────────────────────────
151 # Reading raw manifests (bypassing _verify_snapshot_id) is intentional here:
152 # GC must never delete an object that is referenced in ANY snapshot on disk,
153 # even a corrupt or orphaned one. If we relied on read_snapshot (which
154 # verifies the hash), a commit with a corrupt snapshot_id field pointing to a
155 # non-existent snapshot would cause its objects to be silently GCed.
156 snapshots_dir = _snapshots_dir(repo_root)
157 if snapshots_dir.is_dir():
158 for snap_path in snapshots_dir.glob("*/*.msgpack"):
159 # Symlink guard: skip symlinks to prevent traversal attacks.
160 if snap_path.is_symlink():
161 logger.warning("⚠️ gc: skipping symlink snapshot file %s", snap_path.name)
162 continue
163 try:
164 raw_data = msgpack.unpackb(_zstd_decompress_if_needed(snap_path.read_bytes()), raw=False)
165 manifest = raw_data.get("manifest", {})
166 if isinstance(manifest, dict):
167 for oid in manifest.values():
168 if isinstance(oid, str) and oid:
169 reachable.add(long_id(oid))
170 except Exception as exc:
171 logger.debug("gc: could not read snapshot %s — skipped: %s", snap_path.name, exc)
172
173 # ── Shelf ─────────────────────────────────────────────────────────────────
174 _collect_shelf_objects(repo_root, reachable)
175
176 return reachable
177
178 def _collect_shelf_objects(repo_root: pathlib.Path, reachable: set[str]) -> None:
179 """Add object IDs from per-entry shelf msgpack files into *reachable* in-place.
180
181 Globs ``.muse/shelf/<algo>/*.msgpack`` — one file per entry — matching the
182 layout used by commits and snapshots. Corrupt, oversized, or symlinked
183 files are skipped with a warning so a single bad entry never blocks GC.
184 """
185 from muse.core.store import safe_unpackb
186 shelf = _shelf_dir(repo_root)
187 if not shelf.is_dir():
188 return
189 for entry_path in shelf.glob("*/*.msgpack"):
190 if entry_path.is_symlink():
191 logger.warning("⚠️ shelf entry %s is a symlink — skipping during GC walk", entry_path.name)
192 continue
193 try:
194 size = entry_path.stat().st_size
195 if size > _MAX_SHELF_BYTES:
196 logger.warning("⚠️ shelf entry %s exceeds size limit — skipping", entry_path.name[:24])
197 continue
198 data = safe_unpackb(entry_path.read_bytes())
199 if not isinstance(data, dict):
200 continue
201 snapshot = data.get("snapshot")
202 if isinstance(snapshot, dict):
203 for object_id in snapshot.values():
204 if isinstance(object_id, str):
205 reachable.add(object_id)
206 except OSError as exc:
207 logger.warning(
208 "⚠️ Could not read shelf entry %s during GC walk: %s",
209 entry_path.name[:24], exc,
210 )
211
212 # ---------------------------------------------------------------------------
213 # Reachability walk — commits + snapshots (for --full)
214 # ---------------------------------------------------------------------------
215
216 def _strip_prefix(id_str: str) -> str:
217 """Return the bare hex ID, stripping any ``sha256:`` prefix.
218
219 GC uses bare hex as the canonical form because that is what the filesystem
220 stores (``commits/<hex>.msgpack``, ``snapshots/<hex>.msgpack``). All IDs
221 read from ref files, commit records, and tag records must be normalised
222 through this function before being added to reachable sets or used to
223 build file paths.
224 """
225 return long_id(id_str, strip=True)
226
227 def _collect_reachable_commits(repo_root: pathlib.Path) -> set[str]:
228 """Return all commit IDs reachable from any live branch ref or tag.
229
230 IDs are stored as bare hex (no ``sha256:`` prefix) because they are used
231 only to match the file stems returned by ``_list_stored_msgpack``
232 (``commits/<hex>.msgpack``, ``snapshots/<hex>.msgpack``).
233
234 Walks ``parent_commit_id`` and ``parent2_commit_id`` links in each commit
235 record (read as raw msgpack to survive any schema evolution). Corrupt or
236 missing files are skipped with a warning — they are not added to the
237 reachable set but are also not deleted (the grace period covers the window).
238 """
239 reachable: set[str] = set()
240 commits_dir = _commits_dir(repo_root)
241 if not commits_dir.exists():
242 return reachable
243
244 # Collect all live tips: branch refs + tags
245 tips: list[str] = [
246 _strip_prefix(cid) for _, cid in iter_branch_refs(repo_root) if cid
247 ]
248
249 tags_dir = _tags_dir(repo_root)
250 if tags_dir.exists():
251 for tag_path in tags_dir.glob("*/*/*/*.msgpack"):
252 if tag_path.is_symlink() or not tag_path.is_file():
253 continue
254 try:
255 tag_data = msgpack.unpackb(tag_path.read_bytes(), raw=False)
256 cid = tag_data.get("commit_id") or tag_data.get("target")
257 if cid:
258 tips.append(_strip_prefix(cid))
259 except Exception:
260 pass
261
262 # BFS through parent links
263 queue: deque[str] = deque(tips)
264 while queue:
265 cid = queue.popleft()
266 if cid in reachable:
267 continue
268 reachable.add(cid)
269
270 commit_path = _commit_path(repo_root, long_id(cid))
271 if not commit_path.exists() or commit_path.is_symlink():
272 continue
273 try:
274 data = msgpack.unpackb(commit_path.read_bytes(), raw=False)
275 except Exception as exc:
276 logger.warning("⚠️ Could not read commit %s during GC walk: %s", cid, exc)
277 continue
278
279 p1 = data.get("parent_commit_id")
280 p2 = data.get("parent2_commit_id")
281 if p1:
282 queue.append(_strip_prefix(p1))
283 if p2:
284 queue.append(_strip_prefix(p2))
285
286 return reachable
287
288 def _collect_reachable_snapshots(
289 repo_root: pathlib.Path,
290 reachable_commits: set[str],
291 ) -> tuple[set[str], set[str]]:
292 """Return ``(reachable_snapshot_ids, reachable_object_ids)`` from reachable commits.
293
294 Also folds in any snapshot IDs referenced by shelf entries.
295 """
296 reachable_snaps: set[str] = set()
297 reachable_objs: set[str] = set()
298
299 for cid in reachable_commits:
300 commit_path = _commit_path(repo_root, long_id(cid))
301 if not commit_path.exists() or commit_path.is_symlink():
302 continue
303 try:
304 data = msgpack.unpackb(commit_path.read_bytes(), raw=False)
305 except Exception:
306 continue
307 snap_id_raw = data.get("snapshot_id")
308 if not snap_id_raw:
309 continue
310 # Normalise to bare hex so it matches _list_stored_msgpack stems.
311 snap_id = _strip_prefix(snap_id_raw)
312 reachable_snaps.add(snap_id)
313 snap = read_snapshot(repo_root, snap_id_raw)
314 if snap is not None:
315 for object_id in snap.manifest.values():
316 reachable_objs.add(object_id)
317 else:
318 # read_snapshot returned None — the snapshot file may be corrupt
319 # (hash verification failed) but still contains valid object IDs.
320 # Read the raw manifest without verification to retain its objects:
321 # it is always safer to keep objects than to silently lose them.
322 snap_path = _snapshot_path(repo_root, long_id(snap_id))
323 if snap_path.is_file() and not snap_path.is_symlink():
324 try:
325 raw_snap = msgpack.unpackb(_zstd_decompress_if_needed(snap_path.read_bytes()), raw=False)
326 manifest = raw_snap.get("manifest", {})
327 if isinstance(manifest, dict):
328 for oid in manifest.values():
329 if isinstance(oid, str) and oid:
330 reachable_objs.add(oid)
331 logger.critical(
332 "❌ gc: snapshot %s failed hash verification — "
333 "its objects were retained conservatively. "
334 "Run `muse verify-pack` to audit the store.",
335 snap_id,
336 )
337 except Exception as exc:
338 logger.critical(
339 "❌ gc: could not read corrupt snapshot %s — "
340 "some objects may have been lost: %s",
341 snap_id, exc,
342 )
343
344 # Shelf snapshot IDs
345 _collect_shelf_objects(repo_root, reachable_objs)
346
347 return reachable_snaps, reachable_objs
348
349 # ---------------------------------------------------------------------------
350 # Store enumeration helpers
351 # ---------------------------------------------------------------------------
352
353 _HEX_CHARS: frozenset[str] = frozenset("0123456789abcdef")
354
355 def _is_hex(s: str) -> bool:
356 """Return True iff every character of *s* is a lowercase hex digit."""
357 return bool(s) and all(c in _HEX_CHARS for c in s)
358
359 def _list_stored_objects(
360 repo_root: pathlib.Path,
361 *,
362 grace_period_seconds: int = _DEFAULT_GRACE_PERIOD_SECONDS,
363 ) -> list[tuple[str, pathlib.Path]]:
364 """Return ``(prefixed_object_id, path)`` for every valid SHA-256 object in the store.
365
366 Only **real files** (not symlinks) whose name + parent prefix form a
367 64-char lowercase hex string are included. Stray files (editor temporaries,
368 macOS ``.DS_Store``, etc.) are silently skipped.
369
370 Symlink guard
371 ~~~~~~~~~~~~~
372 Both prefix directories and object files are rejected if they are symlinks.
373 Without this guard a crafted symlink inside ``.muse/objects/`` could cause
374 GC to ``unlink()`` a file anywhere on the filesystem — a silent data
375 destruction vector.
376
377 Grace period
378 ~~~~~~~~~~~~
379 Objects whose ``mtime`` is within *grace_period_seconds* seconds of now are
380 excluded from the returned list even if they are not currently reachable.
381 This closes the TOCTOU window where a concurrent ``muse commit`` has written
382 a blob but not yet created the commit record.
383
384 Args:
385 repo_root: Repository root (contains ``.muse/``).
386 grace_period_seconds: Number of seconds before an object becomes
387 eligible for collection. Default: 30 s.
388 """
389 cutoff = time.time() - grace_period_seconds
390 pairs: list[tuple[str, pathlib.Path]] = []
391
392 for oid, obj_file in iter_stored_objects(repo_root):
393 # Grace period: protect objects written in the last N seconds.
394 try:
395 if obj_file.stat().st_mtime > cutoff:
396 continue
397 except OSError:
398 # File disappeared between iterdir() and stat() — skip.
399 continue
400 pairs.append((oid, obj_file))
401
402 return pairs
403
404 def _list_stored_msgpack(
405 directory: pathlib.Path,
406 *,
407 grace_period_seconds: int = _DEFAULT_GRACE_PERIOD_SECONDS,
408 ) -> list[tuple[str, pathlib.Path]]:
409 """Return ``(stem, path)`` for every non-symlink ``.msgpack`` file in *directory*.
410
411 Applies the same grace-period and symlink guards as ``_list_stored_objects``.
412 """
413 if not directory.exists():
414 return []
415
416 cutoff = time.time() - grace_period_seconds
417 pairs: list[tuple[str, pathlib.Path]] = []
418
419 for p in directory.glob("*/*.msgpack"):
420 if p.is_symlink() or not p.is_file():
421 continue
422 try:
423 if p.stat().st_mtime > cutoff:
424 continue
425 except OSError:
426 continue
427 pairs.append((p.stem, p))
428
429 return pairs
430
431 # ---------------------------------------------------------------------------
432 # Public API
433 # ---------------------------------------------------------------------------
434
435 def run_gc(
436 repo_root: pathlib.Path,
437 *,
438 dry_run: bool = False,
439 grace_period_seconds: int = _DEFAULT_GRACE_PERIOD_SECONDS,
440 full: bool = False,
441 ) -> GcResult:
442 """Prune unreachable objects from the Muse object store.
443
444 Args:
445 repo_root: Root of the Muse repository (``.muse/`` lives here).
446 dry_run: When ``True``, report what *would* be deleted
447 without actually removing anything.
448 grace_period_seconds: Objects written within the last N seconds are
449 never deleted, even if currently unreachable.
450 Protects concurrent ``muse commit`` operations.
451 Default: 30 s.
452 full: When ``True``, also prune orphaned commit records
453 (``.muse/commits/``) and snapshot manifests
454 (``.muse/snapshots/``), mirroring ``git prune``.
455
456 Returns:
457 A ``GcResult`` with counts and the list of collected object IDs.
458 """
459 elapsed = start_timer()
460 result = GcResult(dry_run=dry_run, grace_period_seconds=grace_period_seconds, full=full)
461
462 # Capture warning log messages into result.warnings so callers (e.g. --json
463 # output) can surface them without scraping stderr.
464 class _WarningCapture(logging.Handler):
465 def emit(self, record: logging.LogRecord) -> None:
466 if record.levelno >= logging.WARNING:
467 result.warnings.append(self.format(record))
468
469 _capture_handler = _WarningCapture()
470 _capture_handler.setFormatter(logging.Formatter("%(message)s"))
471 _gc_logger = logging.getLogger(__name__)
472 _gc_logger.addHandler(_capture_handler)
473
474 try:
475 _run_gc_inner(repo_root, result, dry_run=dry_run,
476 grace_period_seconds=grace_period_seconds, full=full)
477 finally:
478 _gc_logger.removeHandler(_capture_handler)
479
480 result.duration_ms = elapsed()
481 logger.info(
482 "gc: %d reachable blobs, %d %s, full=%s, grace=%ds, %.1fms elapsed",
483 result.reachable_count,
484 result.collected_count,
485 "would be removed" if dry_run else "removed",
486 full,
487 grace_period_seconds,
488 result.duration_ms,
489 )
490 return result
491
492 def _run_gc_inner(
493 repo_root: pathlib.Path,
494 result: "GcResult",
495 *,
496 dry_run: bool,
497 grace_period_seconds: int,
498 full: bool,
499 ) -> None:
500 """Core GC logic — separated so run_gc can wrap it with warning capture."""
501
502 if full:
503 # --full: tight reachability — only objects reachable from live refs.
504 reachable_commits = _collect_reachable_commits(repo_root)
505 reachable_snaps, reachable_objs = _collect_reachable_snapshots(
506 repo_root, reachable_commits
507 )
508
509 result.commits_reachable = len(reachable_commits)
510 result.snapshots_reachable = len(reachable_snaps)
511 result.reachable_count = len(reachable_objs)
512
513 # Prune orphaned commits
514 commits_dir = _commits_dir(repo_root)
515 for cid, commit_path in _list_stored_msgpack(
516 commits_dir, grace_period_seconds=grace_period_seconds
517 ):
518 if cid not in reachable_commits:
519 try:
520 size = commit_path.stat().st_size
521 except OSError:
522 size = 0
523 result.commits_collected += 1
524 result.commits_collected_bytes += size
525 result.collected_commit_ids.append(cid)
526 if not dry_run:
527 try:
528 commit_path.unlink()
529 except OSError as exc:
530 logger.warning(
531 "⚠️ Could not remove commit %s: %s", cid, exc
532 )
533
534 # Prune orphaned snapshots
535 snapshots_dir = _snapshots_dir(repo_root)
536 for snap_id, snap_path in _list_stored_msgpack(
537 snapshots_dir, grace_period_seconds=grace_period_seconds
538 ):
539 if snap_id not in reachable_snaps:
540 try:
541 size = snap_path.stat().st_size
542 except OSError:
543 size = 0
544 result.snapshots_collected += 1
545 result.snapshots_collected_bytes += size
546 result.collected_snapshot_ids.append(snap_id)
547 if not dry_run:
548 try:
549 snap_path.unlink()
550 except OSError as exc:
551 logger.warning(
552 "⚠️ Could not remove snapshot %s: %s", snap_id, exc
553 )
554
555 # Prune unreachable blobs (tighter set: only from reachable commits)
556 stored = _list_stored_objects(repo_root, grace_period_seconds=grace_period_seconds)
557 for object_id, obj_path in stored:
558 if object_id not in reachable_objs:
559 try:
560 size = obj_path.stat().st_size
561 except OSError:
562 size = 0
563 result.collected_ids.append(object_id)
564 result.collected_bytes += size
565 result.collected_count += 1
566 if not dry_run:
567 try:
568 obj_path.unlink()
569 try:
570 if not any(obj_path.parent.iterdir()):
571 obj_path.parent.rmdir()
572 except OSError:
573 pass
574 except OSError as exc:
575 logger.warning(
576 "⚠️ Could not remove object %s: %s", object_id, exc
577 )
578
579 else:
580 # Default: conservative blob-only GC using all commits in the store.
581 reachable = _collect_reachable_objects(repo_root)
582 stored = _list_stored_objects(repo_root, grace_period_seconds=grace_period_seconds)
583
584 result.reachable_count = len(reachable)
585
586 for object_id, obj_path in stored:
587 if object_id not in reachable:
588 try:
589 size = obj_path.stat().st_size
590 except OSError:
591 size = 0
592 result.collected_ids.append(object_id)
593 result.collected_bytes += size
594 result.collected_count += 1
595 if not dry_run:
596 try:
597 obj_path.unlink()
598 try:
599 if not any(obj_path.parent.iterdir()):
600 obj_path.parent.rmdir()
601 except OSError:
602 pass
603 except OSError as exc:
604 logger.warning(
605 "⚠️ Could not remove object %s: %s", object_id, exc
606 )
607
608 # ---------------------------------------------------------------------------
609 # Stale remote tracking ref pruning
610 # ---------------------------------------------------------------------------
611
612 def prune_stale_remote_refs(
613 repo_root: pathlib.Path,
614 configured_remote_names: set[str],
615 result: GcResult,
616 *,
617 dry_run: bool,
618 ) -> None:
619 """Remove tracking-ref directories for remotes that are no longer configured.
620
621 When a remote is removed with ``muse remote remove``, the directory
622 ``.muse/remotes/<remote>/`` is left behind — tracking refs accumulate
623 indefinitely. This function deletes those orphaned directories.
624
625 Only **real directories** (not symlinks) whose name does not appear in
626 *configured_remote_names* are removed. The walk is non-recursive against
627 symlinks to prevent traversal attacks.
628
629 Args:
630 repo_root: Repository root.
631 configured_remote_names: Names of remotes currently in ``config.toml``.
632 result: ``GcResult`` to update in-place.
633 dry_run: When ``True``, count but do not delete.
634 """
635 remotes_root = _remotes_dir(repo_root)
636 if not remotes_root.is_dir():
637 return
638
639 for entry in remotes_root.iterdir():
640 if entry.is_symlink() or not entry.is_dir():
641 continue
642 if entry.name in configured_remote_names:
643 continue
644 # Stale remote directory — count and optionally delete all ref files.
645 dir_bytes = 0
646 ref_files: list[pathlib.Path] = []
647 for ref_file in entry.rglob("*"):
648 if ref_file.is_symlink() or not ref_file.is_file():
649 continue
650 try:
651 dir_bytes += ref_file.stat().st_size
652 except OSError:
653 pass
654 ref_files.append(ref_file)
655
656 result.stale_remote_refs_collected += len(ref_files)
657 result.stale_remote_refs_bytes += dir_bytes
658
659 if not dry_run:
660 for ref_file in ref_files:
661 try:
662 ref_file.unlink()
663 except OSError as exc:
664 logger.warning(
665 "⚠️ Could not remove stale remote ref %s: %s", ref_file, exc
666 )
667 # Remove the now-empty directory tree.
668 import shutil
669 try:
670 shutil.rmtree(entry, ignore_errors=True)
671 except OSError as exc:
672 logger.warning(
673 "⚠️ Could not remove stale remote dir %s: %s", entry.name, exc
674 )
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 120 days ago