Last active
February 25, 2026 09:06
-
-
Save kibotu/5a20336239bb4745e346ac113969d2cb to your computer and use it in GitHub Desktop.
macos clean script with dry run for app developers + cursor
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 | |
| """ | |
| 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() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
python3 clean.py --dev-caches --system --build-folders ~/Documents/repos --dry-run