Skip to content

Instantly share code, notes, and snippets.

@romanbsd
Created June 8, 2026 16:37
Show Gist options
  • Select an option

  • Save romanbsd/c1e0fcdad989b921d48fe5e87abecc0b to your computer and use it in GitHub Desktop.

Select an option

Save romanbsd/c1e0fcdad989b921d48fe5e87abecc0b to your computer and use it in GitHub Desktop.
#!/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