Last active
August 11, 2025 09:30
-
-
Save kibotu/fc5a298358a278e04945cbb471159763 to your computer and use it in GitHub Desktop.
Comprehensive MacOS App Cleanup Script
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 | |
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() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
dry run
sudo python3 clean.py --all --path ~/Documents/repos --dry-run
cleanup
sudo python3 clean.py --all --path ~/Documents/repos