gabriel / muse public
gc.py python
771 lines 29.9 KB
Raw
sha256:1a5c0bab2cb0d4c3eed597349ccaa433a4a6add8726890f2d6f577c17dc54972 docs: file follow-up issue for symlog filename-too-long (muse#61) Sonnet 4.6 23 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 objects (``.muse/objects/``) — commits not reachable
24 from any live branch ref or tag by following ``parent_commit_id`` links.
25 - Orphaned snapshot objects (``.muse/objects/``) — 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 tags_dir as _tags_dir,
67 heads_dir as _heads_dir,
68 shelf_dir as _shelf_dir,
69 remotes_dir as _remotes_dir,
70 )
71 from muse.core.object_store import iter_stored_objects
72 from muse.core.refs import iter_branch_refs
73 from muse.core.io import MAX_MSGPACK_BYTES, zstd_decompress_if_needed as _zstd_decompress_if_needed
74 from muse.core.commits import (
75 commit_path as _commit_path,
76 get_all_commits,
77 )
78 from muse.core.snapshots import (
79 read_snapshot,
80 snapshot_path as _snapshot_path,
81 )
82 from muse.core.timing import start_timer
83 from muse.core.reflog import expire_reflog, list_reflog_refs
84 from muse.core.symlog import expire_symlog, list_symlog_symbols
85
86 logger = logging.getLogger(__name__)
87
88 _MAX_SHELF_BYTES: int = MAX_MSGPACK_BYTES
89
90 # Default number of seconds to protect recently-written objects from GC.
91 # See the "Safety" section of the module docstring.
92 _DEFAULT_GRACE_PERIOD_SECONDS: int = 30
93
94 # ---------------------------------------------------------------------------
95 # Result type
96 # ---------------------------------------------------------------------------
97
98 @dataclass
99 class GcResult:
100 """Statistics from one garbage-collection pass."""
101
102 # Blob store (.muse/objects/)
103 reachable_count: int = 0
104 collected_count: int = 0
105 collected_bytes: int = 0
106
107 # Commits in unified store (.muse/objects/sha256/) — populated only with --full
108 commits_reachable: int = 0
109 commits_collected: int = 0
110 commits_collected_bytes: int = 0
111
112 # Snapshots in unified store (.muse/objects/sha256/) — populated only with --full
113 snapshots_reachable: int = 0
114 snapshots_collected: int = 0
115 snapshots_collected_bytes: int = 0
116
117 # Remote tracking refs (.muse/remotes/) — populated only with --full
118 stale_remote_refs_collected: int = 0
119 stale_remote_refs_bytes: int = 0
120
121 # Reflog entries pruned by age during this GC pass
122 reflog_expired: int = 0
123
124 # Symlog entries pruned by age during this GC pass
125 symlog_expired: int = 0
126
127 duration_ms: float = 0.0
128 grace_period_seconds: int = _DEFAULT_GRACE_PERIOD_SECONDS
129 collected_ids: list[str] = field(default_factory=list)
130 collected_commit_ids: list[str] = field(default_factory=list)
131 collected_snapshot_ids: list[str] = field(default_factory=list)
132 warnings: list[str] = field(default_factory=list)
133 dry_run: bool = False
134 full: bool = False
135
136 # ---------------------------------------------------------------------------
137 # Reachability walk — blobs (conservative: all commits in store)
138 # ---------------------------------------------------------------------------
139
140 def _collect_reachable_objects(
141 repo_root: pathlib.Path,
142 ) -> set[str]:
143 """Return the set of all object SHA-256 IDs reachable from any live ref.
144
145 Conservative strategy for the unified object store (all three types — blob,
146 commit, snapshot — now live under ``objects/sha256/``):
147
148 - All ``commit`` and ``snapshot`` typed objects are always reachable.
149 - Every blob ID referenced by any snapshot manifest is reachable.
150 - Malformed objects (no null-byte header) are kept (conservative).
151
152 This mirrors the old behaviour where commits and snapshots lived in separate
153 directories and were never candidates for blob-level GC. Now that they
154 share the store we must explicitly protect them.
155
156 Snapshot manifests are read raw (no hash verification) so that objects are
157 retained even when a snapshot's stored payload is corrupt — it is always
158 safer to keep objects than to silently lose them.
159
160 Shelf entries are also walked so shelved blobs are never GCed.
161 """
162 import json as _json_mod
163
164 reachable: set[str] = set()
165
166 # ── Unified object store walk ─────────────────────────────────────────────
167 for oid, obj_file in iter_stored_objects(repo_root):
168 if obj_file.is_symlink():
169 continue
170 try:
171 raw = obj_file.read_bytes()
172 null_idx = raw.index(b"\0")
173 type_str = raw[:null_idx].decode(errors="replace").split(" ", 1)[0]
174 except (ValueError, OSError):
175 # Malformed or unreadable — keep conservatively.
176 reachable.add(oid)
177 continue
178
179 if type_str == "commit":
180 reachable.add(oid)
181 elif type_str == "snapshot":
182 reachable.add(oid)
183 try:
184 payload = raw[null_idx + 1:]
185 data = _json_mod.loads(payload)
186 manifest = data.get("manifest", {})
187 if isinstance(manifest, dict):
188 for blob_oid in manifest.values():
189 if isinstance(blob_oid, str) and blob_oid:
190 reachable.add(long_id(blob_oid))
191 except Exception as exc:
192 logger.debug("gc: could not parse snapshot %s — skipped: %s", oid, exc)
193 # blob objects are not added here — only added if referenced by a snapshot
194
195 # ── Shelf ─────────────────────────────────────────────────────────────────
196 _collect_shelf_objects(repo_root, reachable)
197
198 return reachable
199
200 def _collect_shelf_objects(repo_root: pathlib.Path, reachable: set[str]) -> None:
201 """Add object IDs from shelf entries into *reachable* in-place.
202
203 Handles both new git-header+JSON files (no extension) and legacy
204 ``.msgpack`` files. Corrupt, oversized, or symlinked files are skipped
205 with a warning so a single bad entry never blocks GC.
206 """
207 import json as _json
208 from muse.core.io import safe_unpackb
209 shelf = _shelf_dir(repo_root)
210 if not shelf.is_dir():
211 return
212 for entry_path in shelf.glob("*/*"):
213 if entry_path.name.startswith("."):
214 continue
215 if entry_path.suffix not in ("", ".msgpack"):
216 continue
217 if entry_path.is_symlink():
218 logger.warning("⚠️ shelf entry %s is a symlink — skipping during GC walk", entry_path.name)
219 continue
220 try:
221 size = entry_path.stat().st_size
222 if size > _MAX_SHELF_BYTES:
223 logger.warning("⚠️ shelf entry %s exceeds size limit — skipping", entry_path.name[:24])
224 continue
225 raw = entry_path.read_bytes()
226 if entry_path.suffix == ".msgpack":
227 data = safe_unpackb(raw)
228 else:
229 null_idx = raw.index(b"\0")
230 data = _json.loads(raw[null_idx + 1:].decode("utf-8"))
231 if not isinstance(data, dict):
232 continue
233 snapshot = data.get("snapshot")
234 if isinstance(snapshot, dict):
235 for object_id in snapshot.values():
236 if isinstance(object_id, str):
237 reachable.add(object_id)
238 except OSError as exc:
239 logger.warning(
240 "⚠️ Could not read shelf entry %s during GC walk: %s",
241 entry_path.name[:24], exc,
242 )
243
244 # ---------------------------------------------------------------------------
245 # Reachability walk — commits + snapshots (for --full)
246 # ---------------------------------------------------------------------------
247
248 def _strip_prefix(id_str: str) -> str:
249 """Return the bare hex ID, stripping any ``sha256:`` prefix.
250
251 GC uses bare hex as the canonical form because that is what the filesystem
252 stores (``commits/<hex>.msgpack``, ``snapshots/<hex>.msgpack``). All IDs
253 read from ref files, commit records, and tag records must be normalised
254 through this function before being added to reachable sets or used to
255 build file paths.
256 """
257 return long_id(id_str, strip=True)
258
259 def _collect_reachable_commits(repo_root: pathlib.Path) -> set[str]:
260 """Return all prefixed commit IDs reachable from any live branch ref or tag.
261
262 BFS through parent links using the unified object store (read_commit).
263 Corrupt or missing commits are skipped — not added to reachable set and
264 not deleted (the grace period covers that window).
265 """
266 from muse.core.commits import read_commit as _read_commit
267 reachable: set[str] = set()
268
269 # Collect all live tips: branch refs + tags
270 tips: list[str] = [cid for _, cid in iter_branch_refs(repo_root) if cid]
271
272 import json as _json
273 tags_dir = _tags_dir(repo_root)
274 if tags_dir.exists():
275 for tag_path in tags_dir.glob("*/*/*/*"):
276 if tag_path.suffix not in (".json", ".msgpack"):
277 continue
278 if tag_path.is_symlink() or not tag_path.is_file():
279 continue
280 try:
281 if tag_path.suffix == ".json":
282 tag_data = _json.loads(tag_path.read_bytes().decode("utf-8"))
283 else:
284 tag_data = msgpack.unpackb(tag_path.read_bytes(), raw=False)
285 cid = tag_data.get("commit_id") or tag_data.get("target")
286 if cid:
287 tips.append(long_id(cid))
288 except Exception:
289 pass
290
291 # BFS through parent links via unified object store
292 queue: deque[str] = deque(tips)
293 while queue:
294 cid = queue.popleft()
295 if cid in reachable:
296 continue
297 reachable.add(cid)
298 try:
299 record = _read_commit(repo_root, cid)
300 except Exception as exc:
301 logger.warning("⚠️ Could not read commit %s during GC walk: %s", cid, exc)
302 continue
303 if record is None:
304 continue
305 if record.parent_commit_id:
306 queue.append(long_id(record.parent_commit_id))
307 if record.parent2_commit_id:
308 queue.append(long_id(record.parent2_commit_id))
309
310 return reachable
311
312 def _collect_reachable_snapshots(
313 repo_root: pathlib.Path,
314 reachable_commits: set[str],
315 ) -> tuple[set[str], set[str]]:
316 """Return ``(reachable_snapshot_ids, reachable_object_ids)`` from reachable commits.
317
318 Reads commits from the unified object store. Also folds in any object IDs
319 referenced by shelf entries.
320 """
321 from muse.core.commits import read_commit as _read_commit
322 reachable_snaps: set[str] = set()
323 reachable_objs: set[str] = set()
324
325 for cid in reachable_commits:
326 try:
327 record = _read_commit(repo_root, cid)
328 except Exception:
329 continue
330 if record is None:
331 continue
332 snap_id = long_id(record.snapshot_id)
333 reachable_snaps.add(snap_id)
334 snap = read_snapshot(repo_root, snap_id)
335 if snap is not None:
336 for object_id in snap.manifest.values():
337 reachable_objs.add(long_id(object_id))
338 else:
339 # read_snapshot returned None — snapshot may be corrupt but still
340 # contain valid object IDs. Walk the raw object to retain them.
341 from muse.core.object_store import object_path as _obj_path
342 obj_file = _obj_path(repo_root, snap_id)
343 if obj_file.is_file() and not obj_file.is_symlink():
344 try:
345 raw = obj_file.read_bytes()
346 null_idx = raw.index(b"\0")
347 payload = raw[null_idx + 1:]
348 raw_snap = json.loads(payload)
349 manifest = raw_snap.get("manifest", {})
350 if isinstance(manifest, dict):
351 for oid in manifest.values():
352 if isinstance(oid, str) and oid:
353 reachable_objs.add(long_id(oid))
354 logger.critical(
355 "❌ gc: snapshot %s failed hash verification — "
356 "its objects were retained conservatively. "
357 "Run `muse verify-pack` to audit the store.",
358 snap_id,
359 )
360 except Exception as exc:
361 logger.critical(
362 "❌ gc: could not read corrupt snapshot %s — "
363 "some objects may have been lost: %s",
364 snap_id, exc,
365 )
366
367 # Shelf snapshot IDs
368 _collect_shelf_objects(repo_root, reachable_objs)
369
370 return reachable_snaps, reachable_objs
371
372 # ---------------------------------------------------------------------------
373 # Store enumeration helpers
374 # ---------------------------------------------------------------------------
375
376 _HEX_CHARS: frozenset[str] = frozenset("0123456789abcdef")
377
378 def _is_hex(s: str) -> bool:
379 """Return True iff every character of *s* is a lowercase hex digit."""
380 return bool(s) and all(c in _HEX_CHARS for c in s)
381
382 def _list_stored_objects(
383 repo_root: pathlib.Path,
384 *,
385 grace_period_seconds: int = _DEFAULT_GRACE_PERIOD_SECONDS,
386 ) -> list[tuple[str, pathlib.Path]]:
387 """Return ``(prefixed_object_id, path)`` for every valid SHA-256 object in the store.
388
389 Only **real files** (not symlinks) whose name + parent prefix form a
390 64-char lowercase hex string are included. Stray files (editor temporaries,
391 macOS ``.DS_Store``, etc.) are silently skipped.
392
393 Symlink guard
394 ~~~~~~~~~~~~~
395 Both prefix directories and object files are rejected if they are symlinks.
396 Without this guard a crafted symlink inside ``.muse/objects/`` could cause
397 GC to ``unlink()`` a file anywhere on the filesystem — a silent data
398 destruction vector.
399
400 Grace period
401 ~~~~~~~~~~~~
402 Objects whose ``mtime`` is within *grace_period_seconds* seconds of now are
403 excluded from the returned list even if they are not currently reachable.
404 This closes the TOCTOU window where a concurrent ``muse commit`` has written
405 a blob but not yet created the commit record.
406
407 Args:
408 repo_root: Repository root (contains ``.muse/``).
409 grace_period_seconds: Number of seconds before an object becomes
410 eligible for collection. Default: 30 s.
411 """
412 cutoff = time.time() - grace_period_seconds
413 pairs: list[tuple[str, pathlib.Path]] = []
414
415 for oid, obj_file in iter_stored_objects(repo_root, skip_symlinks=False):
416 if obj_file.is_symlink():
417 logger.warning("⚠️ object %s is a symlink — skipping during GC walk", oid)
418 continue
419 # Grace period: protect objects written in the last N seconds.
420 try:
421 if obj_file.stat().st_mtime > cutoff:
422 continue
423 except OSError:
424 # File disappeared between iterdir() and stat() — skip.
425 continue
426 pairs.append((oid, obj_file))
427
428 return pairs
429
430 def _list_stored_msgpack(
431 directory: pathlib.Path,
432 *,
433 grace_period_seconds: int = _DEFAULT_GRACE_PERIOD_SECONDS,
434 ) -> list[tuple[str, pathlib.Path]]:
435 """Return ``(stem, path)`` for every non-symlink ``.msgpack`` file in *directory*.
436
437 Applies the same grace-period and symlink guards as ``_list_stored_objects``.
438 """
439 if not directory.exists():
440 return []
441
442 cutoff = time.time() - grace_period_seconds
443 pairs: list[tuple[str, pathlib.Path]] = []
444
445 for p in directory.glob("*/*.msgpack"):
446 if p.is_symlink() or not p.is_file():
447 continue
448 try:
449 if p.stat().st_mtime > cutoff:
450 continue
451 except OSError:
452 continue
453 pairs.append((p.stem, p))
454
455 return pairs
456
457 # ---------------------------------------------------------------------------
458 # Public API
459 # ---------------------------------------------------------------------------
460
461 _DEFAULT_REFLOG_EXPIRE_DAYS: int = 90
462 _DEFAULT_SYMLOG_EXPIRE_DAYS: int = 90
463
464
465 def run_gc(
466 repo_root: pathlib.Path,
467 *,
468 dry_run: bool = False,
469 grace_period_seconds: int = _DEFAULT_GRACE_PERIOD_SECONDS,
470 full: bool = False,
471 reflog_expire_days: int = _DEFAULT_REFLOG_EXPIRE_DAYS,
472 symlog_expire_days: int = _DEFAULT_SYMLOG_EXPIRE_DAYS,
473 ) -> GcResult:
474 """Prune unreachable objects from the Muse object store.
475
476 Args:
477 repo_root: Root of the Muse repository (``.muse/`` lives here).
478 dry_run: When ``True``, report what *would* be deleted
479 without actually removing anything.
480 grace_period_seconds: Objects written within the last N seconds are
481 never deleted, even if currently unreachable.
482 Protects concurrent ``muse commit`` operations.
483 Default: 30 s.
484 full: When ``True``, also prune orphaned commit and
485 snapshot objects from ``.muse/objects/sha256/``,
486 mirroring ``git prune``.
487 reflog_expire_days: Reflog entries older than this many days are
488 pruned as part of the GC pass. Default: 90.
489
490 Returns:
491 A ``GcResult`` with counts and the list of collected object IDs.
492 """
493 elapsed = start_timer()
494 result = GcResult(dry_run=dry_run, grace_period_seconds=grace_period_seconds, full=full)
495
496 # Capture warning log messages into result.warnings so callers (e.g. --json
497 # output) can surface them without scraping stderr.
498 class _WarningCapture(logging.Handler):
499 def emit(self, record: logging.LogRecord) -> None:
500 if record.levelno >= logging.WARNING:
501 result.warnings.append(self.format(record))
502
503 _capture_handler = _WarningCapture()
504 _capture_handler.setFormatter(logging.Formatter("%(message)s"))
505 _gc_logger = logging.getLogger(__name__)
506 _gc_logger.addHandler(_capture_handler)
507
508 try:
509 _run_gc_inner(repo_root, result, dry_run=dry_run,
510 grace_period_seconds=grace_period_seconds, full=full,
511 reflog_expire_days=reflog_expire_days,
512 symlog_expire_days=symlog_expire_days)
513 finally:
514 _gc_logger.removeHandler(_capture_handler)
515
516 result.duration_ms = elapsed()
517 logger.info(
518 "gc: %d reachable blobs, %d %s, full=%s, grace=%ds, %.1fms elapsed",
519 result.reachable_count,
520 result.collected_count,
521 "would be removed" if dry_run else "removed",
522 full,
523 grace_period_seconds,
524 result.duration_ms,
525 )
526 return result
527
528 def _run_gc_inner(
529 repo_root: pathlib.Path,
530 result: "GcResult",
531 *,
532 dry_run: bool,
533 grace_period_seconds: int,
534 full: bool,
535 reflog_expire_days: int = _DEFAULT_REFLOG_EXPIRE_DAYS,
536 symlog_expire_days: int = _DEFAULT_SYMLOG_EXPIRE_DAYS,
537 ) -> None:
538 """Core GC logic — separated so run_gc can wrap it with warning capture."""
539
540 if full:
541 # --full: tight reachability — only objects reachable from live refs.
542 reachable_commits = _collect_reachable_commits(repo_root)
543 reachable_snaps, reachable_objs = _collect_reachable_snapshots(
544 repo_root, reachable_commits
545 )
546
547 result.commits_reachable = len(reachable_commits)
548 result.snapshots_reachable = len(reachable_snaps)
549 result.reachable_count = len(reachable_objs)
550
551 # Walk unified object store — prune commits, snapshots, and blobs that
552 # are not reachable from any live ref. All object types live under
553 # .muse/objects/ after the unified-store migration.
554 cutoff = time.time() - grace_period_seconds
555 for oid, obj_path in iter_stored_objects(repo_root):
556 if obj_path.is_symlink():
557 continue
558 try:
559 if obj_path.stat().st_mtime > cutoff:
560 continue
561 except OSError:
562 continue
563 # Determine object type from header
564 try:
565 raw = obj_path.read_bytes()
566 null_idx = raw.index(b"\0")
567 type_str = raw[:null_idx].decode(errors="replace").split(" ", 1)[0]
568 except (ValueError, OSError):
569 continue
570
571 try:
572 size = obj_path.stat().st_size
573 except OSError:
574 size = 0
575
576 if type_str == "commit":
577 if oid not in reachable_commits:
578 result.commits_collected += 1
579 result.commits_collected_bytes += size
580 result.collected_commit_ids.append(oid)
581 if not dry_run:
582 try:
583 obj_path.unlink()
584 try:
585 if not any(obj_path.parent.iterdir()):
586 obj_path.parent.rmdir()
587 except OSError:
588 pass
589 except OSError as exc:
590 logger.warning("⚠️ Could not remove commit %s: %s", oid, exc)
591 elif type_str == "snapshot":
592 if oid not in reachable_snaps:
593 result.snapshots_collected += 1
594 result.snapshots_collected_bytes += size
595 result.collected_snapshot_ids.append(oid)
596 if not dry_run:
597 try:
598 obj_path.unlink()
599 try:
600 if not any(obj_path.parent.iterdir()):
601 obj_path.parent.rmdir()
602 except OSError:
603 pass
604 except OSError as exc:
605 logger.warning("⚠️ Could not remove snapshot %s: %s", oid, exc)
606 elif type_str == "blob":
607 if oid not in reachable_objs:
608 result.collected_ids.append(oid)
609 result.collected_bytes += size
610 result.collected_count += 1
611 if not dry_run:
612 try:
613 obj_path.unlink()
614 try:
615 if not any(obj_path.parent.iterdir()):
616 obj_path.parent.rmdir()
617 except OSError:
618 pass
619 except OSError as exc:
620 logger.warning("⚠️ Could not remove object %s: %s", oid, exc)
621
622 else:
623 # Default: conservative blob-only GC using all commits in the store.
624 reachable = _collect_reachable_objects(repo_root)
625 stored = _list_stored_objects(repo_root, grace_period_seconds=grace_period_seconds)
626
627 result.reachable_count = len(reachable)
628
629 for object_id, obj_path in stored:
630 if object_id not in reachable:
631 try:
632 size = obj_path.stat().st_size
633 except OSError:
634 size = 0
635 result.collected_ids.append(object_id)
636 result.collected_bytes += size
637 result.collected_count += 1
638 if not dry_run:
639 try:
640 obj_path.unlink()
641 try:
642 if not any(obj_path.parent.iterdir()):
643 obj_path.parent.rmdir()
644 except OSError:
645 pass
646 except OSError as exc:
647 logger.warning(
648 "⚠️ Could not remove object %s: %s", object_id, exc
649 )
650
651 # Reflog expiry — applies regardless of --full flag.
652 _expire_all_reflogs(repo_root, result, reflog_expire_days, dry_run=dry_run)
653
654 # Symlog expiry — applies regardless of --full flag.
655 _expire_all_symlogs(repo_root, result, symlog_expire_days, dry_run=dry_run)
656
657
658 def _expire_all_reflogs(
659 repo_root: pathlib.Path,
660 result: "GcResult",
661 expire_days: int,
662 *,
663 dry_run: bool,
664 ) -> None:
665 """Prune reflog entries older than *expire_days* from all log files.
666
667 Accumulates the total number of expired entries into ``result.reflog_expired``.
668 Individual file failures are logged as warnings and never abort the GC run.
669 """
670 # HEAD log
671 try:
672 expired, _ = expire_reflog(repo_root, None, expire_days, dry_run=dry_run)
673 result.reflog_expired += expired
674 except Exception as exc:
675 logger.warning("⚠️ gc: could not expire HEAD reflog: %s", exc)
676
677 # Per-branch logs
678 for branch in list_reflog_refs(repo_root):
679 try:
680 expired, _ = expire_reflog(repo_root, branch, expire_days, dry_run=dry_run)
681 result.reflog_expired += expired
682 except Exception as exc:
683 logger.warning("⚠️ gc: could not expire reflog for %s: %s", branch, exc)
684
685
686 def _expire_all_symlogs(
687 repo_root: pathlib.Path,
688 result: "GcResult",
689 expire_days: int,
690 *,
691 dry_run: bool,
692 ) -> None:
693 """Prune symlog entries older than *expire_days* from all symbol journals.
694
695 Accumulates the total number of expired entries into ``result.symlog_expired``.
696 Individual file failures are logged as warnings and never abort the GC run.
697 """
698 for addr in list_symlog_symbols(repo_root):
699 try:
700 expired, _ = expire_symlog(repo_root, addr, expire_days, dry_run=dry_run)
701 result.symlog_expired += expired
702 except Exception as exc:
703 logger.warning("⚠️ gc: could not expire symlog for %s: %s", addr, exc)
704
705 # ---------------------------------------------------------------------------
706 # Stale remote tracking ref pruning
707 # ---------------------------------------------------------------------------
708
709 def prune_stale_remote_refs(
710 repo_root: pathlib.Path,
711 configured_remote_names: set[str],
712 result: GcResult,
713 *,
714 dry_run: bool,
715 ) -> None:
716 """Remove tracking-ref directories for remotes that are no longer configured.
717
718 When a remote is removed with ``muse remote remove``, the directory
719 ``.muse/remotes/<remote>/`` is left behind — tracking refs accumulate
720 indefinitely. This function deletes those orphaned directories.
721
722 Only **real directories** (not symlinks) whose name does not appear in
723 *configured_remote_names* are removed. The walk is non-recursive against
724 symlinks to prevent traversal attacks.
725
726 Args:
727 repo_root: Repository root.
728 configured_remote_names: Names of remotes currently in ``config.toml``.
729 result: ``GcResult`` to update in-place.
730 dry_run: When ``True``, count but do not delete.
731 """
732 remotes_root = _remotes_dir(repo_root)
733 if not remotes_root.is_dir():
734 return
735
736 for entry in remotes_root.iterdir():
737 if entry.is_symlink() or not entry.is_dir():
738 continue
739 if entry.name in configured_remote_names:
740 continue
741 # Stale remote directory — count and optionally delete all ref files.
742 dir_bytes = 0
743 ref_files: list[pathlib.Path] = []
744 for ref_file in entry.rglob("*"):
745 if ref_file.is_symlink() or not ref_file.is_file():
746 continue
747 try:
748 dir_bytes += ref_file.stat().st_size
749 except OSError:
750 pass
751 ref_files.append(ref_file)
752
753 result.stale_remote_refs_collected += len(ref_files)
754 result.stale_remote_refs_bytes += dir_bytes
755
756 if not dry_run:
757 for ref_file in ref_files:
758 try:
759 ref_file.unlink()
760 except OSError as exc:
761 logger.warning(
762 "⚠️ Could not remove stale remote ref %s: %s", ref_file, exc
763 )
764 # Remove the now-empty directory tree.
765 import shutil
766 try:
767 shutil.rmtree(entry, ignore_errors=True)
768 except OSError as exc:
769 logger.warning(
770 "⚠️ Could not remove stale remote dir %s: %s", entry.name, exc
771 )
File History 1 commit
sha256:1a5c0bab2cb0d4c3eed597349ccaa433a4a6add8726890f2d6f577c17dc54972 docs: file follow-up issue for symlog filename-too-long (muse#61) Sonnet 4.6 23 days ago