From 2395e3e79f158865a13f070952412aab49c17f9c Mon Sep 17 00:00:00 2001 From: Simone Date: Fri, 24 Jul 2026 22:04:31 +0200 Subject: [PATCH] feat(operations): cargo cache force purge ("empty all") purge_cargo_cache gains force: bool. The default age-based purge only ever removed entries past the configured retention window, which read as a lie when a user expected "purge" to clear everything -- force=True sets the cutoff a day in the future so every existing entry is treated as expired, backing the web UI's explicit "Empty all" action. A disabled cache stays untouched either way. --- src/ci_orchestrator/operations/cache.py | 20 +++++++++++--- tests/python/test_operations_cache.py | 35 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/ci_orchestrator/operations/cache.py b/src/ci_orchestrator/operations/cache.py index a130479..f8b6274 100644 --- a/src/ci_orchestrator/operations/cache.py +++ b/src/ci_orchestrator/operations/cache.py @@ -94,19 +94,33 @@ def cargo_cache_status(config: Config) -> CacheStatus: def purge_cargo_cache( - config: Config, *, retention_days: int | None = None, dry_run: bool = False + config: Config, + *, + retention_days: int | None = None, + force: bool = False, + dry_run: bool = False, ) -> PurgeResult: """Remove cache entries older than ``retention_days`` (or config's). Sizes the victims before deleting (a real prune makes their bytes unreadable), so ``freed_bytes`` is accurate even for a real purge. A disabled cache is left untouched and reports an all-zero result. + + ``force=True`` ignores the retention window entirely and empties the whole + cache (every entry, regardless of age) -- this backs the web UI's explicit + "Empty all" action, as opposed to the default age-based purge. A disabled + cache is still left untouched even under ``force``. """ if not config.cargo_cache.enabled: return PurgeResult(removed_entries=0, freed_bytes=0, dry_run=dry_run) - days = retention_days if retention_days is not None else config.cargo_cache.retention_days - cutoff = datetime.now(tz=UTC) - timedelta(days=days) + if force: + # A cutoff in the future makes every existing entry (mtime <= now) + # older than it, so the age-based prune removes the entire cache. + cutoff = datetime.now(tz=UTC) + timedelta(days=1) + else: + days = retention_days if retention_days is not None else config.cargo_cache.retention_days + cutoff = datetime.now(tz=UTC) - timedelta(days=days) store = CargoCacheStore(config.cargo_cache.root) doomed = store.prune_older_than(cutoff, what_if=True) diff --git a/tests/python/test_operations_cache.py b/tests/python/test_operations_cache.py index e989a0f..1187e93 100644 --- a/tests/python/test_operations_cache.py +++ b/tests/python/test_operations_cache.py @@ -178,6 +178,41 @@ def test_purge_cargo_cache_nothing_to_purge(tmp_path: Path) -> None: assert fresh.exists() +def test_purge_cargo_cache_force_removes_everything_regardless_of_age(tmp_path: Path) -> None: + root = tmp_path / "cache" / "cargo" + fresh = _seed_entry(root, "new", age_days=0) # brand-new, well within retention + mid = _seed_entry(root, "mid", age_days=1) + config = _make_config(root, retention_days=30) # retention alone would spare both + + result = purge_cargo_cache(config, force=True) + + assert result.removed_entries == 2 + assert not fresh.exists() + assert not mid.exists() + + +def test_purge_cargo_cache_force_dry_run_counts_all_but_keeps_files(tmp_path: Path) -> None: + root = tmp_path / "cache" / "cargo" + fresh = _seed_entry(root, "new", age_days=0, size=2 * 1024 * 1024) + config = _make_config(root, retention_days=30) + + result = purge_cargo_cache(config, force=True, dry_run=True) + + assert result == PurgeResult(removed_entries=1, freed_bytes=2 * 1024 * 1024, dry_run=True) + assert fresh.exists(), "force dry_run must not delete anything" + + +def test_purge_cargo_cache_force_disabled_still_zero(tmp_path: Path) -> None: + root = tmp_path / "cache" / "cargo" + old = _seed_entry(root, "old", age_days=1) + config = _make_config(root, enabled=False, retention_days=30) + + result = purge_cargo_cache(config, force=True) + + assert result == PurgeResult(removed_entries=0, freed_bytes=0, dry_run=False) + assert old.exists() + + def test_purge_cargo_cache_missing_root_is_not_an_error(tmp_path: Path) -> None: root = tmp_path / "cache" / "cargo" config = _make_config(root, retention_days=30)