Created
June 8, 2026 16:37
-
-
Save romanbsd/c1e0fcdad989b921d48fe5e87abecc0b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Disk space forensics for macOS. | |
| Scans Docker, app caches, package managers, dev artifacts, logs, simulators, and more. | |
| Produces a prioritized recommendations table at the end. | |
| Usage: ~/venv/bin/python3 scripts/disk-analysis.py | |
| """ | |
| import subprocess | |
| import os | |
| import re | |
| import sys | |
| import shutil | |
| import sqlite3 | |
| from pathlib import Path | |
| import datetime | |
| # ANSI dim/reset only when running in a real terminal | |
| _tty = sys.stdout.isatty() | |
| DIM = "\033[2m" if _tty else "" | |
| RESET = "\033[0m" if _tty else "" | |
| ARROW = f"{DIM}→{RESET} " if _tty else "→ " | |
| HOME = Path.home() | |
| # ── helpers ─────────────────────────────────────────────────────────────────── | |
| def version_key(path): | |
| """Natural version sort key: '1.0.12' > '1.0.9', 'esp32-20251215' > 'esp32-20230921'.""" | |
| return [int(x) if x.isdigit() else x.lower() for x in re.split(r'(\d+)', path.name)] | |
| def run(cmd): | |
| r = subprocess.run(cmd, shell=True, capture_output=True, text=True) | |
| return r.stdout.strip() | |
| def du_kb(path): | |
| """Return size in bytes, or 0 if path doesn't exist.""" | |
| p = Path(path) if not isinstance(path, Path) else path | |
| if not p.exists(): | |
| return 0 | |
| out = run(f"du -skx '{p}' 2>/dev/null") | |
| try: | |
| return int(out.split()[0]) * 1024 | |
| except (ValueError, IndexError): | |
| return 0 | |
| def du_children(path, n=20): | |
| """Return [(size_bytes, child_path)] for immediate children, sorted desc.""" | |
| p = Path(path) if not isinstance(path, Path) else path | |
| if not p.exists(): | |
| return [] | |
| out = run(f"du -skx '{p}'/* 2>/dev/null | sort -rn | head -{n}") | |
| results = [] | |
| for line in out.splitlines(): | |
| parts = line.split(None, 1) | |
| if len(parts) == 2: | |
| try: | |
| results.append((int(parts[0]) * 1024, parts[1])) | |
| except ValueError: | |
| pass | |
| return results | |
| def find_large(root, pattern="*", min_mb=10, exclude="node_modules", n=20): | |
| """Find files matching pattern larger than min_mb under root.""" | |
| p = Path(root) if not isinstance(root, Path) else root | |
| if not p.exists(): | |
| return [] | |
| cmd = (f"find '{p}' -name '{pattern}' -size +{min_mb}M" | |
| f" -not -path '*/{exclude}/*' 2>/dev/null" | |
| f" | xargs -I{{}} du -sk {{}} 2>/dev/null | sort -rn | head -{n}") | |
| out = run(cmd) | |
| results = [] | |
| for line in out.splitlines(): | |
| parts = line.split(None, 1) | |
| if len(parts) == 2: | |
| try: | |
| results.append((int(parts[0]) * 1024, parts[1])) | |
| except ValueError: | |
| pass | |
| return results | |
| def human(b): | |
| for unit in ["B", "KB", "MB", "GB", "TB"]: | |
| if abs(b) < 1024.0: | |
| return f"{b:.1f} {unit}" | |
| b /= 1024.0 | |
| return f"{b:.1f} PB" | |
| def section(title): | |
| print(f"\n{'━'*70}") | |
| print(f" {title}") | |
| print(f"{'━'*70}") | |
| # ── recommendations collector ───────────────────────────────────────────────── | |
| SAFE = "🟢 Safe" | |
| MINOR = "🟡 Minor loss" | |
| DATA = "🔴 Data loss" | |
| recs = [] # (size_bytes, safety, description, command) | |
| def rec(size, safety, description, command): | |
| if size > 0: | |
| recs.append((size, safety, description, command)) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # 1. DISK OVERVIEW | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| section("1. DISK OVERVIEW") | |
| total, used, free = shutil.disk_usage("/") | |
| pct = used / total * 100 | |
| print(f" Total : {human(total)}") | |
| print(f" Used : {human(used)} ({pct:.0f}%)") | |
| print(f" Free : {human(free)}") | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # 2. DOCKER | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| section("2. DOCKER") | |
| docker_raw = HOME / "Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw" | |
| docker_raw_size = du_kb(docker_raw) | |
| if docker_raw_size: | |
| print(f" VM disk (Docker.raw): {human(docker_raw_size)}") | |
| def _parse_docker_size_str(s): | |
| s = s.strip().strip("()") | |
| for suffix, mult in [("TB", 1e12), ("GB", 1e9), ("MB", 1e6), ("kB", 1e3), ("B", 1)]: | |
| if s.endswith(suffix): | |
| try: | |
| return int(float(s[:-len(suffix)]) * mult) | |
| except ValueError: | |
| pass | |
| return 0 | |
| build_cache_reclaimable = 0 | |
| dangling_images_size = 0 | |
| containers_reclaimable = 0 | |
| out = run("docker system df 2>/dev/null") | |
| if out: | |
| for line in out.splitlines(): | |
| print(f" {line}") | |
| for line in out.splitlines(): | |
| parts = line.split() | |
| if len(parts) < 4: | |
| continue | |
| if parts[0] == "Build": | |
| build_cache_reclaimable = _parse_docker_size_str(parts[-1]) | |
| elif parts[0] == "Images": | |
| dangling_images_size = _parse_docker_size_str(parts[-1]) | |
| elif parts[0] == "Containers": | |
| containers_reclaimable = _parse_docker_size_str(parts[-1]) | |
| if build_cache_reclaimable: | |
| rec(build_cache_reclaimable, SAFE, | |
| "Docker build cache (reclaimable)", | |
| "docker buildx prune --force") | |
| if dangling_images_size: | |
| rec(dangling_images_size, SAFE, | |
| "Docker dangling images", | |
| "docker image prune --force") | |
| if containers_reclaimable: | |
| rec(containers_reclaimable, SAFE, | |
| "Docker stopped containers", | |
| "docker container prune --force") | |
| if docker_raw_size: | |
| rec(docker_raw_size // 3, SAFE, | |
| "Docker VM disk — compact after prune (estimated recoverable ~1/3)", | |
| "docker run --rm --privileged --pid=host alpine nsenter -t 1 -m -- fstrim /var/lib/docker\n" | |
| " # then: Docker Desktop → Resources → Reclaim disk space") | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # 3. APPLICATION SUPPORT — deep scan | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| section("3. APPLICATION SUPPORT — top items") | |
| app_support = HOME / "Library/Application Support" | |
| children = du_children(app_support, n=30) | |
| for size, path in children[:25]: | |
| print(f" {human(size):>10} {path}") | |
| # ── Cursor ──────────────────────────────────────────────────────────────────── | |
| print() | |
| print(" — Cursor detail —") | |
| cursor_base = app_support / "Cursor" | |
| state_vscdb = cursor_base / "User/globalStorage/state.vscdb" | |
| state_vscdb_backup = cursor_base / "User/globalStorage/state.vscdb.backup" | |
| cached_data = cursor_base / "CachedData" | |
| cursor_history = cursor_base / "User/History" | |
| cursor_logs = cursor_base / "logs" | |
| cursor_workspace = cursor_base / "User/workspaceStorage" | |
| for label, p in [ | |
| ("state.vscdb", state_vscdb), | |
| ("state.vscdb.backup", state_vscdb_backup), | |
| ("CachedData (old versions)", cached_data), | |
| ("User/History", cursor_history), | |
| ("User/workspaceStorage", cursor_workspace), | |
| ("logs", cursor_logs), | |
| ]: | |
| s = du_kb(p) | |
| if s: | |
| print(f" {human(s):>10} {label}") | |
| backup_size = du_kb(state_vscdb_backup) | |
| rec(backup_size, SAFE, | |
| "Cursor state.vscdb.backup — redundant copy, never used by the app", | |
| f"rm '{state_vscdb_backup}'") | |
| vscdb_size = du_kb(state_vscdb) | |
| rec(vscdb_size, DATA, | |
| "Cursor state.vscdb — all AI chat history, checkpoints, codebase index", | |
| f"# Close Cursor first\nrm '{state_vscdb}' '{state_vscdb_backup}'") | |
| cacheddata_size = du_kb(cached_data) | |
| rec(cacheddata_size, SAFE, | |
| "Cursor CachedData — old Cursor version bundles", | |
| f"rm -rf '{cached_data}'") | |
| history_size = du_kb(cursor_history) | |
| rec(history_size, MINOR, | |
| "Cursor User/History — cross-session undo history", | |
| f"rm -rf '{cursor_history}'") | |
| workspace_size = du_kb(cursor_workspace) | |
| rec(workspace_size, MINOR, | |
| "Cursor workspaceStorage — per-project UI state, extension storage", | |
| f"rm -rf '{cursor_workspace}'") | |
| logs_size = du_kb(cursor_logs) | |
| rec(logs_size, SAFE, | |
| "Cursor logs", | |
| f"rm -rf '{cursor_logs}'") | |
| # ── Notion ──────────────────────────────────────────────────────────────────── | |
| notion_partitions = app_support / "Notion/Partitions" | |
| notion_size = du_kb(notion_partitions) | |
| if notion_size: | |
| print(f"\n — Notion —") | |
| print(f" {human(notion_size):>10} Partitions (offline cache)") | |
| rec(notion_size, MINOR, | |
| "Notion Partitions — Electron offline cache, re-downloads on next launch", | |
| f"rm -rf '{notion_partitions}'") | |
| # ── JetBrains ──────────────────────────────────────────────────────────────── | |
| jb_support = app_support / "JetBrains" | |
| jb_cache = HOME / "Library/Caches/JetBrains" | |
| jb_support_size = du_kb(jb_support) | |
| jb_cache_size = du_kb(jb_cache) | |
| if jb_support_size or jb_cache_size: | |
| print(f"\n — JetBrains —") | |
| if jb_support_size: | |
| print(f" {human(jb_support_size):>10} Application Support/JetBrains") | |
| if jb_cache_size: | |
| print(f" {human(jb_cache_size):>10} Caches/JetBrains") | |
| # Log/index dirs inside JetBrains support are safe to clear | |
| jb_logs_size = sum(du_kb(p) for p in jb_support.glob("*/log") if p.exists()) | |
| jb_index_size = sum(du_kb(p) for p in jb_support.glob("*/index") if p.exists()) | |
| if jb_logs_size: | |
| rec(jb_logs_size, SAFE, "JetBrains IDE logs", | |
| f"find '{jb_support}' -type d -name log -exec rm -rf {{}} + 2>/dev/null") | |
| if jb_cache_size: | |
| rec(jb_cache_size, SAFE, "JetBrains Caches", | |
| f"rm -rf '{jb_cache}'") | |
| # ── VS Code ─────────────────────────────────────────────────────────────────── | |
| code_backup = app_support / "Code/User/globalStorage/state.vscdb.backup" | |
| code_backup_size = du_kb(code_backup) | |
| if code_backup_size: | |
| rec(code_backup_size, SAFE, | |
| "VS Code state.vscdb.backup — redundant", | |
| f"rm '{code_backup}'") | |
| # ── Spotify ─────────────────────────────────────────────────────────────────── | |
| spotify_cache = HOME / "Library/Caches/com.spotify.client" | |
| spotify_size = du_kb(spotify_cache) | |
| if spotify_size: | |
| rec(spotify_size, SAFE, | |
| "Spotify cache", | |
| f"rm -rf '{spotify_cache}'") | |
| # ── Slack ───────────────────────────────────────────────────────────────────── | |
| slack_cache = app_support / "Slack/Cache" | |
| slack_gpu = app_support / "Slack/GPUCache" | |
| slack_size = du_kb(slack_cache) + du_kb(slack_gpu) | |
| if slack_size: | |
| rec(slack_size, SAFE, | |
| "Slack cache", | |
| f"rm -rf '{app_support}/Slack/Cache' '{app_support}/Slack/GPUCache'") | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # 4. LIBRARY CACHES | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| section("4. LIBRARY CACHES — top items") | |
| caches = HOME / "Library/Caches" | |
| for size, path in du_children(caches, n=20): | |
| print(f" {human(size):>10} {path}") | |
| chrome_cache = caches / "Google" | |
| chrome_size = du_kb(chrome_cache) | |
| if chrome_size: | |
| rec(chrome_size, SAFE, | |
| "Google Chrome cache", | |
| f"rm -rf '{chrome_cache}'") | |
| pip_cache = caches / "pip" | |
| pip_size = du_kb(pip_cache) | |
| if pip_size: | |
| rec(pip_size, SAFE, "pip cache", "pip cache purge") | |
| firefox_cache = caches / "Firefox" | |
| firefox_size = du_kb(firefox_cache) | |
| if firefox_size: | |
| rec(firefox_size, SAFE, "Firefox cache", f"rm -rf '{firefox_cache}'") | |
| cpptools_cache = caches / "vscode-cpptools" | |
| cpptools_size = du_kb(cpptools_cache) | |
| if cpptools_size: | |
| rec(cpptools_size, SAFE, "vscode-cpptools cache", f"rm -rf '{cpptools_cache}'") | |
| node_gyp_cache = caches / "node-gyp" | |
| node_gyp_size = du_kb(node_gyp_cache) | |
| if node_gyp_size: | |
| rec(node_gyp_size, SAFE, "node-gyp cache", f"rm -rf '{node_gyp_cache}'") | |
| cocoapods_cache = caches / "CocoaPods" | |
| cocoapods_size = du_kb(cocoapods_cache) | |
| if cocoapods_size: | |
| rec(cocoapods_size, SAFE, "CocoaPods cache", "pod cache clean --all") | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # 5. PACKAGE MANAGER CACHES | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| section("5. PACKAGE MANAGER CACHES") | |
| pm_items = [ | |
| ("npm", HOME / ".npm/_cacache", SAFE, "npm cache clean --force"), | |
| ("pnpm", HOME / "Library/pnpm/store", SAFE, "pnpm store prune"), | |
| ("pnpm-c", caches / "pnpm", SAFE, "pnpm store prune"), | |
| ("yarn", HOME / "Library/Caches/Yarn", SAFE, "yarn cache clean"), | |
| ("yarn2", HOME / ".yarn/cache", SAFE, "yarn cache clean"), | |
| ("Homebrew",HOME / "Library/Caches/Homebrew", SAFE, "brew cleanup --prune=all"), | |
| ("Homebrew2",Path("/opt/homebrew/var/homebrew/locks"), SAFE, "brew cleanup --prune=all"), | |
| ("gem", HOME / ".gem", MINOR,"gem cleanup"), | |
| ("bundle", HOME / ".bundle", MINOR,"bundle clean --force"), | |
| ("cargo", HOME / ".cargo/registry", MINOR,"cargo cache --autoclean # brew install cargo-cache first"), | |
| ("gradle", HOME / ".gradle/caches", SAFE, "rm -rf ~/.gradle/caches"), | |
| ("maven", HOME / ".m2/repository", MINOR,"# Delete unused versions manually in ~/.m2/repository"), | |
| ("ivy", HOME / ".ivy2/cache", SAFE, "rm -rf ~/.ivy2/cache"), | |
| ("go", HOME / "go/pkg/mod/cache", SAFE, "go clean -modcache"), | |
| ("ruby-gems",HOME / ".local/share/gem", MINOR,"gem cleanup"), | |
| ("pip2", HOME / ".cache/pip", SAFE, "pip cache purge"), | |
| ("uv", HOME / ".cache/uv", SAFE, "uv cache clean"), | |
| ("ruff", HOME / ".cache/ruff", SAFE, "rm -rf ~/.cache/ruff"), | |
| ("mypy", HOME / ".mypy_cache", SAFE, "rm -rf ~/.mypy_cache"), | |
| ("pyc", HOME / ".cache/python", SAFE, "find . -name '__pycache__' -exec rm -rf {} + 2>/dev/null"), | |
| ("R", HOME / "Library/R", MINOR,"# Remove old R package versions manually"), | |
| ] | |
| for label, path, safety, cmd in pm_items: | |
| size = du_kb(path) | |
| if size > 1024 * 1024: # only show if >1 MB | |
| print(f" {human(size):>10} {label:<12} {path}") | |
| rec(size, safety, f"{label} cache/packages", cmd) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # 6. DEVELOPER ARTIFACTS | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| section("6. DEVELOPER ARTIFACTS") | |
| # Xcode | |
| xcode_derived = HOME / "Library/Developer/Xcode/DerivedData" | |
| xcode_archives = HOME / "Library/Developer/Xcode/Archives" | |
| xcode_device_support = HOME / "Library/Developer/Xcode/iOS DeviceSupport" | |
| xcode_sim_runtime = HOME / "Library/Developer/CoreSimulator/Volumes" | |
| ios_device_support = HOME / "Library/Developer/Xcode/iOS DeviceSupport" | |
| watchos_device_support = HOME / "Library/Developer/Xcode/watchOS DeviceSupport" | |
| xd_size = du_kb(xcode_derived) | |
| xa_size = du_kb(xcode_archives) | |
| xids_size = du_kb(ios_device_support) | |
| xwds_size = du_kb(watchos_device_support) | |
| print(f" Xcode:") | |
| for label, size, p in [ | |
| ("DerivedData", xd_size, xcode_derived), | |
| ("Archives", xa_size, xcode_archives), | |
| ("iOS DeviceSupport", xids_size, ios_device_support), | |
| ("watchOS DeviceSupport", xwds_size, watchos_device_support), | |
| ]: | |
| if size: | |
| print(f" {human(size):>10} {label}") | |
| if xd_size: | |
| rec(xd_size, SAFE, "Xcode DerivedData — build artifacts, auto-regenerated", | |
| "rm -rf ~/Library/Developer/Xcode/DerivedData") | |
| if xids_size: | |
| rec(xids_size, MINOR, "Xcode iOS DeviceSupport — debug symbols per device", | |
| "# Remove entries for devices you no longer debug\nrm -rf ~/Library/Developer/Xcode/'iOS DeviceSupport'/<version>") | |
| if xwds_size: | |
| rec(xwds_size, MINOR, "Xcode watchOS DeviceSupport", "rm -rf ~/Library/Developer/Xcode/'watchOS DeviceSupport'") | |
| # iOS Simulators | |
| print(f"\n iOS Simulators:") | |
| sim_root = HOME / "Library/Developer/CoreSimulator/Devices" | |
| sim_children = du_children(sim_root, n=20) | |
| total_sim = sum(s for s, _ in sim_children) | |
| fat_sims = [(s, p) for s, p in sim_children if s > 100 * 1024 * 1024] # >100 MB | |
| slim_sims = [(s, p) for s, p in sim_children if s <= 100 * 1024 * 1024] | |
| slim_total = sum(s for s, _ in slim_sims) | |
| for size, path in fat_sims: | |
| uuid = Path(path).name | |
| # Resolve name | |
| plist = Path(path) / "device.plist" | |
| name_out = run(f"plutil -extract name raw '{plist}' 2>/dev/null") | |
| runtime_out = run(f"plutil -extract runtime raw '{plist}' 2>/dev/null") | |
| label = f"{name_out} ({runtime_out.split('.')[-1]})" if name_out else uuid | |
| print(f" {human(size):>10} {label} [{uuid[:8]}…]") | |
| rec(size, MINOR, | |
| f"iOS Simulator data: {label}", | |
| f"xcrun simctl erase {uuid} # or: xcrun simctl delete {uuid}") | |
| if slim_sims: | |
| print(f" {human(slim_total):>10} {len(slim_sims)} other simulators (empty/minimal)") | |
| if slim_total: | |
| sim_uuids = " ".join(Path(p).name for _, p in slim_sims) | |
| rec(slim_total, SAFE, | |
| f"iOS Simulator shells ({len(slim_sims)} unused, minimal content)", | |
| "xcrun simctl delete unavailable # removes simulators for uninstalled runtimes\n" | |
| " # or: xcrun simctl delete all ⚠ removes ALL") | |
| # node_modules in projects | |
| print(f"\n node_modules in ~/projects:") | |
| nm_out = run(f"find '{HOME}/projects' -name 'node_modules' -maxdepth 3 -type d 2>/dev/null" | |
| f" | xargs -I{{}} du -skx {{}} 2>/dev/null | sort -rn | head -20") | |
| nm_total = 0 | |
| nm_items_list = [] | |
| for line in nm_out.splitlines(): | |
| parts = line.split(None, 1) | |
| if len(parts) == 2: | |
| try: | |
| size = int(parts[0]) * 1024 | |
| nm_total += size | |
| nm_items_list.append((size, parts[1])) | |
| print(f" {human(size):>10} {parts[1]}") | |
| except ValueError: | |
| pass | |
| if nm_total: | |
| rec(nm_total, MINOR, | |
| f"node_modules in ~/projects ({len(nm_items_list)} dirs) — reinstall with npm/pnpm/bun install", | |
| "# Remove node_modules for projects you haven't touched recently:\n" | |
| " find ~/projects -name 'node_modules' -maxdepth 3 -type d -exec du -skx {} \\; | sort -rn") | |
| # __pycache__ / .pyc | |
| pyc_out = run(f"find '{HOME}/projects' -name '__pycache__' -maxdepth 5 -type d 2>/dev/null" | |
| f" | xargs -I{{}} du -skx {{}} 2>/dev/null | awk '{{s+=$1}} END {{print s}}'") | |
| pyc_total = int(pyc_out or 0) * 1024 | |
| if pyc_total > 1024 * 1024: | |
| print(f"\n {human(pyc_total):>10} __pycache__ dirs in ~/projects") | |
| rec(pyc_total, SAFE, | |
| "__pycache__ dirs in ~/projects", | |
| "find ~/projects -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null") | |
| # .pytest_cache | |
| pytest_out = run(f"find '{HOME}/projects' -name '.pytest_cache' -maxdepth 4 -type d 2>/dev/null" | |
| f" | xargs -I{{}} du -skx {{}} 2>/dev/null | awk '{{s+=$1}} END {{print s}}'") | |
| pytest_total = int(pytest_out or 0) * 1024 | |
| if pytest_total > 1024 * 1024: | |
| rec(pytest_total, SAFE, | |
| ".pytest_cache dirs in ~/projects", | |
| "find ~/projects -name '.pytest_cache' -type d -exec rm -rf {} + 2>/dev/null") | |
| # Python virtual environments in ~/projects | |
| print(f"\n Python virtual envs in ~/projects:") | |
| venv_out = run( | |
| f"find '{HOME}/projects' -maxdepth 4 -type d \\( -name '.venv' -o -name 'venv' -o -name 'env' \\)" | |
| f" -not -path '*/node_modules/*' 2>/dev/null" | |
| f" | xargs -I{{}} du -skx {{}} 2>/dev/null | sort -rn | head -20" | |
| ) | |
| venv_list = [] | |
| venv_total = 0 | |
| for line in venv_out.splitlines(): | |
| parts = line.split(None, 1) | |
| if len(parts) == 2: | |
| try: | |
| size = int(parts[0]) * 1024 | |
| venv_total += size | |
| venv_list.append((size, parts[1])) | |
| except ValueError: | |
| pass | |
| if venv_list: | |
| for size, path in venv_list[:10]: | |
| print(f" {human(size):>10} {path}") | |
| if len(venv_list) > 10: | |
| print(f" ... and {len(venv_list) - 10} more") | |
| if venv_total > 1024 * 1024: | |
| rec(venv_total, MINOR, | |
| f"Python virtual envs in ~/projects ({len(venv_list)} dirs) — recreate: python3 -m venv .venv", | |
| "# Remove venvs for inactive projects:\n" | |
| " find ~/projects -maxdepth 4 -type d \\( -name '.venv' -o -name 'venv' -o -name 'env' \\)" | |
| " -exec du -skx {} \\; | sort -rn") | |
| else: | |
| print(" None found.") | |
| # Rust target/ directories detected via Cargo.toml proximity | |
| print(f"\n Rust target/ dirs in ~/projects:") | |
| rust_list = [] | |
| _projects_root = HOME / "projects" | |
| if _projects_root.exists(): | |
| for _cargo in (list(_projects_root.glob("*/Cargo.toml")) | |
| + list(_projects_root.glob("*/*/Cargo.toml")) | |
| + list(_projects_root.glob("*/*/*/Cargo.toml"))): | |
| _target = _cargo.parent / "target" | |
| if _target.exists(): | |
| s = du_kb(_target) | |
| if s > 0: | |
| rust_list.append((s, str(_target))) | |
| rust_list.sort(reverse=True) | |
| rust_total = sum(s for s, _ in rust_list) | |
| if rust_list: | |
| for size, path in rust_list[:10]: | |
| print(f" {human(size):>10} {path}") | |
| if len(rust_list) > 10: | |
| print(f" ... and {len(rust_list) - 10} more") | |
| cmd_remove = "\n ".join(f"rm -rf '{p}'" for _, p in rust_list[:6]) | |
| if len(rust_list) > 6: | |
| cmd_remove += "\n # ... (see full list above)" | |
| rec(rust_total, SAFE, | |
| f"Rust target/ build dirs ({len(rust_list)} dirs) — rebuilt with: cargo build", | |
| "# Remove build dirs for inactive Rust projects:\n " + cmd_remove) | |
| else: | |
| print(" None found.") | |
| # Language runtime manager — old installed versions | |
| _nvm_dir = HOME / ".nvm/versions/node" | |
| if _nvm_dir.exists(): | |
| _nvm_vers = sorted((v for v in _nvm_dir.iterdir() if v.is_dir()), key=version_key) | |
| if _nvm_vers: | |
| _nvm_sizes = [(du_kb(v), v) for v in _nvm_vers] | |
| _nvm_total = sum(s for s, _ in _nvm_sizes) | |
| print(f"\n nvm Node.js versions ({len(_nvm_sizes)}, {human(_nvm_total)} total):") | |
| for s, v in _nvm_sizes: | |
| marker = " ← latest" if v == _nvm_vers[-1] else "" | |
| print(f" {human(s):>10} {v.name}{marker}") | |
| _old_nvm = _nvm_sizes[:-1] | |
| _old_nvm_total = sum(s for s, _ in _old_nvm) | |
| if _old_nvm_total: | |
| rec(_old_nvm_total, SAFE, | |
| f"nvm old Node.js versions ({len(_old_nvm)} old, keeping newest)", | |
| "\n ".join(f"nvm uninstall {v.name}" for _, v in _old_nvm)) | |
| _rbenv_dir = HOME / ".rbenv/versions" | |
| if _rbenv_dir.exists(): | |
| _rb_vers = sorted((v for v in _rbenv_dir.iterdir() if v.is_dir()), key=version_key) | |
| if len(_rb_vers) > 1: | |
| _rb_sizes = [(du_kb(v), v) for v in _rb_vers] | |
| _rb_total = sum(s for s, _ in _rb_sizes) | |
| print(f"\n rbenv Ruby versions ({len(_rb_sizes)}, {human(_rb_total)} total):") | |
| for s, v in _rb_sizes: | |
| marker = " ← latest" if v == _rb_vers[-1] else "" | |
| print(f" {human(s):>10} {v.name}{marker}") | |
| _old_rb = _rb_sizes[:-1] | |
| _old_rb_total = sum(s for s, _ in _old_rb) | |
| if _old_rb_total: | |
| rec(_old_rb_total, SAFE, | |
| f"rbenv old Ruby versions ({len(_old_rb)} old, keeping newest)", | |
| "\n ".join(f"rbenv uninstall {v.name}" for _, v in _old_rb)) | |
| _pyenv_dir = HOME / ".pyenv/versions" | |
| if _pyenv_dir.exists(): | |
| _py_vers = sorted((v for v in _pyenv_dir.iterdir() if v.is_dir()), key=version_key) | |
| if len(_py_vers) > 1: | |
| _py_sizes = [(du_kb(v), v) for v in _py_vers] | |
| _py_total = sum(s for s, _ in _py_sizes) | |
| print(f"\n pyenv Python versions ({len(_py_sizes)}, {human(_py_total)} total):") | |
| for s, v in _py_sizes: | |
| marker = " ← latest" if v == _py_vers[-1] else "" | |
| print(f" {human(s):>10} {v.name}{marker}") | |
| _old_py = _py_sizes[:-1] | |
| _old_py_total = sum(s for s, _ in _old_py) | |
| if _old_py_total: | |
| rec(_old_py_total, SAFE, | |
| f"pyenv old Python versions ({len(_old_py)} old, keeping newest)", | |
| "\n ".join(f"pyenv uninstall {v.name}" for _, v in _old_py)) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # 7. LARGE LOG FILES | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| section("7. LARGE LOG FILES (>10 MB)") | |
| log_roots = [ | |
| HOME / "projects", | |
| HOME / "Library/Logs", | |
| Path("/private/var/log"), | |
| ] | |
| log_results = [] | |
| for root in log_roots: | |
| for size, path in find_large(root, "*.log", min_mb=10, n=15): | |
| log_results.append((size, path)) | |
| log_results.sort(reverse=True) | |
| total_logs = 0 | |
| if log_results: | |
| for size, path in log_results[:20]: | |
| print(f" {human(size):>10} {path}") | |
| total_logs += size | |
| if total_logs: | |
| trunc_cmds = "\n ".join(f"> '{p}'" for _, p in log_results) | |
| rec(total_logs, SAFE, | |
| f"Large log files ({len(log_results)} files >10 MB)", | |
| f"# Truncate each file (keeps the file handle open, empties content):\n " | |
| + trunc_cmds) | |
| else: | |
| print(" None found.") | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # 8. MISC / OTHER LOCATIONS | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| section("8. MISC") | |
| # Trash | |
| trash = HOME / ".Trash" | |
| trash_size = du_kb(trash) | |
| if trash_size: | |
| print(f" {human(trash_size):>10} Trash (~/.Trash)") | |
| rec(trash_size, SAFE, "Trash contents", "rm -rf ~/.Trash/* # or: Finder → Empty Trash") | |
| # Android SDK | |
| android = HOME / "Library/Android" | |
| android_size = du_kb(android) | |
| if android_size: | |
| print(f" {human(android_size):>10} Android SDK (~/Library/Android)") | |
| # Check for old SDK platforms / build-tools | |
| sdk_platforms = HOME / "Library/Android/sdk/platforms" | |
| sdk_build_tools = HOME / "Library/Android/sdk/build-tools" | |
| old_build_tools = du_children(sdk_build_tools, n=20) if sdk_build_tools.exists() else [] | |
| if len(old_build_tools) > 1: | |
| old_bt_size = sum(s for s, _ in old_build_tools[1:]) # all but newest | |
| for s, p in old_build_tools: | |
| print(f" {human(s):>10} build-tools/{Path(p).name}") | |
| if old_bt_size: | |
| rec(old_bt_size, MINOR, | |
| f"Android old build-tools ({len(old_build_tools)-1} old versions)", | |
| "# Use Android Studio SDK Manager to remove old build-tools versions") | |
| # Android Studio config (old versions) | |
| as_support = HOME / "Library/Application Support/Google" | |
| as_dirs = [(du_kb(p), str(p)) for p in as_support.glob("AndroidStudio*") if p.is_dir()] | |
| as_dirs.sort(reverse=True) | |
| if len(as_dirs) > 1: | |
| old_as_size = sum(s for s, _ in as_dirs[1:]) | |
| print(f"\n Android Studio old config dirs:") | |
| for size, path in as_dirs: | |
| print(f" {human(size):>10} {Path(path).name}") | |
| rec(old_as_size, SAFE, | |
| f"Android Studio old config dirs ({len(as_dirs)-1} old versions)", | |
| f"# Keep only newest; remove older:\n" | |
| + "\n ".join(f"rm -rf '{p}'" for _, p in as_dirs[1:])) | |
| # ── Arduino ─────────────────────────────────────────────────────────────────── | |
| arduino = HOME / "Library/Arduino15" | |
| arduino_size = du_kb(arduino) | |
| if arduino_size: | |
| print(f"\n — Arduino15 ({human(arduino_size)} total) —") | |
| # staging: always safe to delete | |
| staging_libs = arduino / "staging/libraries" | |
| staging_pkgs = arduino / "staging/packages" | |
| staging_libs_size = du_kb(staging_libs) | |
| staging_pkgs_size = du_kb(staging_pkgs) | |
| staging_total = staging_libs_size + staging_pkgs_size | |
| if staging_total: | |
| print(f" {human(staging_total):>10} staging/ (download cache — safe to delete)") | |
| if staging_libs_size: | |
| # list individual zips to show duplicates | |
| zip_lines = run(f"ls -1 '{staging_libs}' 2>/dev/null | sort -V") | |
| for z in zip_lines.splitlines(): | |
| size = du_kb(staging_libs / z) | |
| print(f" {human(size):>8} {z}") | |
| rec(staging_total, SAFE, | |
| "Arduino15/staging — downloaded library/package zips (re-downloaded on next install)", | |
| f"rm -rf '{arduino}/staging/libraries'/* '{arduino}/staging/packages'/*") | |
| # hardware: list versions per platform, flag old ones | |
| old_hw_paths = [] | |
| old_hw_total = 0 | |
| hw_root = arduino / "packages" | |
| if hw_root.exists(): | |
| print(f"\n Hardware platforms:") | |
| for pkg_dir in sorted(hw_root.iterdir()): | |
| hw_dir = pkg_dir / "hardware" | |
| if not hw_dir.exists(): | |
| continue | |
| for board_dir in sorted(hw_dir.iterdir()): | |
| versions = sorted(board_dir.iterdir(), key=version_key) | |
| sizes = [(du_kb(v), v) for v in versions] | |
| total_board = sum(s for s, _ in sizes) | |
| print(f" {human(total_board):>10} {pkg_dir.name}/{board_dir.name}") | |
| for s, v in sizes: | |
| marker = " ← current" if v == versions[-1] else " ← OLD" | |
| print(f" {human(s):>10} {v.name}{marker}") | |
| # all but the newest version are candidates | |
| for s, v in sizes[:-1]: | |
| old_hw_paths.append((s, v)) | |
| old_hw_total += s | |
| if old_hw_total: | |
| cmd_lines = "\n ".join(f"rm -rf '{v}'" for _, v in old_hw_paths) | |
| rec(old_hw_total, MINOR, | |
| f"Arduino old hardware versions ({len(old_hw_paths)} old, keeping newest per board)", | |
| cmd_lines) | |
| # tools: find tools with multiple versions, flag old ones | |
| old_tool_paths = [] | |
| old_tool_total = 0 | |
| tool_info = [] # (total_size, pkg, tool, [(size, version_path)]) | |
| if hw_root.exists(): | |
| for pkg_dir in sorted(hw_root.iterdir()): | |
| tools_dir = pkg_dir / "tools" | |
| if not tools_dir.exists(): | |
| continue | |
| for tool_dir in sorted(tools_dir.iterdir()): | |
| versions = sorted(tool_dir.iterdir(), key=version_key) | |
| if not versions: | |
| continue | |
| sizes = [(du_kb(v), v) for v in versions if v.is_dir()] | |
| if not sizes: | |
| continue | |
| tool_total = sum(s for s, _ in sizes) | |
| tool_info.append((tool_total, pkg_dir.name, tool_dir.name, sizes)) | |
| tool_info.sort(reverse=True) | |
| print(f"\n Tools (largest first):") | |
| for tool_total, pkg, tool, sizes in tool_info: | |
| print(f" {human(tool_total):>10} {pkg}/{tool}") | |
| for s, v in sizes: | |
| marker = " ← current" if v == sizes[-1][1] else " ← OLD" | |
| print(f" {human(s):>10} {v.name}{marker}") | |
| for s, v in sizes[:-1]: | |
| old_tool_paths.append((s, v, pkg, tool)) | |
| old_tool_total += s | |
| if old_tool_total: | |
| cmd_lines = "\n ".join(f"rm -rf '{v}'" | |
| for _, v, _, _ in old_tool_paths) | |
| rec(old_tool_total, MINOR, | |
| f"Arduino old tool versions ({len(old_tool_paths)} old versions, keeping newest per tool)", | |
| cmd_lines) | |
| # chip-specific libs: informational breakdown (user decides based on target chips) | |
| esp32_libs = [ | |
| ("esp32c3", "ESP32-C3 (RISC-V, single-core)"), | |
| ("esp32c5", "ESP32-C5 (RISC-V, 802.15.4)"), | |
| ("esp32c6", "ESP32-C6 (RISC-V, Wi-Fi 6)"), | |
| ("esp32h2", "ESP32-H2 (RISC-V, BT 5 / 802.15.4)"), | |
| ("esp32s2", "ESP32-S2 (Xtensa, no BT)"), | |
| ("esp32s3", "ESP32-S3 (Xtensa, AI accel)"), | |
| ("esp32p4", "ESP32-P4 (high-perf, no radio)"), | |
| ("esp32p4_es", "ESP32-P4 engineering sample libs"), | |
| ] | |
| esp_tools = hw_root / "esp32/tools" | |
| chip_lib_info = [] | |
| if esp_tools.exists(): | |
| for lib_id, desc in esp32_libs: | |
| lib_dir = esp_tools / f"{lib_id}-libs" | |
| s = du_kb(lib_dir) | |
| if s: | |
| chip_lib_info.append((s, lib_id, desc, lib_dir)) | |
| if chip_lib_info: | |
| chip_lib_info.sort(reverse=True) | |
| total_chip_libs = sum(s for s, _, _, _ in chip_lib_info) | |
| print(f"\n ESP32 chip-specific lib dirs (remove chips you don't target):") | |
| for s, lib_id, desc, _ in chip_lib_info: | |
| print(f" {human(s):>10} {lib_id}-libs — {desc}") | |
| # Build a conditional recommendation (data loss — IDE won't compile for that chip) | |
| cmd = ("# Remove only chip families you will NEVER target.\n" | |
| " # Example — keep only esp32 + esp32s3, drop the rest:\n" | |
| + "\n ".join( | |
| f"# rm -rf '{p}'" | |
| for _, _, _, p in chip_lib_info)) | |
| rec(total_chip_libs, DATA, | |
| f"ESP32 chip-specific libs ({len(chip_lib_info)} families) — remove chips you don't target", | |
| cmd) | |
| # iCloud Drive (local cache) | |
| icloud = HOME / "Library/Mobile Documents" | |
| icloud_size = du_kb(icloud) | |
| if icloud_size: | |
| print(f" {human(icloud_size):>10} iCloud Drive local cache (~/Library/Mobile Documents)") | |
| # Time Machine local snapshots | |
| print() | |
| tm_out = run("tmutil listlocalsnapshotdates / 2>/dev/null | grep -v '^$' | grep -v 'Snapshot'") | |
| if tm_out: | |
| snap_count = len(tm_out.strip().splitlines()) | |
| print(f" Time Machine local snapshots: {snap_count}") | |
| print(tm_out) | |
| # Rough estimate: each snapshot can be hundreds of MB to several GB | |
| rec(snap_count * 500 * 1024 * 1024, SAFE, | |
| f"Time Machine local snapshots ({snap_count} snapshots, size estimated)", | |
| "sudo tmutil deletelocalsnapshots /") | |
| else: | |
| print(" Time Machine local snapshots: none") | |
| # macOS temp | |
| tmp_size = du_kb("/private/var/folders") | |
| print(f" {human(tmp_size):>10} /private/var/folders (macOS temp — managed by OS)") | |
| # Homebrew | |
| brew_cellar = Path("/opt/homebrew/Cellar") | |
| brew_cache = HOME / "Library/Caches/Homebrew" | |
| brew_size = du_kb(brew_cellar) | |
| brew_cache_size = du_kb(brew_cache) | |
| if brew_size or brew_cache_size: | |
| print(f"\n Homebrew:") | |
| if brew_size: | |
| print(f" {human(brew_size):>10} /opt/homebrew/Cellar (all installed formulae)") | |
| if brew_cache_size: | |
| print(f" {human(brew_cache_size):>10} Caches/Homebrew (download cache)") | |
| if brew_cache_size: | |
| rec(brew_cache_size, SAFE, "Homebrew download cache", "brew cleanup --prune=all") | |
| # Check for old formula versions | |
| old_formula_out = run("brew list --versions 2>/dev/null | awk 'NF>2' | head -20") | |
| if old_formula_out: | |
| print(f"\n Formulae with multiple versions (brew cleanup will remove old):") | |
| for line in old_formula_out.splitlines()[:10]: | |
| print(f" {line}") | |
| # VS Code / Cursor extensions | |
| for _ext_label, _ext_paths in [ | |
| ("VS Code", [HOME / ".vscode/extensions"]), | |
| ("Cursor", [HOME / ".cursor/extensions", app_support / "Cursor/extensions"]), | |
| ]: | |
| _ext_existing = [p for p in _ext_paths if p.exists()] | |
| _ext_total = sum(du_kb(p) for p in _ext_existing) | |
| if _ext_total: | |
| print(f"\n {_ext_label} extensions: {human(_ext_total)}") | |
| _ext_children = [] | |
| for ep in _ext_existing: | |
| _ext_children.extend(du_children(ep, n=10)) | |
| _ext_children.sort(reverse=True) | |
| for s, p in _ext_children[:5]: | |
| print(f" {human(s):>10} {Path(p).name}") | |
| if _ext_label == "VS Code": | |
| rec(_ext_total, MINOR, "VS Code extensions (~/.vscode/extensions)", | |
| "# List: code --list-extensions\n" | |
| " # Remove: code --uninstall-extension <extension-id>") | |
| else: | |
| rec(_ext_total, MINOR, "Cursor extensions", | |
| "# Cursor → Extensions panel → Installed → remove unused") | |
| # iOS/iPadOS device backups | |
| _ios_backup = HOME / "Library/Application Support/MobileSync/Backup" | |
| _ios_size = du_kb(_ios_backup) | |
| if _ios_size: | |
| print(f"\n iOS/iPadOS device backups: {human(_ios_size)}") | |
| for s, p in du_children(_ios_backup, n=5)[:5]: | |
| print(f" {human(s):>10} {Path(p).name}") | |
| rec(_ios_size, DATA, | |
| "iOS/iPadOS device backups (MobileSync/Backup)", | |
| "# Manage in Finder: select device → right-click → Manage Backups\n" | |
| f" # Location: {_ios_backup}") | |
| # Crash / diagnostic reports | |
| _diag = HOME / "Library/Logs/DiagnosticReports" | |
| _diag_size = du_kb(_diag) | |
| if _diag_size: | |
| print(f"\n DiagnosticReports (crash logs): {human(_diag_size)}") | |
| rec(_diag_size, SAFE, | |
| "macOS diagnostic/crash reports (DiagnosticReports)", | |
| f"rm -rf '{_diag}'") | |
| # Claude Code conversation history | |
| _claude_proj = HOME / ".claude/projects" | |
| _claude_size = du_kb(_claude_proj) | |
| if _claude_size: | |
| print(f"\n Claude Code conversation history: {human(_claude_size)}") | |
| for s, p in sorted(du_children(_claude_proj, n=10), reverse=True)[:5]: | |
| print(f" {human(s):>10} .../{Path(p).name}") | |
| rec(_claude_size, MINOR, | |
| "Claude Code conversation history (~/.claude/projects/)", | |
| "# Review: ls ~/.claude/projects/\n" | |
| " # Remove old: rm -rf ~/.claude/projects/<path-hash>") | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # 9. DOWNLOADS & HOME HOTSPOTS | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| section("9. DOWNLOADS & HOME HOTSPOTS") | |
| for _dir_label, _dir_path in [ | |
| ("Downloads", HOME / "Downloads"), | |
| ("Desktop", HOME / "Desktop"), | |
| ("Movies", HOME / "Movies"), | |
| ("Documents", HOME / "Documents"), | |
| ]: | |
| _dir_size = du_kb(_dir_path) | |
| if _dir_size < 50 * 1024 * 1024: | |
| continue | |
| print(f"\n — {_dir_label}: {human(_dir_size)} total —") | |
| _dir_children = du_children(_dir_path, n=15) | |
| for s, p in _dir_children[:10]: | |
| print(f" {human(s):>10} {Path(p).name}") | |
| if len(_dir_children) > 10: | |
| print(f" ... and {len(_dir_children) - 10} more items") | |
| if _dir_label == "Downloads" and _dir_size > 200 * 1024 * 1024: | |
| rec(_dir_size, DATA, | |
| f"Downloads folder ({human(_dir_size)}) — review and delete unneeded files", | |
| f"open '{_dir_path}' # opens in Finder for manual review") | |
| elif _dir_label == "Movies" and _dir_size > 500 * 1024 * 1024: | |
| rec(_dir_size, DATA, | |
| f"Movies folder ({human(_dir_size)}) — large video files", | |
| f"open '{_dir_path}' # opens in Finder for manual review") | |
| # Large individual files outside Library/ (spot check) | |
| print(f"\n — Large files > 500 MB outside ~/Library —") | |
| _large_out = run( | |
| f"find '{HOME}' -maxdepth 5 -type f -size +512000k" | |
| f" -not -path '*/Library/*'" | |
| f" -not -path '*/node_modules/*'" | |
| f" -not -path '*/.nvm/*'" | |
| f" -not -path '*/.gradle/*'" | |
| f" -not -path '*/target/*'" | |
| f" 2>/dev/null" | |
| f" | xargs -I{{}} du -sk {{}} 2>/dev/null | sort -rn | head -20" | |
| ) | |
| _large_list = [] | |
| for line in _large_out.splitlines(): | |
| parts = line.split(None, 1) | |
| if len(parts) == 2: | |
| try: | |
| size = int(parts[0]) * 1024 | |
| _large_list.append((size, parts[1])) | |
| except ValueError: | |
| pass | |
| if _large_list: | |
| for s, p in _large_list: | |
| print(f" {human(s):>10} {p}") | |
| _large_total = sum(s for s, _ in _large_list) | |
| print(f"\n ► {len(_large_list)} files, {human(_large_total)} total") | |
| rec(_large_total, DATA, | |
| f"Large files >500 MB in home (outside Library/) — {len(_large_list)} files", | |
| "# Review each before deleting:\n" | |
| + "\n ".join(f"# ls -lh '{p}'" for _, p in _large_list[:5]) | |
| + ("\n # ..." if len(_large_list) > 5 else "")) | |
| else: | |
| print(" None found above 500 MB (outside Library/).") | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # 10. OPEN-BUT-DELETED FILES (phantom space) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| section("10. OPEN-BUT-DELETED FILES") | |
| lsof_out = run("lsof -nP 2>/dev/null | awk '$4 ~ /[0-9]/ && /deleted/ {print $2, $1, $7, $9}' | sort -k3 -rn | head -20") | |
| phantom_total = 0 | |
| if lsof_out: | |
| print(f" {'PID':<8} {'Process':<22} {'Size':>10} Path") | |
| print(f" {'─'*7} {'─'*21} {'─'*10} {'─'*40}") | |
| for line in lsof_out.splitlines(): | |
| parts = line.split(None, 3) | |
| if len(parts) < 3: | |
| continue | |
| pid, proc, size_str = parts[0], parts[1], parts[2] | |
| path = parts[3] if len(parts) > 3 else "" | |
| try: | |
| size = int(size_str) | |
| phantom_total += size | |
| print(f" {pid:<8} {proc:<22} {human(size):>10} {path}") | |
| except ValueError: | |
| pass | |
| if phantom_total: | |
| print(f"\n ► Total phantom space: {human(phantom_total)}") | |
| rec(phantom_total, SAFE, | |
| "Open-but-deleted files (phantom space) — restart holding processes", | |
| "# Identify processes from table above and restart them, or reboot") | |
| else: | |
| print(" None found.") | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # 11. RECOMMENDATIONS TABLE | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| section("11. RECOMMENDATIONS — sorted by size") | |
| recs.sort(key=lambda x: x[0], reverse=True) | |
| if not recs: | |
| print(" Nothing actionable found.") | |
| else: | |
| total_safe = sum(s for s, safety, _, _ in recs if safety == SAFE) | |
| total_minor = sum(s for s, safety, _, _ in recs if safety == MINOR) | |
| total_data = sum(s for s, safety, _, _ in recs if safety == DATA) | |
| # Print table | |
| col_size = 10 | |
| col_safe = 12 | |
| col_desc = 52 | |
| col_cmd = 0 # printed below | |
| print(f" {'Size':>{col_size}} {'Safety':<{col_safe}} {'Description':<{col_desc}}") | |
| print(f" {'─'*col_size} {'─'*col_safe} {'─'*col_desc}") | |
| for size, safety, desc, cmd in recs: | |
| # Wrap description if too long | |
| desc_lines = [] | |
| words = desc.split() | |
| line = "" | |
| for w in words: | |
| if len(line) + len(w) + 1 <= col_desc: | |
| line = f"{line} {w}".strip() | |
| else: | |
| desc_lines.append(line) | |
| line = w | |
| if line: | |
| desc_lines.append(line) | |
| print(f" {human(size):>{col_size}} {safety:<{col_safe}} {desc_lines[0]:<{col_desc}}") | |
| for extra in desc_lines[1:]: | |
| print(f" {' '*col_size} {' '*col_safe} {extra}") | |
| # Print command indented | |
| for cmd_line in cmd.splitlines(): | |
| print(f" {' '*col_size} {' '*col_safe} {DIM}→ {cmd_line}{RESET}") | |
| print() | |
| print(f" {'─'*70}") | |
| print(f" {'Potential savings':}") | |
| print(f" {SAFE} : {human(total_safe)}") | |
| print(f" {MINOR} : {human(total_minor)}") | |
| print(f" {DATA}: {human(total_data)}") | |
| print(f" {'Total':15}: {human(total_safe + total_minor + total_data)}") | |
| print() | |
| print(f" Disk currently: {human(free)} free of {human(total)}") | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # 12. CLEANUP SCRIPT GENERATOR | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| section("12. CLEANUP SCRIPT GENERATOR") | |
| _safe_recs = [(s, desc, cmd) for s, safety, desc, cmd in recs if safety == SAFE] | |
| if not _safe_recs: | |
| print(" No safe operations found to script.") | |
| else: | |
| _safe_recs.sort(reverse=True) | |
| _script_path = HOME / "cleanup-safe.sh" | |
| _ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") | |
| _total_safe_script = sum(s for s, _, _ in _safe_recs) | |
| _script_lines = [ | |
| "#!/usr/bin/env bash", | |
| "# Disk cleanup — SAFE operations only (caches and build artifacts that auto-regenerate)", | |
| f"# Generated by disk-analysis.py on {_ts}", | |
| f"# Potential savings: {human(_total_safe_script)}", | |
| "#", | |
| "# Review this file before running. Comment out any lines you want to skip.", | |
| "# Usage: bash ~/cleanup-safe.sh", | |
| "", | |
| "set -euo pipefail", | |
| "", | |
| ] | |
| for s, desc, cmd in _safe_recs: | |
| _script_lines.append(f"# {'─' * 60}") | |
| _script_lines.append(f"# {desc} ({human(s)})") | |
| _script_lines.append(f"echo '▶ {desc} ({human(s)})'") | |
| for _cmd_line in cmd.splitlines(): | |
| _stripped = _cmd_line.strip() | |
| if _stripped: | |
| _script_lines.append(_stripped) | |
| _script_lines.append("") | |
| _script_lines += [ | |
| "echo ''", | |
| "echo '✅ Done. Run: df -h / to see freed space.'", | |
| "", | |
| ] | |
| with open(_script_path, "w") as _f: | |
| _f.write("\n".join(_script_lines) + "\n") | |
| _script_path.chmod(0o755) | |
| print(f" Generated : {_script_path}") | |
| print(f" Operations: {len(_safe_recs)} safe items · {human(_total_safe_script)} potential savings") | |
| print() | |
| print(f" Review first : cat ~/cleanup-safe.sh") | |
| print(f" Then run : bash ~/cleanup-safe.sh") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment