From 8ff9ea1c23ef3d847d0dfe43e106788bcfe09db0 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Tue, 20 Oct 2026 21:47:45 +0200 Subject: [PATCH 1/2] fix(cleanup): prune date-partitioned state stores and stale JetBrains data (ticket-091) fixos skipped ~/.local/state entirely, so collectors writing YYYY/MM/DD partitions without retention (here ~7 GB/day) filled the disk unseen. cleanup --state-old --days N removes only whole day partitions older than N days by name and by every file's mtime, keeps the current day, files beside the year tree, symlink targets and other filesystems, and reports a hardlink-aware reclaimable size. --jetbrains now handles the flat Toolbox layout: version data directories that no install in Toolbox state.json uses (snap installs included) and that a newer version superseded are candidates; duplicate registered installs are reported for removal through Toolbox instead of being deleted. Co-Authored-By: Claude Opus 5.5 --- fixos/cli/_cleanup_space.py | 218 +++++++++++++++++++++++- fixos/cli/cleanup_cmd.py | 29 ++++ fixos/constants.py | 1 + fixos/diagnostics/state_retention.py | 205 ++++++++++++++++++++++ project/ticket-091/README.md | 20 +++ project/ticket-091/intent.json | 99 +++++++++++ tests/unit/test_jetbrains_toolbox_v2.py | 72 ++++++++ tests/unit/test_state_retention.py | 171 +++++++++++++++++++ 8 files changed, 808 insertions(+), 7 deletions(-) create mode 100644 fixos/diagnostics/state_retention.py create mode 100644 project/ticket-091/README.md create mode 100644 project/ticket-091/intent.json create mode 100644 tests/unit/test_jetbrains_toolbox_v2.py create mode 100644 tests/unit/test_state_retention.py diff --git a/fixos/cli/_cleanup_space.py b/fixos/cli/_cleanup_space.py index d261895..46e1b61 100644 --- a/fixos/cli/_cleanup_space.py +++ b/fixos/cli/_cleanup_space.py @@ -20,6 +20,7 @@ from fixos.cli._cleanup_utils import _format_bytes, _parse_selection from fixos.constants import ( + DEFAULT_STATE_OLD_DAYS, DEFAULT_THUMBNAILS_UNUSED_DAYS, DEFAULT_TMP_UNUSED_DAYS, DEFAULT_TRASH_UNUSED_DAYS, @@ -496,10 +497,81 @@ def _cleanup_user_cache( # ── JetBrains Toolbox / caches ─────────────────────────────────────────── +_JETBRAINS_DATA_DIR = re.compile(r"([A-Za-z]+)(\d{4})\.(\d+)") + + +def _toolbox_installs(home: Path) -> list[dict] | None: + """Installs registered in Toolbox state.json; None when any cannot be read.""" + state = home / ".local/share/JetBrains/Toolbox/state.json" + try: + tools = json.loads(state.read_text(encoding="utf-8"))["tools"] + except (OSError, ValueError, KeyError, TypeError): + return None + installs = [] + for tool in tools: + location = Path(str(tool.get("installLocation") or "")) + try: + info = json.loads((location / "product-info.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + installs.append( + { + "tool_id": tool.get("toolId"), + "build": tool.get("buildNumber"), + "version": tool.get("displayVersion"), + "location": location, + "data_dir": info.get("dataDirectoryName"), + } + ) + return installs + + +def _jetbrains_stale_data_dirs(home: Path, installs: list[dict] | None) -> list[dict]: + """Version data dirs no install uses and that a newer version superseded.""" + data_root = home / ".local/share/JetBrains" + if installs is None or not data_root.is_dir(): + return [] + in_use = {install["data_dir"] for install in installs if install["data_dir"]} + versions: dict[str, list[tuple[tuple[int, int], Path]]] = {} + for entry in data_root.iterdir(): + match = _JETBRAINS_DATA_DIR.fullmatch(entry.name) + if match and entry.is_dir() and not entry.is_symlink(): + version = (int(match.group(2)), int(match.group(3))) + versions.setdefault(match.group(1), []).append((version, entry)) + candidates = [] + for items in versions.values(): + newest = max(version for version, _ in items) + for version, entry in items: + if version < newest and entry.name not in in_use: + candidates.append( + { + "description": entry.name, + "path": entry, + "size_bytes": _dir_size(entry), + "note": "dane starej wersji IDE, żadna instalacja ich nie używa", + } + ) + return candidates + + +def _toolbox_duplicate_installs(installs: list[dict] | None) -> list[str]: + """Same tool and build registered more than once; remove through Toolbox.""" + groups: dict[tuple[str, str], list[dict]] = {} + for install in installs or []: + groups.setdefault((str(install["tool_id"]), str(install["build"])), []).append(install) + return [ + f"{tool} {items[0]['version']} ({build}) zainstalowany {len(items)}×: " + + ", ".join(str(item["location"]) for item in items) + + " — usuń zbędny kanał w JetBrains Toolbox" + for (tool, build), items in sorted(groups.items()) + if len(items) > 1 + ] + + def _jetbrains_candidates() -> list[dict]: - """Stale Toolbox channels (all but newest per app) + IDE caches.""" + """Stale Toolbox channels (all but newest per app), unused version data and IDE caches.""" home = Path.home() - candidates = [] + candidates = _jetbrains_stale_data_dirs(home, _toolbox_installs(home)) apps_dir = home / ".local/share/JetBrains/Toolbox/apps" if apps_dir.is_dir(): for app_dir in sorted(apps_dir.iterdir()): @@ -542,8 +614,9 @@ def _jetbrains_candidates() -> list[dict]: def _cleanup_jetbrains( json_output: bool, dry_run: bool, list_only: bool, yes: bool ) -> None: - """Remove stale JetBrains Toolbox channels and IDE caches.""" + """Remove stale JetBrains Toolbox channels, unused version data and IDE caches.""" candidates = _jetbrains_candidates() + advisories = _toolbox_duplicate_installs(_toolbox_installs(Path.home())) if json_output: _echo_json( { @@ -554,16 +627,19 @@ def _cleanup_jetbrains( "path": str(c["path"]), } for c in candidates - ] + ], + "advisories": advisories, } ) return + for advisory in advisories: + click.echo(click.style(f" Uwaga: {advisory}", fg="yellow")) if not candidates: click.echo( click.style("Brak starych kanałów Toolbox ani cache IDE.", fg="green") ) return - _display_candidates("JETBRAINS — stare kanały i cache:", candidates) + _display_candidates("JETBRAINS — stare kanały, dane starych wersji i cache:", candidates) if list_only: return selected = ( @@ -1616,8 +1692,8 @@ def _cleanup_thumbnails( "default_days": 0.0, "risk_level": "safe", "default_safe": True, - "description": "Stare wersje IDE z Toolbox oraz ~/.cache/JetBrains", - "protected": "Ustawienia i konfiguracja w ~/.local/share/JetBrains", + "description": "Stare wersje IDE z Toolbox, nieużywane dane starszych wersji oraz ~/.cache/JetBrains", + "protected": "Dane wersji używanych przez instalacje z Toolbox state.json (także snap), ~/.config/JetBrains", "example": "fixos cleanup --jetbrains", }, { @@ -1646,6 +1722,19 @@ def _cleanup_thumbnails( "protected": "Workspace'y powiązane z działającymi kontenerami", "example": "fixos cleanup --gitive", }, + { + "id": "state-old", + "flag": "--state-old", + "name": "Stare partycje dzienne stanu aplikacji", + "direct": True, + "default_retention": f"{DEFAULT_STATE_OLD_DAYS} dni", + "default_days": float(DEFAULT_STATE_OLD_DAYS), + "risk_level": "review", + "default_safe": False, + "description": "Katalogi YYYY/MM/DD w ~/.local/state i ~/.local/share starsze niż --days", + "protected": "Bieżący dzień, pliki poza partycjami (np. checkpoint.sqlite3), dowiązania, inne systemy plików", + "example": "fixos cleanup --state-old --days 14 --dry-run", + }, ] @@ -1815,3 +1904,118 @@ def _cleanup_build_caches( click.echo("Anulowano.") return _remove_selected(candidates, selected) + + +# ── date-partitioned state stores ──────────────────────────────────────── + + +def _cleanup_state_old( + json_output: bool, + dry_run: bool, + list_only: bool, + yes: bool, + days: int = DEFAULT_STATE_OLD_DAYS, + roots: list[Path] | None = None, +) -> None: + """Remove YYYY/MM/DD partitions of application state stores older than `days`.""" + from fixos.diagnostics.state_retention import ( + remove_old_partitions, + scan_state_retention, + ) + + try: + stores = scan_state_retention(int(days), roots=roots) + except ValueError as exc: + click.echo(click.style(f"Błąd: {exc}", fg="red")) + return + candidates = [ + { + "description": str(store.path), + "path": store.path, + "store": store, + "size_bytes": store.reclaimable_bytes, + "note": ( + f"{len(store.old_days)} dni {store.oldest}..{store.newest}, " + f"zostaje {store.kept_days}" + + ( + f", pozorny rozmiar {_format_bytes(store.apparent_bytes)} (twarde dowiązania)" + if store.apparent_bytes != store.reclaimable_bytes + else "" + ) + ), + } + for store in stores + ] + + if json_output: + _echo_json({ + "days": days, + "candidates": [ + { + "path": str(c["path"]), + "partitions": len(c["store"].old_days), + "oldest": str(c["store"].oldest), + "newest": str(c["store"].newest), + "kept_partitions": c["store"].kept_days, + "apparent_bytes": c["store"].apparent_bytes, + "reclaimable_bytes": c["size_bytes"], + } + for c in candidates + ], + "total_bytes": sum(c["size_bytes"] for c in candidates), + }) + return + + if not candidates: + click.echo( + click.style( + f"Brak partycji dziennych YYYY/MM/DD starszych niż {days} dni.", + fg="green", + ) + ) + return + + _display_candidates( + f"Magazyny stanu z partycjami dziennymi starszymi niż {days} dni:", candidates + ) + if list_only: + return + + selected = ( + list(range(len(candidates))) + if yes + else _choose(candidates, "Numery magazynów do przycięcia (np. 1,2, all)") + ) + if not selected: + click.echo("Pominięto.") + return + + if dry_run: + _show_dry_run_commands( + [ + ["rm", "-rf", str(p.path)] + for i in selected + for p in candidates[i]["store"].old_days + ] + ) + return + + if not yes and not _confirm_remove(): + click.echo("Anulowano.") + return + freed = 0 + for index in selected: + store = candidates[index]["store"] + removed, errors = remove_old_partitions(store) + if removed == len(store.old_days): + freed += store.reclaimable_bytes + style = "green" if not errors else "yellow" + click.echo( + click.style( + f" {store.path}: usunięto {removed}/{len(store.old_days)} partycji", + fg=style, + ) + ) + for error in errors[:5]: + click.echo(click.style(f" {error}", fg="red")) + click.echo(click.style(f" Zwolniono około {_format_bytes(freed)}", fg="green")) diff --git a/fixos/cli/cleanup_cmd.py b/fixos/cli/cleanup_cmd.py index de5dfcf..c8f51c1 100644 --- a/fixos/cli/cleanup_cmd.py +++ b/fixos/cli/cleanup_cmd.py @@ -25,6 +25,7 @@ _cleanup_journal, _cleanup_libvirt, _cleanup_snap_old, + _cleanup_state_old, _cleanup_thumbnails, _cleanup_tmp, _cleanup_trash, @@ -37,6 +38,7 @@ from fixos.cli._cleanup_venvs import _cleanup_venvs from fixos.constants import ( DEFAULT_CLEANUP_THRESHOLD_MB, + DEFAULT_STATE_OLD_DAYS, DEFAULT_THUMBNAILS_UNUSED_DAYS, DEFAULT_TMP_UNUSED_DAYS, DEFAULT_TRASH_UNUSED_DAYS, @@ -1614,6 +1616,7 @@ def _execute_yes_cleanup(plan: dict, scanner, dry_run: bool = False) -> None: "(docker, docker-all, docker-old, docker-containers, docker-networks, " "docker-stale-services, orphaned-projects, ollama-old, snap-old, " "journal, user-cache, jetbrains, libvirt, gitive, docker-buildcache, docker-volumes, tmp, venvs-old, " + "state-old, " "npm, ...)" ), ) @@ -1819,6 +1822,17 @@ def _execute_yes_cleanup(plan: dict, scanner, dry_run: bool = False) -> None: default=False, help="Usuń wygenerowane miniaturki (~/.cache/thumbnails) starsze niż retencja (domyślnie 30 dni)", ) +@click.option( + "--state-old", + "state_old", + is_flag=True, + default=False, + help=( + "Przytnij partycje dzienne YYYY/MM/DD w ~/.local/state i ~/.local/share " + f"starsze niż --days (domyślnie {DEFAULT_STATE_OLD_DAYS}); bieżący dzień " + "i pliki poza partycjami pozostają" + ), +) @click.option( "--venvs-old", is_flag=True, @@ -1923,6 +1937,7 @@ def cleanup_services( tmp_cleanup, trash_cleanup, thumbnails_cleanup, + state_old, venvs_old, projects_path, days, @@ -2036,6 +2051,7 @@ def cleanup_services( tmp_cleanup, trash_cleanup, thumbnails_cleanup, + state_old, ) ) or cleanup not in (None, "venvs-old"): raise click.UsageError("--venvs-old uruchom jako osobną akcję") @@ -2086,6 +2102,7 @@ def cleanup_services( tmp_cleanup, trash_cleanup, thumbnails_cleanup, + state_old, days is not None, retention is not None, hours is not None, @@ -2178,6 +2195,18 @@ def cleanup_services( else DEFAULT_THUMBNAILS_UNUSED_DAYS, ), ), + "state-old": ( + state_old, + lambda j, d, lst, y: _cleanup_state_old( + j, + d, + lst, + y, + days=int(effective_days) + if effective_days is not None + else DEFAULT_STATE_OLD_DAYS, + ), + ), } want_space = [ name diff --git a/fixos/constants.py b/fixos/constants.py index c348dd2..405624e 100644 --- a/fixos/constants.py +++ b/fixos/constants.py @@ -105,6 +105,7 @@ DEFAULT_TMP_UNUSED_DAYS = 1.0 DEFAULT_TRASH_UNUSED_DAYS = 14.0 DEFAULT_THUMBNAILS_UNUSED_DAYS = 30.0 +DEFAULT_STATE_OLD_DAYS = 14 # Developer project artifact scanner (venvs, node_modules, build caches, ...) PROJECT_SCAN_DEFAULT_PATH = "~/github" diff --git a/fixos/diagnostics/state_retention.py b/fixos/diagnostics/state_retention.py new file mode 100644 index 0000000..a6bd979 --- /dev/null +++ b/fixos/diagnostics/state_retention.py @@ -0,0 +1,205 @@ +"""Retention for date-partitioned application state stores. + +Collectors and agents often append telemetry under XDG state/data directories +as ``/YYYY/MM/DD/`` without any retention, which can fill a disk at +several GB per day. This module finds such stores and selects whole day +partitions that are older than a cutoff, both by the partition name and by the +modification time of every file inside it. + +Only ``DD`` directories are ever selected. Files beside the year tree (for +example a collector's ``checkpoint.sqlite3``), symlinks, partitions on another +filesystem and the current day are never touched. Reclaimable size counts a +hard-linked inode only when every link to it is inside the selection, so +content-addressed snapshot trees are not overestimated. +""" + +from __future__ import annotations + +import os +import shutil +import time +from dataclasses import dataclass, field +from datetime import date, timedelta +from pathlib import Path + +DEFAULT_STATE_ROOTS = ("~/.local/state", "~/.local/share") +MAX_STORE_DEPTH = 5 + + +@dataclass +class DayPartition: + path: Path + day: date + newest_mtime: float + apparent_bytes: int + inodes: dict[tuple[int, int], tuple[int, int, int]] = field(repr=False) + + +@dataclass +class PartitionedStore: + path: Path + old_days: list[DayPartition] + kept_days: int + apparent_bytes: int + reclaimable_bytes: int + + @property + def oldest(self) -> date: + return self.old_days[0].day + + @property + def newest(self) -> date: + return self.old_days[-1].day + + +def _numeric_children(path: Path, width: int, low: int, high: int) -> list[tuple[int, Path]]: + found = [] + try: + entries = list(os.scandir(path)) + except OSError: + return found + for entry in entries: + name = entry.name + if len(name) == width and name.isdigit() and low <= int(name) <= high: + if entry.is_dir(follow_symlinks=False): + found.append((int(name), Path(entry.path))) + return sorted(found) + + +def _day_partitions(store: Path) -> list[tuple[date, Path]]: + days = [] + for year, year_path in _numeric_children(store, 4, 2000, 2100): + for month, month_path in _numeric_children(year_path, 2, 1, 12): + for day, day_path in _numeric_children(month_path, 2, 1, 31): + try: + days.append((date(year, month, day), day_path)) + except ValueError: + continue + return days + + +def _measure(day_path: Path, root_dev: int) -> DayPartition | None: + """Return the partition, or None when it cannot be removed safely.""" + newest = 0.0 + apparent = 0 + inodes: dict[tuple[int, int], tuple[int, int, int]] = {} + for current, dirs, files in os.walk(day_path, followlinks=False): + for name in dirs + files: + try: + info = os.lstat(os.path.join(current, name)) + except OSError: + return None + if info.st_dev != root_dev: + return None + newest = max(newest, info.st_mtime) + if name in files and not os.path.islink(os.path.join(current, name)): + key = (info.st_dev, info.st_ino) + size, nlink, links = inodes.get(key, (info.st_size, info.st_nlink, 0)) + if links == 0: + apparent += size + inodes[key] = (size, nlink, links + 1) + try: + info = os.lstat(day_path) + except OSError: + return None + return DayPartition(day_path, date.min, max(newest, info.st_mtime), apparent, inodes) + + +def _reclaimable(partitions: list[DayPartition]) -> int: + links: dict[tuple[int, int], int] = {} + sizes: dict[tuple[int, int], tuple[int, int]] = {} + for partition in partitions: + for key, (size, nlink, count) in partition.inodes.items(): + links[key] = links.get(key, 0) + count + sizes[key] = (size, nlink) + # Space returns only when every directory entry of the inode is removed. + return sum(size for key, (size, nlink) in sizes.items() if links[key] >= nlink) + + +def find_partitioned_stores(roots: list[Path], max_depth: int = MAX_STORE_DEPTH) -> list[Path]: + """Directories that contain a ``YYYY/MM/DD`` tree, without descending into them.""" + stores: list[Path] = [] + for root in roots: + if not root.is_dir() or root.is_symlink(): + continue + pending = [(root, 0)] + while pending: + current, depth = pending.pop() + if _day_partitions(current): + stores.append(current) + continue + if depth >= max_depth: + continue + try: + entries = list(os.scandir(current)) + except OSError: + continue + for entry in entries: + if entry.is_dir(follow_symlinks=False) and not entry.name.startswith("."): + pending.append((Path(entry.path), depth + 1)) + return sorted(stores) + + +def scan_state_retention( + days: int, + roots: list[Path] | None = None, + today: date | None = None, + now: float | None = None, +) -> list[PartitionedStore]: + """Stores with day partitions older than ``days`` by name and by file mtime.""" + if days < 1: + raise ValueError("--days must be at least 1; the current day is never removed") + roots = roots or [Path(root).expanduser() for root in DEFAULT_STATE_ROOTS] + today = today or date.today() + now = now if now is not None else time.time() + cutoff_day = today - timedelta(days=days) + cutoff_mtime = now - days * 86400 + result = [] + for store in find_partitioned_stores(roots): + try: + root_dev = os.lstat(store).st_dev + except OSError: + continue + old: list[DayPartition] = [] + kept = 0 + for day, day_path in _day_partitions(store): + if day >= cutoff_day: + kept += 1 + continue + partition = _measure(day_path, root_dev) + if partition is None or partition.newest_mtime >= cutoff_mtime: + kept += 1 + continue + partition.day = day + old.append(partition) + if old: + result.append(PartitionedStore( + path=store, + old_days=old, + kept_days=kept, + apparent_bytes=sum(p.apparent_bytes for p in old), + reclaimable_bytes=_reclaimable(old), + )) + return sorted(result, key=lambda store: store.reclaimable_bytes, reverse=True) + + +def remove_old_partitions(store: PartitionedStore) -> tuple[int, list[str]]: + """Remove the selected day partitions and the month/year directories they empty.""" + removed = 0 + errors: list[str] = [] + for partition in store.old_days: + if partition.path.is_symlink(): + errors.append(f"{partition.path}: symlink") + continue + try: + shutil.rmtree(partition.path) + removed += 1 + except OSError as exc: + errors.append(f"{partition.path}: {exc}") + continue + for parent in (partition.path.parent, partition.path.parent.parent): + try: + parent.rmdir() + except OSError: + break + return removed, errors diff --git a/project/ticket-091/README.md b/project/ticket-091/README.md new file mode 100644 index 0000000..d18fc66 --- /dev/null +++ b/project/ticket-091/README.md @@ -0,0 +1,20 @@ +# Ticket 091: Retention for date-partitioned state stores in fixos cleanup + +- **ID**: ticket-091 +- **Owner**: unresolved:human +- **Status**: IN_PROGRESS +- **Workflow state**: EDIT +- **Created**: 2026-10-20 + +## Goal and scope + +To be completed from human-owned input. + +## Acceptance criteria + +- [ ] AC-01: Scope is approved by a human owner. + +## Tracking boundary + +This directory contains the minimal reviewed intent. Optional participant prose +and raw command logs are not required delivery output. diff --git a/project/ticket-091/intent.json b/project/ticket-091/intent.json new file mode 100644 index 0000000..8b61520 --- /dev/null +++ b/project/ticket-091/intent.json @@ -0,0 +1,99 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-091", + "summary": "Retention for date-partitioned state stores in fixos cleanup", + "workstream": "application", + "classification": { + "kind": "BUG", + "priority": "P1", + "origin": "requested" + }, + "allowedPaths": [ + "project/ticket-091/**", + "TODO.md", + "project/TICKETS.md", + "fixos/cli/_cleanup_space.py", + "fixos/cli/cleanup_cmd.py", + "fixos/constants.py", + "fixos/diagnostics/state_retention.py", + "tests/unit/test_state_retention.py", + "tests/unit/test_jetbrains_toolbox_v2.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "48b29790f2d88397712dc91f44a2ecf7109c5ab0", + "targetBranch": "main", + "outcome": "fixos cleanup finds date-partitioned application state stores under ~/.local/state and ~/.local/share and prunes whole day partitions older than --days with a hardlink-aware reclaim estimate. JetBrains cleanup also finds data directories of superseded IDE versions that no registered install uses, and reports duplicate Toolbox installs instead of deleting them.", + "nonGoals": [ + "No deletion outside YYYY/MM/DD day partitions", + "No change to the collectors that write these stores", + "No automatic selection without --yes or an explicit choice" + ], + "complexity": "M", + "estimatedMinutes": 90, + "budgets": { + "maxImplementationFiles": 7, + "maxAffectedComponents": 2, + "maxPublicInterfaceChanges": 2, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Pure scanner/remover in fixos/diagnostics/state_retention.py; CLI handler in _cleanup_space.py following the thumbnails pattern; --state-old flag and -c state-old in cleanup_cmd space_actions; policy matrix entry with review risk.", + "components": [ + { + "name": "state-retention", + "paths": [ + "fixos/diagnostics/state_retention.py", + "tests/unit/test_state_retention.py" + ] + }, + { + "name": "cleanup-cli", + "paths": [ + "fixos/cli/_cleanup_space.py", + "fixos/cli/cleanup_cmd.py", + "fixos/constants.py", + "tests/unit/test_jetbrains_toolbox_v2.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [ + "New fixos cleanup --state-old option (default 14 days)", + "fixos cleanup --jetbrains lists stale version data dirs and duplicate-install advisories" + ], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Revert the commit; the option is opt-in and existing cleanup paths are unchanged." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "python -m pytest tests/unit/test_state_retention.py" + ], + "evidence": "project/ticket-091/README.md" + }, + { + "criterion": "AC-02", + "commands": [ + "fixos cleanup --state-old --list", + "./project/governance-check.sh" + ], + "evidence": "project/ticket-091/README.md" + } + ] + } +} diff --git a/tests/unit/test_jetbrains_toolbox_v2.py b/tests/unit/test_jetbrains_toolbox_v2.py new file mode 100644 index 0000000..0a92084 --- /dev/null +++ b/tests/unit/test_jetbrains_toolbox_v2.py @@ -0,0 +1,72 @@ +"""JetBrains cleanup with the flat Toolbox layout and installs registered in state.json.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from fixos.cli import _cleanup_space as space + + +def _install(root: Path, name: str, data_dir: str) -> Path: + location = root / name + location.mkdir(parents=True) + (location / "product-info.json").write_text( + json.dumps({"dataDirectoryName": data_dir}), encoding="utf-8" + ) + return location + + +def _home(tmp_path: Path, tools: list[dict]) -> Path: + toolbox = tmp_path / ".local/share/JetBrains/Toolbox" + toolbox.mkdir(parents=True, exist_ok=True) + (toolbox / "state.json").write_text(json.dumps({"tools": tools}), encoding="utf-8") + for name in ("WebStorm2025.3", "WebStorm2026.2", "PyCharm2026.2", "PyCharm2026.3", "Webstorm"): + (tmp_path / ".local/share/JetBrains" / name).mkdir() + (tmp_path / ".local/share/JetBrains" / name / "data").write_bytes(b"x" * 10) + return tmp_path + + +def _tools(tmp_path: Path) -> list[dict]: + apps = tmp_path / ".local/share/JetBrains/Toolbox/apps" + snap = tmp_path / "snap/pycharm-professional/686" + return [ + {"toolId": "WebStorm", "buildNumber": "262.1", "displayVersion": "2026.2.3", + "installLocation": str(_install(apps, "webstorm", "WebStorm2026.2"))}, + {"toolId": "WebStorm", "buildNumber": "262.1", "displayVersion": "2026.2.3", + "installLocation": str(_install(apps, "webstorm-3", "WebStorm2026.2"))}, + {"toolId": "PyCharm-P", "buildNumber": "263.7", "displayVersion": "2026.3", + "installLocation": str(_install(apps, "pycharm-2", "PyCharm2026.3"))}, + {"toolId": "PyCharm-P", "buildNumber": "262.9", "displayVersion": "2026.2.3", + "installLocation": str(_install(snap.parent, snap.name, "PyCharm2026.2"))}, + ] + + +def test_only_superseded_unused_version_data_is_a_candidate(tmp_path): + home = _home(tmp_path, _tools(tmp_path)) + names = [c["description"] for c in space._jetbrains_stale_data_dirs(home, space._toolbox_installs(home))] + # PyCharm2026.2 is older than 2026.3 but the snap install still uses it. + assert names == ["WebStorm2025.3"] + + +def test_unreadable_install_fails_closed(tmp_path): + tools = _tools(tmp_path) + tools.append({"toolId": "Gateway", "buildNumber": "1", "installLocation": str(tmp_path / "missing")}) + home = _home(tmp_path, tools) + assert space._toolbox_installs(home) is None + assert space._jetbrains_stale_data_dirs(home, None) == [] + + +def test_duplicate_registered_installs_are_advised_not_deleted(tmp_path): + home = _home(tmp_path, _tools(tmp_path)) + [advisory] = space._toolbox_duplicate_installs(space._toolbox_installs(home)) + assert "WebStorm 2026.2.3 (262.1) zainstalowany 2×" in advisory + assert "Toolbox" in advisory + + +def test_jetbrains_candidates_include_stale_data_without_touching_installs(tmp_path, monkeypatch): + home = _home(tmp_path, _tools(tmp_path)) + monkeypatch.setattr(space.Path, "home", classmethod(lambda cls: home)) + paths = {c["path"] for c in space._jetbrains_candidates()} + assert home / ".local/share/JetBrains/WebStorm2025.3" in paths + assert not any("Toolbox/apps" in str(path) for path in paths) diff --git a/tests/unit/test_state_retention.py b/tests/unit/test_state_retention.py new file mode 100644 index 0000000..300def0 --- /dev/null +++ b/tests/unit/test_state_retention.py @@ -0,0 +1,171 @@ +"""Tests for retention of date-partitioned state stores (fixos cleanup --state-old).""" + +from __future__ import annotations + +import json +import os +import time +from datetime import date +from pathlib import Path + +import click +import pytest +from click.testing import CliRunner + +from fixos.cli import _cleanup_space as space +from fixos.cli import cleanup_cmd +from fixos.diagnostics.state_retention import ( + find_partitioned_stores, + remove_old_partitions, + scan_state_retention, +) + +TODAY = date(2026, 10, 20) +NOW = time.mktime((2026, 10, 20, 12, 0, 0, 0, 0, -1)) +DAY = 86400 + + +def _write(path: Path, size: int, age_days: float) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"x" * size) + stamp = NOW - age_days * DAY + os.utime(path, (stamp, stamp)) + return path + + +def _age_dirs(root: Path, age_days: float) -> None: + stamp = NOW - age_days * DAY + for current, dirs, _files in os.walk(root): + for name in dirs: + os.utime(os.path.join(current, name), (stamp, stamp)) + + +def _store(tmp_path: Path) -> Path: + store = tmp_path / "state/collector/diagnostics/v1/processes" + _write(store / "2026/09/01/00.jsonl", 1000, 49) + _write(store / "2026/09/02/00.jsonl", 2000, 48) + _write(store / "2026/10/19/00.jsonl", 4000, 1) + _write(store / "checkpoint.sqlite3", 500, 60) + _age_dirs(store / "2026/09", 48) + return store + + +def _scan(tmp_path: Path, days: int = 14): + return scan_state_retention(days, roots=[tmp_path / "state"], today=TODAY, now=NOW) + + +def test_finds_store_and_selects_only_old_day_partitions(tmp_path): + store = _store(tmp_path) + [found] = _scan(tmp_path) + assert found.path == store + assert [str(p.day) for p in found.old_days] == ["2026-09-01", "2026-09-02"] + assert found.kept_days == 1 + assert found.apparent_bytes == found.reclaimable_bytes == 3000 + + +def test_store_detection_does_not_descend_into_partitions(tmp_path): + store = _store(tmp_path) + _write(tmp_path / "state/other/notes.txt", 10, 100) + assert find_partitioned_stores([tmp_path / "state"]) == [store] + + +def test_recent_write_into_old_named_partition_keeps_it(tmp_path): + store = _store(tmp_path) + _write(store / "2026/09/01/late.jsonl", 10, 0.1) + [found] = _scan(tmp_path) + assert [str(p.day) for p in found.old_days] == ["2026-09-02"] + assert found.kept_days == 2 + + +def test_current_day_cannot_be_selected(tmp_path): + _store(tmp_path) + with pytest.raises(ValueError): + _scan(tmp_path, days=0) + + +def test_hardlink_shared_with_kept_data_is_not_reclaimable(tmp_path): + store = _store(tmp_path) + blob = _write(tmp_path / "state/blobs/a.blob", 5000, 60) + os.link(blob, store / "2026/09/01/a.blob") + os.utime(store / "2026/09/01", (NOW - 48 * DAY,) * 2) + [found] = _scan(tmp_path) + assert found.apparent_bytes == 8000 + assert found.reclaimable_bytes == 3000 + + +def test_hardlinks_inside_selection_are_counted_once(tmp_path): + store = _store(tmp_path) + first = _write(store / "2026/09/01/b.blob", 7000, 49) + os.link(first, store / "2026/09/01/b-copy.blob") + os.link(first, store / "2026/09/02/b.blob") + _age_dirs(store / "2026/09", 48) + [found] = _scan(tmp_path) + assert found.reclaimable_bytes == 3000 + 7000 + + +def test_removal_keeps_checkpoint_recent_day_and_symlink_target(tmp_path): + store = _store(tmp_path) + outside = _write(tmp_path / "keep/precious.db", 10, 100) + os.symlink(outside, store / "2026/09/02/link.db") + os.utime(store / "2026/09/02/link.db", (NOW - 48 * DAY,) * 2, follow_symlinks=False) + _age_dirs(store / "2026/09", 48) + [found] = _scan(tmp_path) + removed, errors = remove_old_partitions(found) + assert (removed, errors) == (2, []) + assert not (store / "2026/09").exists() + assert (store / "2026/10/19/00.jsonl").is_file() + assert (store / "checkpoint.sqlite3").is_file() + assert outside.read_bytes() == b"x" * 10 + + +def test_symlinked_store_root_is_ignored(tmp_path): + real = tmp_path / "real" + _write(real / "2026/09/01/00.jsonl", 10, 49) + (tmp_path / "state").mkdir() + os.symlink(real, tmp_path / "state/linked") + assert _scan(tmp_path) == [] + + +def _invoke(fn, *args, **kwargs): + @click.command() + def command(): + fn(*args, **kwargs) + + return CliRunner().invoke(command, input=kwargs.pop("input_text", "")) + + +def test_handler_json_reports_reclaimable_and_apparent(tmp_path, monkeypatch): + _store(tmp_path) + monkeypatch.setattr( + "fixos.diagnostics.state_retention.time.time", lambda: NOW + ) + monkeypatch.setattr( + "fixos.diagnostics.state_retention.date", + type("FixedDate", (date,), {"today": classmethod(lambda cls: TODAY)}), + ) + result = _invoke( + space._cleanup_state_old, True, False, False, False, + days=14, roots=[tmp_path / "state"], + ) + payload = json.loads(result.output) + assert payload["total_bytes"] == 3000 + assert payload["candidates"][0]["partitions"] == 2 + + +def test_cli_dry_run_lists_partitions_and_removes_nothing(tmp_path, monkeypatch): + home = tmp_path / "home" + _store(home / ".local") + store = home / ".local/state/collector/diagnostics/v1/processes" + monkeypatch.setenv("HOME", str(home)) + result = CliRunner().invoke( + cleanup_cmd.cleanup_services, ["--state-old", "--days", "14", "--dry-run", "--yes"] + ) + assert result.exit_code == 0, result.output + assert f"rm -rf {store / '2026/09/01'}" in result.output + assert str(store / "2026/10/19") not in result.output + assert (store / "2026/09/01/00.jsonl").is_file() + + +def test_policy_matrix_lists_state_old(): + ids = {policy["id"] for policy in space.CLEANUP_POLICIES} + assert "state-old" in ids From e2d3e12d22a1109efbb56003ef3cd462eb0262c8 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Tue, 20 Oct 2026 21:57:13 +0200 Subject: [PATCH 2/2] style(cleanup): satisfy ruff SIM102 and DTZ011 in state retention (ticket-091) Co-Authored-By: Claude Opus 5.5 --- fixos/diagnostics/state_retention.py | 19 ++++++++++++++----- tests/unit/test_state_retention.py | 5 +---- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/fixos/diagnostics/state_retention.py b/fixos/diagnostics/state_retention.py index a6bd979..cabc82f 100644 --- a/fixos/diagnostics/state_retention.py +++ b/fixos/diagnostics/state_retention.py @@ -19,7 +19,7 @@ import shutil import time from dataclasses import dataclass, field -from datetime import date, timedelta +from datetime import date, datetime, timedelta from pathlib import Path DEFAULT_STATE_ROOTS = ("~/.local/state", "~/.local/share") @@ -60,9 +60,13 @@ def _numeric_children(path: Path, width: int, low: int, high: int) -> list[tuple return found for entry in entries: name = entry.name - if len(name) == width and name.isdigit() and low <= int(name) <= high: - if entry.is_dir(follow_symlinks=False): - found.append((int(name), Path(entry.path))) + if ( + len(name) == width + and name.isdigit() + and low <= int(name) <= high + and entry.is_dir(follow_symlinks=False) + ): + found.append((int(name), Path(entry.path))) return sorted(found) @@ -116,6 +120,11 @@ def _reclaimable(partitions: list[DayPartition]) -> int: return sum(size for key, (size, nlink) in sizes.items() if links[key] >= nlink) +def _today() -> date: + """Local calendar day, the same clock collectors use to name partitions.""" + return datetime.now().astimezone().date() + + def find_partitioned_stores(roots: list[Path], max_depth: int = MAX_STORE_DEPTH) -> list[Path]: """Directories that contain a ``YYYY/MM/DD`` tree, without descending into them.""" stores: list[Path] = [] @@ -150,7 +159,7 @@ def scan_state_retention( if days < 1: raise ValueError("--days must be at least 1; the current day is never removed") roots = roots or [Path(root).expanduser() for root in DEFAULT_STATE_ROOTS] - today = today or date.today() + today = today or _today() now = now if now is not None else time.time() cutoff_day = today - timedelta(days=days) cutoff_mtime = now - days * 86400 diff --git a/tests/unit/test_state_retention.py b/tests/unit/test_state_retention.py index 300def0..4116a0f 100644 --- a/tests/unit/test_state_retention.py +++ b/tests/unit/test_state_retention.py @@ -139,10 +139,7 @@ def test_handler_json_reports_reclaimable_and_apparent(tmp_path, monkeypatch): monkeypatch.setattr( "fixos.diagnostics.state_retention.time.time", lambda: NOW ) - monkeypatch.setattr( - "fixos.diagnostics.state_retention.date", - type("FixedDate", (date,), {"today": classmethod(lambda cls: TODAY)}), - ) + monkeypatch.setattr("fixos.diagnostics.state_retention._today", lambda: TODAY) result = _invoke( space._cleanup_state_old, True, False, False, False, days=14, roots=[tmp_path / "state"],