Skip to content

Instantly share code, notes, and snippets.

@kibotu
Last active February 25, 2026 09:06
Show Gist options
  • Select an option

  • Save kibotu/5a20336239bb4745e346ac113969d2cb to your computer and use it in GitHub Desktop.

Select an option

Save kibotu/5a20336239bb4745e346ac113969d2cb to your computer and use it in GitHub Desktop.
macos clean script with dry run for app developers + cursor
#!/usr/bin/env python3
"""
macOS Cleanup Tool
Usage:
python3 clean.py --system # system caches & logs
python3 clean.py --dev-caches # developer tool caches
python3 clean.py --build-folders ~/Projects # 'build' dirs in tree
python3 clean.py --docker # docker images & volumes
python3 clean.py --ios-backups # iOS device backups
python3 clean.py --simulators # iOS simulator devices
python3 clean.py --all # everything except build-folders
python3 clean.py --all --build-folders ~/Projects # the full sweep
python3 clean.py --all --dry-run # preview everything
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
HOME = Path.home()
# ---------------------------------------------------------------------------
# Data
# ---------------------------------------------------------------------------
@dataclass
class Entry:
path: str
size: int
category: str
@dataclass
class Report:
dry_run: bool = False
entries: list[Entry] = field(default_factory=list)
commands: list[str] = field(default_factory=list)
ts: datetime = field(default_factory=datetime.now)
disk_before: str = ""
disk_after: str = ""
_covered: set[str] = field(default_factory=set, repr=False)
def add(self, path: str, size: int, category: str):
if size > 0:
self.entries.append(Entry(path, size, category))
def is_covered(self, path: str) -> bool:
"""Return True if *path* falls under an already-processed root."""
rp = os.path.realpath(path)
return any(rp == c or rp.startswith(c + os.sep) for c in self._covered)
def cover(self, path: str):
self._covered.add(os.path.realpath(path))
def total(self) -> int:
return sum(e.size for e in self.entries)
def by_category(self) -> dict[str, int]:
t: dict[str, int] = {}
for e in self.entries:
t[e.category] = t.get(e.category, 0) + e.size
return t
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def fmt(n: int | float) -> str:
for u in ("B", "KB", "MB", "GB", "TB"):
if n < 1024:
return f"{n:.2f} {u}"
n /= 1024
return f"{n:.2f} PB"
def measure(path: str) -> int:
"""Return size in bytes. Uses `du` with a Python fallback."""
if not os.path.exists(path):
return 0
try:
out = subprocess.check_output(["du", "-sk", path], stderr=subprocess.DEVNULL)
return int(out.split()[0]) * 1024
except Exception:
total = 0
try:
for root, _, files in os.walk(path):
for f in files:
try:
total += os.path.getsize(os.path.join(root, f))
except OSError:
pass
except OSError:
pass
return total
def nuke(path: str, dry_run: bool):
if dry_run:
return
try:
if os.path.isdir(path):
shutil.rmtree(path, ignore_errors=True)
elif os.path.isfile(path) or os.path.islink(path):
os.remove(path)
except Exception as e:
print(f" Warning: {path}: {e}")
def shell(cmd: list[str], dry_run: bool) -> str:
if dry_run:
print(f" [DRY RUN] Would run: {' '.join(cmd)}")
return ""
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
return r.stdout.strip()
except Exception as e:
print(f" Warning: {' '.join(cmd)}: {e}")
return ""
def disk_free() -> str:
try:
r = subprocess.run(["df", "-h", "/"], capture_output=True, text=True)
return r.stdout.strip().splitlines()[1].split()[3]
except Exception:
return "?"
def process(
path: str,
cat: str,
rpt: Report,
dry_run: bool,
*,
keep_root: bool = False,
recreate: bool = False,
) -> int:
"""Measure, record, and optionally remove *path*. Returns bytes freed."""
if rpt.is_covered(path):
return 0
if not os.path.exists(path):
return 0
size = measure(path)
if size == 0:
return 0
tag = "Would clean" if dry_run else "Cleaning"
print(f" {tag}: {path} ({fmt(size)})")
rpt.add(path, size, cat)
rpt.cover(path)
if keep_root:
if os.path.isdir(path):
for entry in os.listdir(path):
nuke(os.path.join(path, entry), dry_run)
else:
nuke(path, dry_run)
if recreate and not dry_run:
os.makedirs(path, exist_ok=True)
return size
# ---------------------------------------------------------------------------
# --system
# ---------------------------------------------------------------------------
def do_system(rpt: Report, dry_run: bool) -> int:
total = 0
cat = "System"
print("\n--- System ---\n")
for p in (
HOME / "Library/Caches",
HOME / "Library/Logs",
):
total += process(str(p), cat, rpt, dry_run, keep_root=True)
total += process(
str(HOME / "Library/Saved Application State"), cat, rpt, dry_run
)
total += process(str(HOME / ".Trash"), cat, rpt, dry_run, keep_root=True)
for p in ("/Library/Caches", "/private/var/log"):
total += process(p, cat, rpt, dry_run, keep_root=True)
total += process("/private/var/vm/sleepimage", cat, rpt, dry_run)
return total
# ---------------------------------------------------------------------------
# --dev-caches
# ---------------------------------------------------------------------------
DEV_TARGETS: list[tuple[Path, bool]] = [
# iOS / macOS
(HOME / "Library/Developer/Xcode/DerivedData", False),
(HOME / "Library/Developer/Xcode/Archives", False),
(HOME / "Library/Developer/Xcode/iOS DeviceSupport", False),
(HOME / "Library/Developer/CoreSimulator/Caches", False),
(HOME / "Library/Caches/org.swift.swiftpm", False),
(HOME / "Library/org.swift.swiftpm", False),
(HOME / "Library/Caches/CocoaPods", False),
(HOME / ".cocoapods/repos", False),
(HOME / ".mint", False),
# Android / Java
(HOME / ".gradle/caches", False),
(HOME / ".gradle/wrapper/dists", False),
(HOME / ".gradle/daemon", False),
(HOME / ".m2/repository", False),
(HOME / ".android/cache", False),
# Node
(HOME / ".npm", True),
(HOME / ".cache/yarn", False),
(HOME / ".pnpm-store", False),
# Python
(HOME / ".cache/pip", False),
(HOME / "Library/Caches/pip", False),
# Rust
(HOME / ".cargo/registry", False),
(HOME / ".rustup/tmp", False),
(HOME / ".rustup/downloads", False),
# Go
(HOME / ".cache/go-build", False),
(HOME / "go/pkg/mod/cache", False),
# Flutter / Dart
(HOME / ".pub-cache", False),
# IDEs
(HOME / ".cursor/caches", True),
(HOME / "Library/Application Support/Code/CachedData", False),
(HOME / ".cache/JetBrains", False),
# Misc
(HOME / ".cache/pre-commit", False),
(HOME / "Library/Caches/Homebrew", False),
]
def do_dev_caches(rpt: Report, dry_run: bool) -> int:
total = 0
cat = "Dev Caches"
print("\n--- Developer Caches ---\n")
for path, recreate in DEV_TARGETS:
total += process(str(path), cat, rpt, dry_run, recreate=recreate)
if shutil.which("xcrun"):
tag = "[DRY RUN] Would delete" if dry_run else "Deleting"
print(f" {tag} unavailable iOS simulators")
shell(["xcrun", "simctl", "delete", "unavailable"], dry_run)
rpt.commands.append("xcrun simctl delete unavailable")
if shutil.which("brew"):
tag = "[DRY RUN] Would run" if dry_run else "Running"
print(f" {tag} brew cleanup")
shell(["brew", "cleanup", "--prune=all"], dry_run)
rpt.commands.append("brew cleanup --prune=all")
return total
# ---------------------------------------------------------------------------
# --build-folders
# ---------------------------------------------------------------------------
BUILD_SKIP_PREFIXES = [
os.path.join(str(HOME), "Library", "Android", "sdk"),
os.path.join(str(HOME), "Applications"),
]
def do_build_folders(root: str, rpt: Report, dry_run: bool) -> int:
total = 0
cat = "Build Folders"
print(f"\n--- Build Folders: {root} ---\n")
print(" Scanning...")
found: list[str] = []
for dirpath, dirnames, _ in os.walk(root):
dirnames[:] = [d for d in dirnames if d != ".git"]
if any(dirpath.startswith(s) for s in BUILD_SKIP_PREFIXES):
dirnames.clear()
continue
if "build" in dirnames:
found.append(os.path.join(dirpath, "build"))
dirnames.remove("build")
if not found:
print(" No build folders found.")
return 0
print(f" Found {len(found)} build folder(s)\n")
for bp in found:
size = measure(bp)
if size == 0:
continue
tag = "Would delete" if dry_run else "Deleting"
print(f" {tag}: {bp} ({fmt(size)})")
rpt.add(bp, size, cat)
nuke(bp, dry_run)
total += size
return total
# ---------------------------------------------------------------------------
# --docker
# ---------------------------------------------------------------------------
def do_docker(rpt: Report, dry_run: bool) -> int:
total = 0
cat = "Docker"
print("\n--- Docker ---\n")
if not shutil.which("docker"):
print(" Skipped: 'docker' not found.")
return 0
docker_data = HOME / "Library/Containers/com.docker.docker"
total += process(
str(docker_data / "Data/vms"), cat, rpt, dry_run, keep_root=True
)
total += process(
str(docker_data / "Data/docker.raw"), cat, rpt, dry_run
)
for cmd_label, cmd in [
("docker system prune -af", ["docker", "system", "prune", "-af"]),
("docker volume prune -f", ["docker", "volume", "prune", "-f"]),
("docker builder prune -af", ["docker", "builder", "prune", "-af"]),
]:
tag = "[DRY RUN] Would run" if dry_run else "Running"
print(f" {tag} {cmd_label}")
shell(cmd, dry_run)
rpt.commands.append(cmd_label)
return total
# ---------------------------------------------------------------------------
# --ios-backups
# ---------------------------------------------------------------------------
def do_ios_backups(rpt: Report, dry_run: bool) -> int:
total = 0
cat = "iOS Backups"
print("\n--- iOS Backups ---\n")
backup_dir = HOME / "Library/Application Support/MobileSync/Backup"
total += process(str(backup_dir), cat, rpt, dry_run, keep_root=True)
return total
# ---------------------------------------------------------------------------
# --simulators
# ---------------------------------------------------------------------------
def do_simulators(rpt: Report, dry_run: bool) -> int:
total = 0
cat = "Simulators"
print("\n--- iOS Simulators ---\n")
devices_dir = HOME / "Library/Developer/CoreSimulator/Devices"
total += process(str(devices_dir), cat, rpt, dry_run, keep_root=True)
profiles_dir = HOME / "Library/Developer/CoreSimulator/Profiles"
total += process(str(profiles_dir), cat, rpt, dry_run, keep_root=True)
if shutil.which("xcrun"):
tag = "[DRY RUN] Would run" if dry_run else "Running"
print(f" {tag} xcrun simctl delete all")
shell(["xcrun", "simctl", "delete", "all"], dry_run)
rpt.commands.append("xcrun simctl delete all")
return total
# ---------------------------------------------------------------------------
# Disk analysis (folded from analyze_storage.py into the report)
# ---------------------------------------------------------------------------
SPACE_HOGS = [
("Xcode DerivedData", HOME / "Library/Developer/Xcode/DerivedData"),
("iOS Device Support", HOME / "Library/Developer/Xcode/iOS DeviceSupport"),
("Android SDK", HOME / "Library/Android/sdk"),
("Docker", HOME / "Library/Containers/com.docker.docker"),
("Homebrew", Path("/opt/homebrew")),
("npm cache", HOME / ".npm"),
("Gradle cache", HOME / ".gradle"),
("CocoaPods cache", HOME / "Library/Caches/CocoaPods"),
("Simulator devices", HOME / "Library/Developer/CoreSimulator/Devices"),
("System logs", Path("/private/var/log")),
]
def snapshot_space_hogs() -> list[tuple[str, str, int]]:
"""Return [(label, path, size_bytes)] for known space-consuming dirs."""
results = []
for label, p in SPACE_HOGS:
ps = str(p)
if os.path.exists(ps):
size = measure(ps)
if size > 0:
results.append((label, ps, size))
results.sort(key=lambda x: -x[2])
return results
# ---------------------------------------------------------------------------
# Markdown report
# ---------------------------------------------------------------------------
CATS = ("System", "Dev Caches", "Docker", "iOS Backups", "Simulators", "Build Folders")
def build_markdown(rpt: Report) -> str:
mode = "Dry Run (nothing deleted)" if rpt.dry_run else "Live"
ts = rpt.ts.strftime("%Y-%m-%d %H:%M:%S")
by_cat = rpt.by_category()
verb = "Potential savings" if rpt.dry_run else "Space freed"
lines = [
"# Cleanup Report",
"",
"| | |",
"|---|---|",
f"| **Date** | {ts} |",
f"| **Mode** | {mode} |",
f"| **Disk free before** | {rpt.disk_before} |",
f"| **Disk free after** | {rpt.disk_after} |",
"",
"## Summary",
"",
f"| Category | {verb} |",
"|----------|--------|",
]
for cat in CATS:
if cat in by_cat:
lines.append(f"| {cat} | {fmt(by_cat[cat])} |")
lines += [f"| **Total** | **{fmt(rpt.total())}** |", ""]
for cat in CATS:
items = [e for e in rpt.entries if e.category == cat]
if not items:
continue
lines += [f"## {cat}", "", "| Path | Size |", "|------|------|"]
for e in sorted(items, key=lambda x: -x.size):
lines.append(f"| `{e.path}` | {fmt(e.size)} |")
lines.append("")
if rpt.commands:
lines += ["## Commands Executed", ""]
for cmd in rpt.commands:
lines.append(f"- `{cmd}`")
lines.append("")
hogs = snapshot_space_hogs()
if hogs:
lines += [
"## Disk Space Hogs (reference)",
"",
"| What | Path | Size |",
"|------|------|------|",
]
for label, path, size in hogs:
lines.append(f"| {label} | `{path}` | {fmt(size)} |")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(
description="macOS cleanup tool — flags aggregate, combine freely.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"examples:\n"
" python3 clean.py --system\n"
" python3 clean.py --dev-caches --dry-run\n"
" python3 clean.py --build-folders ~/Projects\n"
" python3 clean.py --all --dry-run\n"
" python3 clean.py --all --build-folders ~/Projects --dry-run\n"
),
)
ap.add_argument(
"--system", action="store_true", help="system caches, logs, trash"
)
ap.add_argument(
"--dev-caches",
action="store_true",
help="iOS/Android/Node/Python/Rust/Go developer caches",
)
ap.add_argument(
"--build-folders",
metavar="PATH",
help="recursively delete 'build' dirs under PATH (skips .git)",
)
ap.add_argument(
"--docker",
action="store_true",
help="docker images, volumes, and build cache",
)
ap.add_argument(
"--ios-backups",
action="store_true",
help="iOS device backups (~/Library/Application Support/MobileSync/Backup)",
)
ap.add_argument(
"--simulators",
action="store_true",
help="iOS simulator devices and profiles",
)
ap.add_argument(
"--all",
action="store_true",
help="shortcut for --system --dev-caches --docker --ios-backups --simulators",
)
ap.add_argument(
"--dry-run", action="store_true", help="simulate only — nothing is deleted"
)
args = ap.parse_args()
if args.all:
args.system = True
args.dev_caches = True
args.docker = True
args.ios_backups = True
args.simulators = True
has_work = any([
args.system, args.dev_caches, args.build_folders,
args.docker, args.ios_backups, args.simulators,
])
if not has_work:
ap.print_help()
sys.exit(0)
if args.build_folders and not os.path.isdir(args.build_folders):
print(f"Error: '{args.build_folders}' is not a valid directory.")
sys.exit(1)
rpt = Report(dry_run=args.dry_run, ts=datetime.now())
rpt.disk_before = disk_free()
label = "DRY RUN" if args.dry_run else "LIVE"
print(f"macOS Cleanup Tool [{label}]")
print("=" * 60)
if args.system:
do_system(rpt, args.dry_run)
if args.dev_caches:
do_dev_caches(rpt, args.dry_run)
if args.docker:
do_docker(rpt, args.dry_run)
if args.ios_backups:
do_ios_backups(rpt, args.dry_run)
if args.simulators:
do_simulators(rpt, args.dry_run)
if args.build_folders:
do_build_folders(args.build_folders, rpt, args.dry_run)
rpt.disk_after = disk_free()
print("\n" + "=" * 60)
by_cat = rpt.by_category()
for cat in CATS:
if cat in by_cat:
print(f" {cat}: {fmt(by_cat[cat])}")
verb = "Potential savings" if args.dry_run else "Space freed"
print(f" {verb}: {fmt(rpt.total())}")
print(f" Disk free: {rpt.disk_before} -> {rpt.disk_after}")
print("=" * 60)
if args.dry_run:
print("\nDry run complete. Re-run without --dry-run to delete.")
out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "report.md")
with open(out, "w") as f:
f.write(build_markdown(rpt))
print(f"Report saved to {out}")
if __name__ == "__main__":
main()
@kibotu
Copy link
Author

kibotu commented Feb 25, 2026

python3 clean.py --dev-caches --system --build-folders ~/Documents/repos --dry-run

Screenshot 2026-02-25 at 10 06 11

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment