Skip to content

Instantly share code, notes, and snippets.

@kibotu
Last active August 11, 2025 09:30
Show Gist options
  • Save kibotu/fc5a298358a278e04945cbb471159763 to your computer and use it in GitHub Desktop.
Save kibotu/fc5a298358a278e04945cbb471159763 to your computer and use it in GitHub Desktop.
Comprehensive MacOS App Cleanup Script
#!/usr/bin/env python3
import subprocess
import shutil
import os
import argparse
from pathlib import Path
# Global dry run flag
DRY_RUN = False
def run_cmd(cmd, sudo=False):
"""Execute a command with optional sudo."""
if DRY_RUN:
cmd_str = ' '.join(['sudo'] + cmd if sudo else cmd)
print(f" [DRY RUN] Would run: {cmd_str}")
return ""
if sudo:
cmd = ['sudo'] + cmd
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode != 0:
print(f"Warning: Command {' '.join(cmd)} failed:\n{result.stderr}")
return result.stdout.strip()
except subprocess.TimeoutExpired:
print(f"Warning: Command {' '.join(cmd)} timed out")
return ""
except Exception as e:
print(f"Warning: Command {' '.join(cmd)} failed with exception: {e}")
return ""
def folder_size(path):
"""Get size of folder or file in bytes using 'du -sk' for accuracy."""
if not os.path.exists(path):
return 0
try:
output = subprocess.check_output(['du', '-sk', path], stderr=subprocess.DEVNULL)
size_kb = int(output.split()[0].decode('utf-8'))
return size_kb * 1024
except Exception:
# Fallback to Python calculation for inaccessible directories
return get_dir_size_python(path)
def get_dir_size_python(dir_path):
"""Calculate directory size using Python (fallback method)."""
total_size = 0
try:
for dirpath, dirnames, filenames in os.walk(dir_path):
for filename in filenames:
file_path = os.path.join(dirpath, filename)
try:
total_size += os.path.getsize(file_path)
except (OSError, FileNotFoundError):
continue
except (PermissionError, FileNotFoundError):
return 0
return total_size
def format_size(bytes_size):
"""Human-readable file size."""
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if bytes_size < 1024:
return f"{bytes_size:.2f} {unit}"
bytes_size /= 1024
return f"{bytes_size:.2f} PB"
def remove_path(path):
"""Remove a file or directory."""
if DRY_RUN:
if os.path.isdir(path):
print(f" [DRY RUN] Would remove directory: {path}")
elif os.path.isfile(path):
print(f" [DRY RUN] Would remove file: {path}")
return
try:
if os.path.isdir(path):
shutil.rmtree(path, ignore_errors=True)
elif os.path.isfile(path):
os.remove(path)
except Exception as e:
print(f" Warning: Failed to remove {path}: {e}")
def delete_build_folders(root_dir):
"""Delete all 'build' folders recursively, excluding those in .git directories and skip folders."""
total_space_reclaimed = 0
build_folders_found = []
home_dir = str(Path.home())
# Folders to skip during scanning
skip_folders = [
os.path.join(home_dir, 'Library', 'Android', 'sdk'),
os.path.join(home_dir, 'Applications')
]
print(f"Scanning for build folders in: {root_dir}")
for dirpath, dirnames, filenames in os.walk(root_dir):
# Skip if the current path contains '.git'
if '.git' in dirpath.split(os.sep):
continue
# Skip specified folders
if any(dirpath.startswith(skip_folder) for skip_folder in skip_folders):
continue
if 'build' in dirnames:
build_dir_path = os.path.join(dirpath, 'build')
build_folders_found.append(build_dir_path)
if not build_folders_found:
print(" No build folders found")
return 0
for build_dir_path in build_folders_found:
if os.path.exists(build_dir_path):
space_to_reclaim = get_dir_size_python(build_dir_path)
total_space_reclaimed += space_to_reclaim
action = "[DRY RUN] Would delete" if DRY_RUN else "Deleting"
print(f" {action}: {build_dir_path} ({format_size(space_to_reclaim)})")
remove_path(build_dir_path)
return total_space_reclaimed
def clean_developer_caches():
"""Clean development-related cache directories."""
total_freed = 0
home_dir = Path.home()
# Development cache directories
dev_directories = [
# Android/Java
home_dir / '.gradle',
home_dir / '.m2',
home_dir / '.android' / 'cache',
# iOS/Swift
home_dir / 'Library' / 'Developer' / 'Xcode' / 'DerivedData',
home_dir / 'Library' / 'Caches' / 'org.swift.swiftpm',
home_dir / 'Library' / 'org.swift.swiftpm',
# Node.js
home_dir / '.npm',
home_dir / 'node_modules' / '.cache',
# Python
home_dir / '.cache' / 'pip',
home_dir / '__pycache__',
# Other common dev caches
home_dir / '.cargo' / 'registry',
home_dir / '.cache' / 'yarn',
# Mint package manager (entire folder)
home_dir / '.mint',
]
# Special handling for Cursor - only clean caches subdirectory
cursor_cache_dir = home_dir / '.cursor' / 'caches'
print("Cleaning development caches...")
for dir_path in dev_directories:
dir_path_str = str(dir_path)
if os.path.exists(dir_path_str):
try:
space_before = folder_size(dir_path_str)
if space_before > 0:
action = "[DRY RUN] Would clean" if DRY_RUN else "Cleaning"
print(f" {action}: {dir_path_str} ({format_size(space_before)})")
remove_path(dir_path_str)
# Recreate empty directory structure for some paths
if not DRY_RUN and any(x in dir_path_str for x in ['.gradle', '.m2', '.npm']):
os.makedirs(dir_path_str, exist_ok=True)
total_freed += space_before
except Exception as e:
print(f" Warning: Failed to clean {dir_path_str}: {e}")
else:
# Create empty directory for essential dev tools
if not DRY_RUN and any(x in dir_path_str for x in ['.gradle', '.m2', '.npm']):
os.makedirs(dir_path_str, exist_ok=True)
# Special handling for Cursor - only clean caches subdirectory
cursor_cache_str = str(cursor_cache_dir)
if os.path.exists(cursor_cache_str):
try:
space_before = folder_size(cursor_cache_str)
if space_before > 0:
action = "[DRY RUN] Would clean" if DRY_RUN else "Cleaning"
print(f" {action}: {cursor_cache_str} ({format_size(space_before)})")
remove_path(cursor_cache_str)
# Recreate empty caches directory
if not DRY_RUN:
os.makedirs(cursor_cache_str, exist_ok=True)
total_freed += space_before
except Exception as e:
print(f" Warning: Failed to clean {cursor_cache_str}: {e}")
return total_freed
def system_cleanup():
"""Perform system-wide cleanup operations."""
total_freed = 0
home_dir = Path.home()
cleanup_steps = [
{
"desc": "Xcode Derived Data",
"paths": [str(home_dir / "Library/Developer/Xcode/DerivedData")],
"cmds": []
},
{
"desc": "Unavailable iOS Simulators",
"paths": [],
"cmds": [["xcrun", "simctl", "delete", "unavailable"]],
"check_cmd": "xcrun"
},
{
"desc": "Old Xcode Device Support files",
"paths": [str(home_dir / "Library/Developer/Xcode/iOS DeviceSupport")],
"cmds": []
},
{
"desc": "User caches",
"paths": [str(home_dir / "Library/Caches")],
"cmds": [],
"selective": True # Only clean specific subdirectories
},
{
"desc": "System caches",
"paths": ["/Library/Caches"],
"cmds": [],
"sudo": True,
"selective": True
},
{
"desc": "Sleep image",
"paths": ["/private/var/vm/sleepimage"],
"cmds": [
["sudo", "rm", "-f", "/private/var/vm/sleepimage"],
["sudo", "touch", "/private/var/vm/sleepimage"],
["sudo", "chflags", "uchg", "/private/var/vm/sleepimage"],
],
"skip_path_size": True
},
{
"desc": "System logs",
"paths": ["/private/var/log"],
"cmds": [["sudo", "rm", "-rf", "/private/var/log/*.log"]],
"sudo": True,
"selective": True
},
{
"desc": "User logs",
"paths": [str(home_dir / "Library/Logs")],
"cmds": [],
"selective": True
},
{
"desc": "Docker system prune",
"paths": [],
"cmds": [["docker", "system", "prune", "-a", "-f"]],
"check_cmd": "docker"
},
{
"desc": "Rebuild Spotlight index",
"paths": [],
"cmds": [["sudo", "mdutil", "-E", "/"]],
"sudo": True,
"skip_path_size": True
}
]
print("Starting system cleanup...\n")
for step in cleanup_steps:
print(f"Step: {step['desc']}")
# Check if required command exists
if "check_cmd" in step:
if shutil.which(step["check_cmd"]) is None:
print(f" Skipped: '{step['check_cmd']}' command not found.")
continue
step_total_before = 0
step_total_after = 0
# Calculate size before cleanup for paths
for p in step.get("paths", []):
if step.get("skip_path_size", False):
continue
size_before = folder_size(p)
step_total_before += size_before
# Run cleanup commands if any
if step.get("cmds"):
for cmd in step["cmds"]:
sudo = step.get("sudo", False)
if not DRY_RUN:
print(f" Running command: {' '.join(cmd)}")
run_cmd(cmd, sudo=sudo)
# Clean directories
for p in step.get("paths", []):
if not os.path.exists(p):
continue
try:
if step.get("selective", False):
# For selective cleanup, only remove specific cache subdirectories
if os.path.isdir(p):
safe_cache_dirs = [
'com.apple.Safari', 'com.google.Chrome', 'Firefox',
'Adobe', 'Microsoft', 'Slack', 'Spotify'
]
for entry in os.listdir(p):
entry_path = os.path.join(p, entry)
# Skip important application caches
if not any(safe_dir in entry for safe_dir in safe_cache_dirs):
if os.path.isdir(entry_path) and entry.startswith('com.'):
# Clean contents but keep directory structure
if DRY_RUN:
print(f" [DRY RUN] Would clean contents of: {entry_path}")
else:
for subentry in os.listdir(entry_path):
subentry_path = os.path.join(entry_path, subentry)
remove_path(subentry_path)
else:
remove_path(entry_path)
else:
# Full cleanup - remove everything in directory or file itself
if os.path.isdir(p):
if DRY_RUN:
print(f" [DRY RUN] Would clean all contents of: {p}")
else:
for entry in os.listdir(p):
entry_path = os.path.join(p, entry)
remove_path(entry_path)
else:
remove_path(p)
except Exception as e:
print(f" Warning: Failed to clean {p}: {e}")
# Calculate size after cleanup (for dry run, assume same as before)
for p in step.get("paths", []):
if step.get("skip_path_size", False):
continue
if DRY_RUN:
size_after = folder_size(p) # Size remains same in dry run
else:
size_after = folder_size(p)
step_total_after += size_after
# Print freed space
if step_total_before or step_total_after:
if DRY_RUN:
freed = step_total_before # In dry run, show potential savings
print(f" [DRY RUN] Would free: {format_size(freed)} (current size: {format_size(step_total_before)})\n")
else:
freed = max(0, step_total_before - step_total_after)
print(f" Freed: {format_size(freed)} (from {format_size(step_total_before)} to {format_size(step_total_after)})\n")
total_freed += freed
else:
print(" No size info to show for this step.\n")
return total_freed
def main():
global DRY_RUN
parser = argparse.ArgumentParser(
description="Comprehensive macOS cleanup tool - removes build folders, development caches, and system clutter.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --all # Full system cleanup
%(prog)s --all --dry-run # Show what would be cleaned without doing it
%(prog)s --path /Users/john/Projects # Clean build folders in specific directory
%(prog)s --dev-caches --dry-run # Preview development cache cleanup
%(prog)s --system # System cleanup only
%(prog)s --path /path --dev-caches # Combined custom path + dev cache cleanup
"""
)
parser.add_argument("--path", type=str,
help="Root directory to search for build folders")
parser.add_argument("--all", action="store_true",
help="Perform complete cleanup (system + dev caches + build folders in home)")
parser.add_argument("--dev-caches", action="store_true",
help="Clean development-related cache directories")
parser.add_argument("--system", action="store_true",
help="Perform system cleanup operations")
parser.add_argument("--build-folders", action="store_true",
help="Clean build folders in home directory")
parser.add_argument("--dry-run", action="store_true",
help="Show what would be cleaned without actually deleting anything")
args = parser.parse_args()
# Set global dry run flag
DRY_RUN = args.dry_run
# If no arguments provided, show help
if not any([args.path, args.all, args.dev_caches, args.system, args.build_folders]):
parser.print_help()
return
total_space_freed = 0
print("🧹 macOS Comprehensive Cleanup Tool")
if DRY_RUN:
print("πŸ” DRY RUN MODE - No files will be deleted")
print("\n" + "=" * 50)
# Custom path build folder cleanup
if args.path:
if os.path.isdir(args.path):
print(f"\nπŸ“ Build Folder Cleanup: {args.path}")
print("-" * 40)
space_freed = delete_build_folders(args.path)
total_space_freed += space_freed
if space_freed > 0:
action = "Would free" if DRY_RUN else "Build folders freed"
print(f"{action}: {format_size(space_freed)}")
print()
else:
print(f"Error: '{args.path}' is not a valid directory.")
return
# Development caches cleanup
if args.dev_caches or args.all:
print("\nπŸ”§ Development Caches Cleanup")
print("-" * 40)
dev_freed = clean_developer_caches()
total_space_freed += dev_freed
if dev_freed > 0:
action = "Would free" if DRY_RUN else "Development caches freed"
print(f"{action}: {format_size(dev_freed)}")
print()
# Build folders in home directory
if args.build_folders or args.all:
print("\n🏠 Home Directory Build Folders")
print("-" * 40)
home_freed = delete_build_folders(str(Path.home()))
total_space_freed += home_freed
if home_freed > 0:
action = "Would free" if DRY_RUN else "Home build folders freed"
print(f"{action}: {format_size(home_freed)}")
print()
# System cleanup
if args.system or args.all:
print("\nπŸ–₯️ System Cleanup")
print("-" * 40)
system_freed = system_cleanup()
total_space_freed += system_freed
# Final summary
print("=" * 50)
if DRY_RUN:
print(f"πŸ” TOTAL SPACE THAT WOULD BE FREED: {format_size(total_space_freed)}")
print("=" * 50)
print("\nπŸ’‘ This was a dry run. To actually perform the cleanup, run the same command without --dry-run")
else:
print(f"πŸŽ‰ TOTAL SPACE FREED: {format_size(total_space_freed)}")
print("=" * 50)
if total_space_freed > 100 * 1024 * 1024: # More than 100MB
print("\nπŸ’‘ Cleanup complete! Consider restarting your Mac for optimal performance.")
else:
print("\nβœ… Cleanup complete!")
if __name__ == "__main__":
main()
@kibotu
Copy link
Author

kibotu commented Aug 11, 2025

dry run

sudo python3 clean.py --all --path ~/Documents/repos --dry-run

cleanup

sudo python3 clean.py --all --path ~/Documents/repos

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