Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 211 additions & 7 deletions fixos/cli/_cleanup_space.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()):
Expand Down Expand Up @@ -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(
{
Expand All @@ -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 = (
Expand Down Expand Up @@ -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",
},
{
Expand Down Expand Up @@ -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",
},
]


Expand Down Expand Up @@ -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"))
29 changes: 29 additions & 0 deletions fixos/cli/cleanup_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
_cleanup_journal,
_cleanup_libvirt,
_cleanup_snap_old,
_cleanup_state_old,
_cleanup_thumbnails,
_cleanup_tmp,
_cleanup_trash,
Expand All @@ -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,
Expand Down Expand Up @@ -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, ...)"
),
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1923,6 +1937,7 @@ def cleanup_services(
tmp_cleanup,
trash_cleanup,
thumbnails_cleanup,
state_old,
venvs_old,
projects_path,
days,
Expand Down Expand Up @@ -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ę")
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions fixos/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading