Coverage for src/footman/_gc.py: 95%
54 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
1"""Cache garbage collection — the detached child a run spawns at most daily.
3The cache is derived state top to bottom (completion manifests rebuild on
4any execution-path run; timing history regrows), which is what makes
5collecting it casually safe: the worst possible outcome of any deletion is
6a rebuild. Two rules, in order:
81. **The directory is gone.** Manifests bake in the ``cwd`` they describe;
9 if that path no longer exists, the pair (manifest + timing history) is
10 leftovers from a deleted project — collected at any age.
112. **The pair is idle.** Untouched for `IDLE_DAYS`, nobody even TAB-completes
12 there any more (background refreshes keep a visited manifest's mtime
13 fresh) — collected. Manifests from before the ``cwd`` key rely on this
14 rule alone.
16The invoking directory's own pair is never touched, and every failure is
17silent — a concurrently-reading completion child on Windows may hold a file
18open, and a collector must never be louder than what it collects.
19"""
21from __future__ import annotations
23import contextlib
24import json
25import sys
26import time
27from pathlib import Path
29IDLE_DAYS = 90
30STAMP = "gc.stamp"
33def collect(cache_dir: Path, skip_stem: str = "") -> int:
34 """Apply the rules to *cache_dir*; returns the number of files removed.
36 *skip_stem* is the invoking directory's manifest stem (its hash) — that
37 pair is in active use and never touched.
38 """
39 now = time.time()
40 removed = 0
42 def unlink(path: Path) -> None:
43 nonlocal removed
44 try:
45 path.unlink()
46 removed += 1
47 except OSError:
48 pass
50 for manifest_path in cache_dir.glob("*.json"):
51 name = manifest_path.name
52 if name.endswith(".times.json"):
53 continue # handled with its manifest, or as an orphan below
54 stem = name[: -len(".json")]
55 if stem == skip_stem:
56 continue
57 times_path = cache_dir / f"{stem}.times.json"
58 try:
59 data = json.loads(manifest_path.read_text("utf-8"))
60 except (OSError, ValueError):
61 data = None
62 cwd = data.get("cwd") if isinstance(data, dict) else None
63 if isinstance(cwd, str) and cwd and not Path(cwd).exists():
64 doomed = True # rule 1: leftovers of a deleted project
65 else:
66 doomed = _idle(now, manifest_path, times_path) # rule 2
67 if doomed:
68 unlink(manifest_path)
69 unlink(times_path)
71 # Timing files whose manifest twin is already gone: age them alone.
72 for times_path in cache_dir.glob("*.times.json"):
73 stem = times_path.name[: -len(".times.json")]
74 if stem == skip_stem or (cache_dir / f"{stem}.json").exists():
75 continue
76 if _idle(now, times_path):
77 unlink(times_path)
79 return removed
82def _idle(now: float, *paths: Path) -> bool:
83 """Whether the newest of *paths* is older than the idle window."""
84 newest = 0.0
85 for path in paths:
86 with contextlib.suppress(OSError):
87 newest = max(newest, path.stat().st_mtime)
88 return newest > 0 and (now - newest) > IDLE_DAYS * 86400
91def main() -> None:
92 """Entry for the detached child: argv is (cache_dir, skip_stem)."""
93 if len(sys.argv) < 2: 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true
94 return
95 skip = sys.argv[2] if len(sys.argv) > 2 else ""
96 collect(Path(sys.argv[1]), skip)