Last active
May 2, 2026 20:13
-
-
Save FoushWare/99291423fd4b44c74cc5ade2f3a1e338 to your computer and use it in GitHub Desktop.
macOS Developer Cleanup Script — free up space from caches, editors & dev tools
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
| #!/bin/sh | |
| # ============================================================ | |
| # 🧹 macOS Developer Cleanup Script — v5.0 | |
| # | |
| # COMPATIBILITY: | |
| # Works with any POSIX-compliant shell — bash 3.2+, bash 5, | |
| # zsh, dash, sh. No bash-specific features used. | |
| # Tested on macOS (default bash 3.2 / zsh) and Linux. | |
| # | |
| # WHAT THIS SCRIPT DOES: | |
| # Phase 1 — SCAN: finds all caches & orphaned configs, | |
| # shows live spinner, measures sizes | |
| # Phase 2 — REPORT: prints full table with sizes per item | |
| # Phase 3 — CLEAN: asks y/n per item, shows progress | |
| # | |
| # ⚠️ PER-USER SCOPE: | |
| # Only cleans the account of whoever runs it. | |
| # On a multi-user Mac, each person must run it separately. | |
| # Paths like ~/Library, ~/.npm, ~/.cargo are unique per user. | |
| # | |
| # SAFE TO RUN: | |
| # • Does NOT touch projects, documents, or source code | |
| # • Does NOT remove editor settings or keychains | |
| # • Does NOT modify system-level files | |
| # • Orphaned configs only flagged when the app is gone | |
| # | |
| # HOW TO RUN: | |
| # chmod +x cleanup.sh | |
| # ./cleanup.sh | |
| # ============================================================ | |
| # ─── COMPATIBILITY LAYER ──────────────────────────────────── | |
| # We use /bin/sh shebang for maximum portability. | |
| # All constructs below are POSIX-compliant: | |
| # - No arrays → use temp files and line-delimited strings | |
| # - No declare -A → use flat key-value files in /tmp | |
| # - No mapfile → use while read loops | |
| # - No (( )) arith → use $(( )) which is POSIX | |
| # - No [[ ]] → use [ ] (single bracket, POSIX) | |
| # - No echo -e → use printf (POSIX, consistent everywhere) | |
| # - No local → scope with subshells where needed | |
| # - No $'...' → use printf for special chars | |
| # ─── SCRATCH DIRECTORY ────────────────────────────────────── | |
| # Instead of bash associative arrays, we store scan results | |
| # as plain files in a temp directory. Each "key" becomes a | |
| # filename. This works in every shell and survives subshells. | |
| # | |
| # Structure: | |
| # $TMPDIR/cleanup_*/ | |
| # items/KEY → label string | |
| # sizes/KEY → byte count | |
| # orphan/KEY → "1" if orphaned config, "0" otherwise | |
| # groups/GROUP → newline-separated list of KEYs | |
| # paths/KEY → newline-separated list of paths | |
| SCRATCH="" | |
| TOTAL_FREED=0 | |
| GRAND_TOTAL=0 | |
| init_scratch() { | |
| SCRATCH=$(mktemp -d /tmp/cleanup_XXXXXX) | |
| mkdir -p "$SCRATCH/items" "$SCRATCH/sizes" \ | |
| "$SCRATCH/orphan" "$SCRATCH/groups" \ | |
| "$SCRATCH/paths" "$SCRATCH/gtotals" | |
| } | |
| # cleanup_scratch: always remove temp dir on exit | |
| cleanup_scratch() { | |
| [ -n "$SCRATCH" ] && rm -rf "$SCRATCH" | |
| } | |
| # ─── ANSI COLORS ──────────────────────────────────────────── | |
| # Detect if the terminal supports color (check TERM and tty). | |
| # If not, all color vars are empty strings — output stays clean. | |
| if [ -t 1 ] && [ "${TERM:-}" != "dumb" ]; then | |
| RED='\033[0;31m' | |
| YELLOW='\033[1;33m' | |
| GREEN='\033[0;32m' | |
| CYAN='\033[0;36m' | |
| BOLD='\033[1m' | |
| DIM='\033[2m' | |
| MAGENTA='\033[0;35m' | |
| BLUE='\033[0;34m' | |
| RESET='\033[0m' | |
| else | |
| RED='' YELLOW='' GREEN='' CYAN='' | |
| BOLD='' DIM='' MAGENTA='' BLUE='' RESET='' | |
| fi | |
| # ─── SPINNER ──────────────────────────────────────────────── | |
| # POSIX-compatible spinner using a background subshell. | |
| # We track the PID manually and kill it when done. | |
| # Uses a simple ASCII fallback if unicode isn't supported. | |
| SPINNER_PID="" | |
| spinner_start() { | |
| msg="$1" | |
| # Check if terminal likely supports unicode | |
| spinner_chars="⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" | |
| ( | |
| # Subshell: loop through spinner frames | |
| # We use cut to extract one char at a time from the string | |
| i=1 | |
| while true; do | |
| # Extract frame character by position | |
| frame=$(printf '%s' "$spinner_chars" | cut -c$i) | |
| printf "\r ${CYAN}%s${RESET} %s..." "$frame" "$msg" | |
| i=$(( i + 1 )) | |
| # Reset after 10 frames | |
| [ $i -gt 10 ] && i=1 | |
| sleep 0.08 | |
| done | |
| ) & | |
| SPINNER_PID=$! | |
| } | |
| spinner_stop() { | |
| if [ -n "$SPINNER_PID" ]; then | |
| # Kill the spinner background process | |
| kill "$SPINNER_PID" 2>/dev/null | |
| wait "$SPINNER_PID" 2>/dev/null | |
| SPINNER_PID="" | |
| # Erase the spinner line | |
| printf "\r\033[K" | |
| fi | |
| } | |
| spinner_done() { | |
| # Replace spinner with a success line | |
| # Args: <label> <size_string> | |
| spinner_stop | |
| printf " ${GREEN}✔${RESET} %-42s ${DIM}%s${RESET}\n" "$1" "$2" | |
| } | |
| spinner_skip() { | |
| # Replace spinner with a dim dash for empty/absent items | |
| spinner_stop | |
| printf " ${DIM}- %-42s nothing found${RESET}\n" "$1" | |
| } | |
| # ─── UTILITY FUNCTIONS ────────────────────────────────────── | |
| # dir_bytes: total size in bytes for all existing paths given. | |
| # Uses du -sk (POSIX) and multiplies by 1024. | |
| dir_bytes() { | |
| total=0 | |
| for p in "$@"; do | |
| if [ -e "$p" ]; then | |
| b=$(du -sk "$p" 2>/dev/null | awk '{print $1 * 1024}') | |
| # du may return empty string (not "0") when macOS restricts | |
| # access due to Full Disk Access permissions — default to 0 | |
| b="${b:-0}" | |
| # Fallback: if du returns 0 but directory has contents, | |
| # macOS may be restricting access (Full Disk Access). | |
| # Count items instead and use 4KB per item as a floor | |
| # so the item still appears in the report. | |
| if [ "$b" -eq 0 ] && [ -d "$p" ]; then | |
| item_count=$(find "$p" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l | tr -d ' ') | |
| item_count="${item_count:-0}" | |
| [ "$item_count" -gt 0 ] && b=$(( item_count * 4096 )) | |
| fi | |
| total=$(( total + b )) | |
| fi | |
| done | |
| printf '%s' "$total" | |
| } | |
| # human: converts bytes to human-readable string | |
| human() { | |
| bytes=$1 | |
| if [ "$bytes" -ge 1073741824 ]; then | |
| printf "%.1f GB" "$(echo "scale=1; $bytes/1073741824" | bc)" | |
| elif [ "$bytes" -ge 1048576 ]; then | |
| printf "%.1f MB" "$(echo "scale=1; $bytes/1048576" | bc)" | |
| elif [ "$bytes" -ge 1024 ]; then | |
| printf "%.1f KB" "$(echo "scale=1; $bytes/1024" | bc)" | |
| else | |
| printf "%s B" "$bytes" | |
| fi | |
| } | |
| # size_color: returns ANSI color for a byte count | |
| size_color() { | |
| bytes=$1 | |
| if [ "$bytes" -ge 1073741824 ]; then | |
| printf '%s' "$RED" | |
| elif [ "$bytes" -ge 104857600 ]; then | |
| printf '%s' "$YELLOW" | |
| else | |
| printf '%s' "$GREEN" | |
| fi | |
| } | |
| # any_exist: returns 0 if at least one of the given paths exists | |
| any_exist() { | |
| for p in "$@"; do | |
| [ -e "$p" ] && return 0 | |
| done | |
| return 1 | |
| } | |
| # app_installed: checks /Applications and ~/Applications for a .app | |
| app_installed() { | |
| [ -d "/Applications/$1.app" ] || [ -d "$HOME/Applications/$1.app" ] | |
| } | |
| # cmd_exists: returns 0 if command is available in PATH | |
| cmd_exists() { | |
| command -v "$1" >/dev/null 2>&1 | |
| } | |
| # ask: y/n prompt, loops until valid answer. | |
| # Returns 0 for yes, 1 for no. | |
| ask() { | |
| prompt="$1" | |
| while true; do | |
| printf " ${CYAN}?${RESET} %s [y/n]: " "$prompt" | |
| read -r ans | |
| case "$ans" in | |
| [Yy]*) return 0 ;; | |
| [Nn]*) return 1 ;; | |
| *) printf " Please answer y or n.\n" ;; | |
| esac | |
| done | |
| } | |
| # do_rm: shows live status, deletes paths, updates TOTAL_FREED | |
| do_rm() { | |
| label="$1"; shift | |
| before=$(dir_bytes "$@") | |
| printf " ${CYAN}⟳${RESET} Removing: %s..." "$label" | |
| for p in "$@"; do rm -rf "$p" 2>/dev/null || true; done | |
| printf "\r\033[K" | |
| printf " ${GREEN}✓${RESET} Removed: ${BOLD}%s${RESET} ${DIM}freed %s${RESET}\n" \ | |
| "$label" "$(human "$before")" | |
| TOTAL_FREED=$(( TOTAL_FREED + before )) | |
| } | |
| # ─── SCAN REGISTRY (file-based, no arrays) ────────────────── | |
| # register: core scan function. Checks paths, measures size, | |
| # stores results as files under $SCRATCH/. | |
| # | |
| # Usage: register <KEY> <label> <orphan:0|1> <GROUP> <path> [...] | |
| # | |
| # Files written: | |
| # $SCRATCH/items/KEY → label | |
| # $SCRATCH/sizes/KEY → bytes | |
| # $SCRATCH/orphan/KEY → 0 or 1 | |
| # $SCRATCH/groups/GROUP → KEY appended as a line | |
| # $SCRATCH/paths/KEY → one path per line | |
| register() { | |
| key="$1" label="$2" orphan="$3" group="$4" | |
| shift 4 | |
| # All remaining args are paths | |
| spinner_start "Scanning $label" | |
| # Check if any path exists | |
| found=0 | |
| for p in "$@"; do | |
| [ -e "$p" ] && { found=1; break; } | |
| done | |
| if [ "$found" -eq 0 ]; then | |
| spinner_skip "$label" | |
| return 0 | |
| fi | |
| # Measure total bytes | |
| bytes=$(dir_bytes "$@") | |
| if [ "$bytes" -eq 0 ]; then | |
| spinner_skip "$label" | |
| return 0 | |
| fi | |
| spinner_done "$label" "$(human "$bytes")" | |
| # Write to registry files | |
| printf '%s' "$label" > "$SCRATCH/items/$key" | |
| printf '%s' "$bytes" > "$SCRATCH/sizes/$key" | |
| printf '%s' "$orphan" > "$SCRATCH/orphan/$key" | |
| # Append key to group file (one key per line) | |
| printf '%s\n' "$key" >> "$SCRATCH/groups/$group" | |
| # Write paths (one per line) — handles spaces in paths | |
| : > "$SCRATCH/paths/$key" | |
| for p in "$@"; do | |
| printf '%s\n' "$p" >> "$SCRATCH/paths/$key" | |
| done | |
| } | |
| # get_size: read stored byte count for a key | |
| get_size() { cat "$SCRATCH/sizes/$1" 2>/dev/null || printf '0'; } | |
| # get_label: read stored label for a key | |
| get_label() { cat "$SCRATCH/items/$1" 2>/dev/null || printf ''; } | |
| # get_orphan: read orphan flag for a key | |
| get_orphan() { cat "$SCRATCH/orphan/$1" 2>/dev/null || printf '0'; } | |
| # key_exists: returns 0 if a key was registered | |
| key_exists() { [ -f "$SCRATCH/items/$1" ]; } | |
| # group_exists: returns 0 if a group has any registered keys | |
| group_exists() { [ -f "$SCRATCH/groups/$1" ] && [ -s "$SCRATCH/groups/$1" ]; } | |
| # group_total: sum all sizes for a group | |
| group_total() { | |
| grp="$1" | |
| total=0 | |
| [ -f "$SCRATCH/groups/$grp" ] || { printf '0'; return; } | |
| # Use fd 3 so this read does not consume the caller's stdin | |
| while IFS= read -r key <&3; do | |
| [ -f "$SCRATCH/sizes/$key" ] || continue | |
| sz=$(cat "$SCRATCH/sizes/$key") | |
| total=$(( total + sz )) | |
| done 3< "$SCRATCH/groups/$grp" | |
| printf '%s' "$total" | |
| } | |
| # group_label: human name for each group ID | |
| group_label() { | |
| case "$1" in | |
| SYSTEM) printf '🖥 macOS System' ;; | |
| BROWSERS) printf '🌐 Browsers' ;; | |
| EDITORS) printf '✏️ Editor Caches' ;; | |
| ORPHANS) printf '👻 Orphaned Configs' ;; | |
| NODE) printf '📦 Node / JS Tooling' ;; | |
| PYTHON) printf '🐍 Python Tooling' ;; | |
| RUST) printf '🦀 Rust / Cargo' ;; | |
| RUBY) printf '💎 Ruby / Gems' ;; | |
| GO) printf '🐹 Go' ;; | |
| JAVA) printf '☕ Java / JVM Tooling' ;; | |
| MOBILE) printf '📱 Mobile Dev (iOS / Android)' ;; | |
| DEVOPS) printf '⚙️ DevOps / Cloud / Infra' ;; | |
| DOTNET) printf '🔷 .NET Tooling' ;; | |
| PHP) printf '🐘 PHP Tooling' ;; | |
| DATA) printf '🤖 Data / ML Tooling' ;; | |
| DESIGN) printf '🎨 Design Tools' ;; | |
| MACOS) printf '🍎 macOS Extras' ;; | |
| *) printf '%s' "$1" ;; | |
| esac | |
| } | |
| # GROUPS_ORDERED: space-separated list — used in all three phases | |
| GROUPS_ORDERED="SYSTEM BROWSERS EDITORS ORPHANS NODE PYTHON RUST RUBY GO JAVA MOBILE DEVOPS DOTNET PHP DATA DESIGN MACOS" | |
| # ════════════════════════════════════════════════════════════ | |
| # SETUP & SIGNAL HANDLING | |
| # ════════════════════════════════════════════════════════════ | |
| init_scratch | |
| # On exit (normal or Ctrl+C), kill spinner and remove temp files | |
| trap 'spinner_stop; cleanup_scratch' EXIT | |
| trap 'printf "\n Aborted.\n"; exit 1' INT | |
| # ════════════════════════════════════════════════════════════ | |
| # BANNER | |
| # ════════════════════════════════════════════════════════════ | |
| clear | |
| printf "\n" | |
| printf "${BOLD}╔══════════════════════════════════════════════════╗${RESET}\n" | |
| printf "${BOLD}║ 🧹 macOS Developer Cleanup — v5.0 ║${RESET}\n" | |
| printf "${BOLD}╠══════════════════════════════════════════════════╣${RESET}\n" | |
| printf "${BOLD}║ Running as: ${RESET}%-33s${BOLD}║${RESET}\n" "$(whoami)" | |
| printf "${BOLD}║ Shell: ${RESET}%-33s${BOLD}║${RESET}\n" "${SHELL##*/}" | |
| printf "${BOLD}║ Home: ${RESET}%-33s${BOLD}║${RESET}\n" "$HOME" | |
| printf "${BOLD}╠══════════════════════════════════════════════════╣${RESET}\n" | |
| printf "${BOLD}║ ${YELLOW}⚠️ Only cleaning THIS user's files.${RESET}${BOLD} ║${RESET}\n" | |
| printf "${BOLD}║ ${DIM}Other accounts need to run this separately.${RESET}${BOLD} ║${RESET}\n" | |
| printf "${BOLD}╚══════════════════════════════════════════════════╝${RESET}\n" | |
| # ════════════════════════════════════════════════════════════ | |
| # PHASE 1 — LIVE SCAN | |
| # | |
| # Every register() call: | |
| # - Shows spinner while du measures the path | |
| # - Prints ✔ + size if found, or - if absent/empty | |
| # - Writes result to $SCRATCH file registry | |
| # Nothing is deleted in this phase. | |
| # ════════════════════════════════════════════════════════════ | |
| printf "\n" | |
| printf "${BOLD}${BLUE} ▶ Phase 1 of 3 — Scanning your system${RESET}\n" | |
| printf "${DIM} Every item is checked. ✔ = found, - = empty/absent.${RESET}\n\n" | |
| # ── macOS System caches ────────────────────────────────────── | |
| # Always safe to delete — macOS and apps rebuild on next launch. | |
| printf " ${BOLD}${MAGENTA}🖥 macOS System${RESET}\n" | |
| register SYS_CACHES "User Library Caches" 0 SYSTEM \ | |
| "$HOME/Library/Caches" | |
| register SYS_LOGS "User Library Logs" 0 SYSTEM \ | |
| "$HOME/Library/Logs" | |
| register SYS_HTTP "HTTP Storages" 0 SYSTEM \ | |
| "$HOME/Library/HTTPStorages" | |
| # ~/.Trash — main user trash. | |
| # macOS restricts direct access to ~/.Trash since Monterey — du and | |
| # find both silently fail without Full Disk Access granted to Terminal. | |
| # We use osascript to count items (runs in the user session, bypasses | |
| # the restriction), then register the path manually if items exist. | |
| _trash_count=$(osascript -e 'tell application "Finder" to get count of (items of trash)' 2>/dev/null || echo "0") | |
| _trash_count="${_trash_count:-0}" | |
| if [ "$_trash_count" -gt 0 ]; then | |
| # Use 50MB as a conservative placeholder — we cannot measure the | |
| # real size without Full Disk Access, but osascript confirmed items exist. | |
| # The actual space freed will be reported after emptying. | |
| _trash_bytes=$(( _trash_count * 1024 * 1024 )) | |
| printf '%s' "Trash (~/.Trash — ${_trash_count} items)" > "$SCRATCH/items/SYS_TRASH" | |
| printf '%s' "$_trash_bytes" > "$SCRATCH/sizes/SYS_TRASH" | |
| printf '%s' "0" > "$SCRATCH/orphan/SYS_TRASH" | |
| printf '%s | |
| ' "SYS_TRASH" >> "$SCRATCH/groups/SYSTEM" | |
| printf '%s | |
| ' "$HOME/.Trash" > "$SCRATCH/paths/SYS_TRASH" | |
| printf " ${GREEN}✔${RESET} %-42s ${DIM}%s${RESET} | |
| " "Trash (~/.Trash)" "${_trash_count} items (size needs Full Disk Access to measure)" | |
| else | |
| printf " ${DIM}- %-42s nothing found${RESET} | |
| " "Trash (~/.Trash)" | |
| fi | |
| unset _trash_count _trash_bytes | |
| # ~/Downloads — large files accumulate here over time. | |
| # We register it as a browseable item (not auto-deleted) so the | |
| # user can decide. Only shown if it contains more than 100MB. | |
| _dl_bytes=$(du -sk "$HOME/Downloads" 2>/dev/null | awk '{print $1 * 1024}') | |
| _dl_bytes="${_dl_bytes:-0}" | |
| if [ "$_dl_bytes" -gt 104857600 ]; then | |
| # Count items for context | |
| _dl_count=$(find "$HOME/Downloads" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l | tr -d ' ') | |
| _dl_count="${_dl_count:-0}" | |
| printf '%s' "Downloads folder (~${_dl_count} items)" > "$SCRATCH/items/SYS_DOWNLOADS" | |
| printf '%s' "$_dl_bytes" > "$SCRATCH/sizes/SYS_DOWNLOADS" | |
| printf '%s' "0" > "$SCRATCH/orphan/SYS_DOWNLOADS" | |
| printf '%s | |
| ' "SYS_DOWNLOADS" >> "$SCRATCH/groups/SYSTEM" | |
| printf '%s | |
| ' "$HOME/Downloads" > "$SCRATCH/paths/SYS_DOWNLOADS" | |
| printf " ${GREEN}✔${RESET} %-42s ${DIM}%s${RESET} | |
| " "Downloads folder" "$(human "$_dl_bytes") — ${_dl_count} items" | |
| else | |
| printf " ${DIM}- %-42s under 100 MB${RESET} | |
| " "Downloads folder" | |
| fi | |
| unset _dl_bytes _dl_count | |
| # ~/Desktop — developers often use the desktop as a temp dump: | |
| # DMG installers, ZIP archives, screenshots, notes, random files. | |
| # Only shown if over 50MB — small desktops are not worth flagging. | |
| _dk_bytes=$(du -sk "$HOME/Desktop" 2>/dev/null | awk '{print $1 * 1024}') | |
| _dk_bytes="${_dk_bytes:-0}" | |
| if [ "$_dk_bytes" -gt 52428800 ]; then | |
| _dk_count=$(find "$HOME/Desktop" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l | tr -d ' ') | |
| _dk_count="${_dk_count:-0}" | |
| printf '%s' "Desktop (~${_dk_count} items)" > "$SCRATCH/items/SYS_DESKTOP" | |
| printf '%s' "$_dk_bytes" > "$SCRATCH/sizes/SYS_DESKTOP" | |
| printf '%s' "0" > "$SCRATCH/orphan/SYS_DESKTOP" | |
| printf '%s | |
| ' "SYS_DESKTOP" >> "$SCRATCH/groups/SYSTEM" | |
| printf '%s | |
| ' "$HOME/Desktop" > "$SCRATCH/paths/SYS_DESKTOP" | |
| printf " ${GREEN}✔${RESET} %-42s ${DIM}%s${RESET} | |
| " "Desktop" "$(human "$_dk_bytes") — ${_dk_count} items" | |
| else | |
| printf " ${DIM}- %-42s under 50 MB${RESET} | |
| " "Desktop" | |
| fi | |
| unset _dk_bytes _dk_count | |
| # External volumes: each mounted volume has a hidden .Trashes/$UID | |
| # folder for files deleted from that volume. The glob must be | |
| # expanded BEFORE calling register — globs inside quoted strings | |
| # are never expanded by the shell. | |
| # We build the argument list dynamically then call register. | |
| # Build a list of external volume trash paths and measure them directly. | |
| # We do NOT pass globs to register() — they must be pre-expanded. | |
| _vol_trash_bytes=0 | |
| _vol_trash_paths="" | |
| for _vol in /Volumes/*/; do | |
| _tpath="${_vol}.Trashes/$UID" | |
| if [ -d "$_tpath" ]; then | |
| _b=$(du -sk "$_tpath" 2>/dev/null | awk '{print $1 * 1024}') | |
| _b="${_b:-0}" | |
| # Fallback if du is restricted by macOS Full Disk Access | |
| if [ "$_b" -eq 0 ]; then | |
| _c=$(find "$_tpath" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l | tr -d ' ') | |
| _c="${_c:-0}" | |
| [ "$_c" -gt 0 ] && _b=$(( _c * 4096 )) | |
| fi | |
| _vol_trash_bytes=$(( _vol_trash_bytes + _b )) | |
| _vol_trash_paths="$_vol_trash_paths|$_tpath" | |
| fi | |
| done | |
| if [ "$_vol_trash_bytes" -gt 0 ]; then | |
| # Write registry entries manually (same format as register()) | |
| printf '%s' "Trash on external volumes" > "$SCRATCH/items/SYS_TRASH_VOL" | |
| printf '%s' "$_vol_trash_bytes" > "$SCRATCH/sizes/SYS_TRASH_VOL" | |
| printf '%s' "0" > "$SCRATCH/orphan/SYS_TRASH_VOL" | |
| printf '%s\n' "SYS_TRASH_VOL" >> "$SCRATCH/groups/SYSTEM" | |
| # Write paths — split on | separator | |
| : > "$SCRATCH/paths/SYS_TRASH_VOL" | |
| _remaining="$_vol_trash_paths" | |
| while [ -n "$_remaining" ]; do | |
| _remaining="${_remaining#|}" | |
| _p="${_remaining%%|*}" | |
| [ -n "$_p" ] && printf '%s\n' "$_p" >> "$SCRATCH/paths/SYS_TRASH_VOL" | |
| _remaining="${_remaining#$_p}" | |
| done | |
| printf " ${GREEN}✔${RESET} %-42s ${DIM}%s${RESET}\n" \ | |
| "Trash on external volumes" "$(human "$_vol_trash_bytes")" | |
| else | |
| printf " ${DIM}- %-42s nothing found${RESET}\n" "Trash on external volumes" | |
| fi | |
| unset _vol _tpath _b _c _vol_trash_bytes _vol_trash_paths _p _remaining | |
| register SYS_MAIL "Apple Mail Downloads" 0 SYSTEM \ | |
| "$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads" | |
| # ── Browsers ──────────────────────────────────────────────── | |
| # Browser caches are network/render caches only — no user data. | |
| # Pages may load slightly slower after first visit post-clean. | |
| printf "\n ${BOLD}${MAGENTA}🌐 Browsers${RESET}\n" | |
| register BR_CHROME "Chrome caches" 0 BROWSERS \ | |
| "$HOME/Library/Application Support/Google/Chrome/Default/Cache" \ | |
| "$HOME/Library/Application Support/Google/Chrome/Default/Code Cache" \ | |
| "$HOME/Library/Application Support/Google/Chrome/Default/GPUCache" | |
| register BR_SAFARI "Safari caches" 0 BROWSERS \ | |
| "$HOME/Library/Containers/com.apple.Safari/Data/Library/Caches" | |
| register BR_FIREFOX "Firefox caches" 0 BROWSERS \ | |
| "$HOME/Library/Caches/Firefox" | |
| register BR_ARC "Arc caches" 0 BROWSERS \ | |
| "$HOME/Library/Application Support/Arc/User Data/Default/Cache" \ | |
| "$HOME/Library/Application Support/Arc/User Data/Default/Code Cache" | |
| register BR_BRAVE "Brave caches" 0 BROWSERS \ | |
| "$HOME/Library/Application Support/BraveSoftware/Brave-Browser/Default/Cache" \ | |
| "$HOME/Library/Application Support/BraveSoftware/Brave-Browser/Default/Code Cache" | |
| # ── Editor caches ─────────────────────────────────────────── | |
| # VS Code-based editors store compiled extensions, GPU shaders, | |
| # workspace indexes, and logs. All safe to clear — rebuilt on | |
| # next open (editor may feel slower for a moment). | |
| printf "\n ${BOLD}${MAGENTA}✏️ Editor Caches${RESET}\n" | |
| register ED_VSCODE "VS Code caches & logs" 0 EDITORS \ | |
| "$HOME/Library/Application Support/Code/Cache" \ | |
| "$HOME/Library/Application Support/Code/CachedData" \ | |
| "$HOME/Library/Application Support/Code/CachedExtensions" \ | |
| "$HOME/Library/Application Support/Code/GPUCache" \ | |
| "$HOME/Library/Application Support/Code/logs" \ | |
| "$HOME/Library/Application Support/Code/workspaceStorage" | |
| register ED_CURSOR "Cursor caches & logs" 0 EDITORS \ | |
| "$HOME/Library/Application Support/Cursor/Cache" \ | |
| "$HOME/Library/Application Support/Cursor/CachedData" \ | |
| "$HOME/Library/Application Support/Cursor/CachedExtensions" \ | |
| "$HOME/Library/Application Support/Cursor/GPUCache" \ | |
| "$HOME/Library/Application Support/Cursor/logs" \ | |
| "$HOME/Library/Application Support/Cursor/workspaceStorage" | |
| register ED_WINDSURF "Windsurf caches & logs" 0 EDITORS \ | |
| "$HOME/Library/Application Support/Windsurf/Cache" \ | |
| "$HOME/Library/Application Support/Windsurf/CachedData" \ | |
| "$HOME/Library/Application Support/Windsurf/GPUCache" \ | |
| "$HOME/Library/Application Support/Windsurf/logs" \ | |
| "$HOME/Library/Application Support/Windsurf/workspaceStorage" | |
| register ED_ZED "Zed caches & logs" 0 EDITORS \ | |
| "$HOME/Library/Caches/Zed" \ | |
| "$HOME/Library/Logs/Zed" | |
| register ED_SUBLIME "Sublime Text caches" 0 EDITORS \ | |
| "$HOME/Library/Caches/com.sublimetext.4" \ | |
| "$HOME/Library/Application Support/Sublime Text/Cache" \ | |
| "$HOME/Library/Application Support/Sublime Text/Index" | |
| register ED_NOVA "Nova (Panic) caches" 0 EDITORS \ | |
| "$HOME/Library/Caches/com.panic.Nova" | |
| register ED_VIM "Vim / Neovim swap & undo files" 0 EDITORS \ | |
| "$HOME/.vim/swap" "$HOME/.vim/undo" "$HOME/.vim/backup" \ | |
| "$HOME/.local/share/nvim/swap" \ | |
| "$HOME/.local/share/nvim/undo" \ | |
| "$HOME/.local/share/nvim/backup" \ | |
| "$HOME/.cache/nvim" | |
| register ED_EMACS "Emacs caches" 0 EDITORS \ | |
| "$HOME/.emacs.d/.cache" \ | |
| "$HOME/.emacs.d/eln-cache" \ | |
| "$HOME/.cache/emacs" | |
| register ED_HELIX "Helix caches" 0 EDITORS \ | |
| "$HOME/Library/Caches/helix" \ | |
| "$HOME/.cache/helix" | |
| register ED_JETBRAINS "JetBrains IDE caches" 0 EDITORS \ | |
| "$HOME/Library/Caches/JetBrains" | |
| register ED_XCODE "Xcode DerivedData" 0 EDITORS \ | |
| "$HOME/Library/Developer/Xcode/DerivedData" | |
| register ED_XCODE_DEV "Xcode old device support files" 0 EDITORS \ | |
| "$HOME/Library/Developer/Xcode/iOS DeviceSupport" \ | |
| "$HOME/Library/Developer/Xcode/watchOS DeviceSupport" \ | |
| "$HOME/Library/Developer/Xcode/tvOS DeviceSupport" | |
| # ── Orphaned configs ──────────────────────────────────────── | |
| # When you uninstall an app on macOS its support folders stay | |
| # behind — macOS does not auto-remove them. We only flag a path | |
| # as orphaned when BOTH the .app bundle AND the CLI are absent. | |
| printf "\n ${BOLD}${MAGENTA}👻 Orphaned Configs (app uninstalled)${RESET}\n" | |
| # Cursor: check .app, `cursor` CLI, and common non-standard paths | |
| if ! app_installed "Cursor" \ | |
| && ! cmd_exists cursor \ | |
| && [ ! -f "/usr/local/bin/cursor" ] \ | |
| && [ ! -f "$HOME/.local/bin/cursor" ]; then | |
| register ORPH_CURSOR "Cursor leftover config" 1 ORPHANS \ | |
| "$HOME/Library/Application Support/Cursor" \ | |
| "$HOME/.cursor" | |
| fi | |
| # Windsurf: check .app, ~/Applications, and `windsurf` CLI | |
| if ! app_installed "Windsurf" \ | |
| && ! cmd_exists windsurf; then | |
| register ORPH_WINDSURF "Windsurf leftover config" 1 ORPHANS \ | |
| "$HOME/Library/Application Support/Windsurf" | |
| fi | |
| # Zed: check .app and `zed` CLI | |
| if ! app_installed "Zed" && ! cmd_exists zed; then | |
| register ORPH_ZED "Zed leftover config" 1 ORPHANS \ | |
| "$HOME/.config/zed" | |
| fi | |
| # Sublime Text: check .app and `subl` CLI shim | |
| if ! app_installed "Sublime Text" && ! cmd_exists subl; then | |
| register ORPH_SUBLIME "Sublime Text leftover data" 1 ORPHANS \ | |
| "$HOME/Library/Application Support/Sublime Text" | |
| fi | |
| # Neovim: CLI only | |
| if ! cmd_exists nvim; then | |
| register ORPH_NVIM "Neovim leftover config" 1 ORPHANS \ | |
| "$HOME/.config/nvim" \ | |
| "$HOME/.local/share/nvim" | |
| fi | |
| # Helix: CLI only | |
| if ! cmd_exists hx; then | |
| register ORPH_HELIX "Helix leftover config" 1 ORPHANS \ | |
| "$HOME/.config/helix" | |
| fi | |
| # Emacs: CLI only | |
| if ! cmd_exists emacs; then | |
| register ORPH_EMACS "Emacs leftover config" 1 ORPHANS \ | |
| "$HOME/.emacs.d" \ | |
| "$HOME/.config/emacs" | |
| fi | |
| # JetBrains: managed via Toolbox | |
| if ! app_installed "JetBrains Toolbox"; then | |
| register ORPH_JETBRAINS "JetBrains leftover support data" 1 ORPHANS \ | |
| "$HOME/Library/Application Support/JetBrains" | |
| fi | |
| # VS Code: check `code` CLI | |
| if ! cmd_exists code && [ -d "$HOME/.vscode" ]; then | |
| register ORPH_VSCODE "VS Code leftover ~/.vscode" 1 ORPHANS \ | |
| "$HOME/.vscode" | |
| fi | |
| # ── Node / JS tooling ─────────────────────────────────────── | |
| # All package manager caches — 100% safe to clear. | |
| # The next install re-downloads what it needs. | |
| printf "\n ${BOLD}${MAGENTA}📦 Node / JS Tooling${RESET}\n" | |
| register JS_NPM "npm cache" 0 NODE \ | |
| "$HOME/.npm/_cacache" | |
| register JS_YARN1 "Yarn v1 cache" 0 NODE \ | |
| "$HOME/.cache/yarn" | |
| register JS_YARNB "Yarn Berry cache (v2/v3/v4)" 0 NODE \ | |
| "$HOME/.yarn/berry/cache" | |
| register JS_PNPM "pnpm store" 0 NODE \ | |
| "$HOME/.pnpm-store" \ | |
| "$HOME/.local/share/pnpm/store" | |
| register JS_NVM "nvm download cache" 0 NODE \ | |
| "$HOME/.nvm/.cache" | |
| register JS_BUN "Bun cache" 0 NODE \ | |
| "$HOME/.bun/install/cache" | |
| register JS_DENO "Deno cache" 0 NODE \ | |
| "$HOME/Library/Caches/deno" | |
| # ── Python tooling ────────────────────────────────────────── | |
| printf "\n ${BOLD}${MAGENTA}🐍 Python Tooling${RESET}\n" | |
| register PY_PIP "pip cache" 0 PYTHON \ | |
| "$HOME/Library/Caches/pip" | |
| register PY_POETRY "Poetry cache" 0 PYTHON \ | |
| "$HOME/Library/Caches/pypoetry" | |
| register PY_PYENV "pyenv download cache" 0 PYTHON \ | |
| "$HOME/.pyenv/cache" | |
| register PY_UV "uv cache (Astral)" 0 PYTHON \ | |
| "$HOME/.cache/uv" \ | |
| "$HOME/Library/Caches/uv" | |
| # ── Rust / Cargo ──────────────────────────────────────────── | |
| # Clears downloaded crate cache — NOT compiled target/ dirs. | |
| # Per-project cleanup needs `cargo clean` inside each project. | |
| printf "\n ${BOLD}${MAGENTA}🦀 Rust / Cargo${RESET}\n" | |
| register RS_CARGO "Cargo registry & git cache" 0 RUST \ | |
| "$HOME/.cargo/registry/cache" \ | |
| "$HOME/.cargo/git/db" | |
| # ── Ruby / Gems ───────────────────────────────────────────── | |
| printf "\n ${BOLD}${MAGENTA}💎 Ruby / Gems${RESET}\n" | |
| register RB_GEM "gem cache" 0 RUBY \ | |
| "$HOME/.gem" | |
| register RB_BUNDLE "Bundler cache" 0 RUBY \ | |
| "$HOME/.bundle/cache" | |
| # ── Go ────────────────────────────────────────────────────── | |
| printf "\n ${BOLD}${MAGENTA}🐹 Go${RESET}\n" | |
| register GO_MOD "Go module cache" 0 GO \ | |
| "$HOME/go/pkg/mod/cache" | |
| # ── Java / JVM ────────────────────────────────────────────── | |
| # Maven and Gradle cache downloaded jars/plugins globally. | |
| # Safe to clear — next build re-downloads what it needs. | |
| # SBT (Scala) and Ivy share similar caches. | |
| printf "\n ${BOLD}${MAGENTA}☕ Java / JVM Tooling${RESET}\n" | |
| register JVM_MAVEN "Maven local repository cache" 0 JAVA \ | |
| "$HOME/.m2/repository" | |
| # .m2/settings.xml is NOT touched — only the downloaded jars | |
| register JVM_GRADLE "Gradle caches" 0 JAVA \ | |
| "$HOME/.gradle/caches" \ | |
| "$HOME/.gradle/wrapper/dists" | |
| # wrapper/dists = downloaded Gradle wrapper versions | |
| register JVM_SBT "sbt / Ivy cache" 0 JAVA \ | |
| "$HOME/.sbt" \ | |
| "$HOME/.ivy2/cache" | |
| register JVM_KOTLIN "Kotlin compiler cache" 0 JAVA \ | |
| "$HOME/.konan/cache" \ | |
| "$HOME/Library/Caches/KotlinIDE" | |
| # ── Mobile Development ────────────────────────────────────── | |
| # Xcode simulators, Android build caches, Flutter artifacts, | |
| # CocoaPods download cache, and React Native Metro bundler. | |
| printf "\n ${BOLD}${MAGENTA}📱 Mobile Dev${RESET}\n" | |
| register MOB_SIM "Xcode iOS Simulators (unused runtimes)" 0 MOBILE \ | |
| "$HOME/Library/Developer/CoreSimulator/Caches" | |
| # Full simulator runtimes (can be 5-15GB) — delete via Xcode > Preferences | |
| register MOB_SIM_LOGS "Simulator logs & crashed reports" 0 MOBILE \ | |
| "$HOME/Library/Logs/CoreSimulator" | |
| register MOB_ANDROID_CACHE "Android SDK build cache" 0 MOBILE \ | |
| "$HOME/.android/cache" \ | |
| "$HOME/Library/Android/sdk/.temp" | |
| register MOB_GRADLE_ANDROID "Android Gradle build cache" 0 MOBILE \ | |
| "$HOME/.gradle/caches/build-cache-1" | |
| # Android-specific Gradle build cache — often very large | |
| register MOB_FLUTTER "Flutter tool cache" 0 MOBILE \ | |
| "$HOME/.pub-cache/hosted" \ | |
| "$HOME/Library/Caches/flutter_tools" | |
| # pub-cache/hosted = downloaded pub packages | |
| register MOB_COCOAPODS "CocoaPods download cache" 0 MOBILE \ | |
| "$HOME/Library/Caches/CocoaPods" | |
| # Pod source archives — rebuilt on next `pod install` | |
| register MOB_RN_METRO "React Native Metro bundler cache" 0 MOBILE \ | |
| "$HOME/Library/Caches/com.facebook.ReactNativeBuild" \ | |
| "/tmp/metro-*" \ | |
| "/tmp/haste-map-*" | |
| # ── DevOps / Cloud / Infra ────────────────────────────────── | |
| # Terraform plugin cache, AWS/GCP/Azure CLI caches, | |
| # kubectl, Helm chart cache, Ansible collections. | |
| printf "\n ${BOLD}${MAGENTA}⚙️ DevOps / Cloud / Infra${RESET}\n" | |
| register OPS_TERRAFORM "Terraform plugin cache" 0 DEVOPS \ | |
| "$HOME/.terraform.d/plugin-cache" \ | |
| "$HOME/.terraform/providers" | |
| # Provider binaries — re-downloaded on next terraform init | |
| register OPS_AWS "AWS CLI cache" 0 DEVOPS \ | |
| "$HOME/.aws/cli/cache" \ | |
| "$HOME/Library/Caches/pip/wheels" | |
| # SSO token cache and pip wheels used during awscli install | |
| register OPS_GCLOUD "Google Cloud SDK cache" 0 DEVOPS \ | |
| "$HOME/.config/gcloud/logs" \ | |
| "$HOME/Library/Caches/google-cloud-sdk" | |
| register OPS_AZURE "Azure CLI cache & logs" 0 DEVOPS \ | |
| "$HOME/.azure/logs" \ | |
| "$HOME/.azure/telemetry" | |
| register OPS_HELM "Helm chart & repository cache" 0 DEVOPS \ | |
| "$HOME/Library/Caches/helm" \ | |
| "$HOME/.cache/helm" | |
| register OPS_KUBECTL "kubectl cache" 0 DEVOPS \ | |
| "$HOME/.kube/cache" \ | |
| "$HOME/.kube/http-cache" | |
| register OPS_VAGRANT "Vagrant box cache" 0 DEVOPS \ | |
| "$HOME/.vagrant.d/tmp" \ | |
| "$HOME/.vagrant.d/gems" | |
| register OPS_ANSIBLE "Ansible collections & cache" 0 DEVOPS \ | |
| "$HOME/.ansible/tmp" \ | |
| "$HOME/.cache/ansible-compat" | |
| # ── .NET ──────────────────────────────────────────────────── | |
| # NuGet package cache and dotnet tool/SDK temp files. | |
| printf "\n ${BOLD}${MAGENTA}🔷 .NET Tooling${RESET}\n" | |
| register NET_NUGET "NuGet package cache" 0 DOTNET \ | |
| "$HOME/.nuget/packages" \ | |
| "$HOME/Library/Caches/NuGetPackages" | |
| # Safe to clear — restored on next dotnet build/restore | |
| register NET_DOTNET "dotnet temp & telemetry" 0 DOTNET \ | |
| "$HOME/.dotnet/toolResolverCache" \ | |
| "$HOME/.dotnet/TelemetryStorageService" | |
| # ── PHP ───────────────────────────────────────────────────── | |
| # Composer caches downloaded package archives and metadata. | |
| printf "\n ${BOLD}${MAGENTA}🐘 PHP / Composer${RESET}\n" | |
| register PHP_COMPOSER "Composer cache" 0 PHP \ | |
| "$HOME/.composer/cache" \ | |
| "$HOME/.cache/composer" | |
| # composer cache clear is the native command — rm is equivalent | |
| # ── Data / ML ─────────────────────────────────────────────── | |
| # Conda/Mamba package cache, Hugging Face model downloads, | |
| # Jupyter runtime files, and ML framework caches. | |
| printf "\n ${BOLD}${MAGENTA}🤖 Data / ML Tooling${RESET}\n" | |
| register DATA_CONDA "Conda / Mamba package cache" 0 DATA \ | |
| "$HOME/opt/anaconda3/pkgs" \ | |
| "$HOME/opt/miniconda3/pkgs" \ | |
| "$HOME/mambaforge/pkgs" \ | |
| "$HOME/.conda/pkgs" | |
| # Downloaded conda package archives — safe to purge | |
| register DATA_HF "Hugging Face model cache" 0 DATA \ | |
| "$HOME/.cache/huggingface/hub" | |
| # Downloaded model weights — can be very large (GB per model) | |
| # Only remove if you are OK re-downloading models you use | |
| register DATA_TORCH "PyTorch / TorchVision download cache" 0 DATA \ | |
| "$HOME/.cache/torch/hub" \ | |
| "$HOME/.cache/torch/kernels" | |
| register DATA_JUPYTER "Jupyter runtime & nbconvert cache" 0 DATA \ | |
| "$HOME/Library/Jupyter/runtime" \ | |
| "$HOME/.local/share/jupyter/runtime" \ | |
| "$HOME/.cache/jupyter" | |
| register DATA_KERAS "Keras dataset cache" 0 DATA \ | |
| "$HOME/.keras/datasets" | |
| # ── Design Tools ──────────────────────────────────────────── | |
| # Figma, Sketch, Adobe XD and Framer keep large local caches | |
| # for fonts, offline rendering, and version history. | |
| printf "\n ${BOLD}${MAGENTA}🎨 Design Tools${RESET}\n" | |
| register DSN_FIGMA "Figma local cache" 0 DESIGN \ | |
| "$HOME/Library/Application Support/Figma/Desktop/Cache" \ | |
| "$HOME/Library/Application Support/Figma/Desktop/Code Cache" \ | |
| "$HOME/Library/Application Support/Figma/Desktop/GPUCache" | |
| # Figma re-fetches files from cloud on next open | |
| register DSN_SKETCH "Sketch caches" 0 DESIGN \ | |
| "$HOME/Library/Caches/com.bohemiancoding.sketch3" \ | |
| "$HOME/Library/Application Support/com.bohemiancoding.sketch3/ComponentCache" | |
| register DSN_ADOBE "Adobe Creative Cloud cache" 0 DESIGN \ | |
| "$HOME/Library/Application Support/Adobe/Common/Media Cache Files" \ | |
| "$HOME/Library/Application Support/Adobe/Common/Media Cache" \ | |
| "$HOME/Library/Caches/Adobe" | |
| # Media encoder and preview cache — safe to remove | |
| register DSN_FRAMER "Framer cache" 0 DESIGN \ | |
| "$HOME/Library/Application Support/Framer/Cache" \ | |
| "$HOME/Library/Application Support/Framer/Code Cache" | |
| # ── macOS extras ──────────────────────────────────────────── | |
| printf "\n ${BOLD}${MAGENTA}🍎 macOS Extras${RESET}\n" | |
| register MAC_QL "QuickLook thumbnail cache" 0 MACOS \ | |
| "$HOME/Library/Caches/com.apple.QuickLook.thumbnailcache" | |
| # ════════════════════════════════════════════════════════════ | |
| # PHASE 2 — REPORT | |
| # | |
| # Reads from the $SCRATCH file registry built in Phase 1. | |
| # Prints grouped table with sizes and orphan flags. | |
| # ════════════════════════════════════════════════════════════ | |
| # Compute grand total | |
| GRAND_TOTAL=0 | |
| for g in $GROUPS_ORDERED; do | |
| group_exists "$g" || continue | |
| gt=$(group_total "$g") | |
| GRAND_TOTAL=$(( GRAND_TOTAL + gt )) | |
| printf '%s' "$gt" > "$SCRATCH/gtotals/$g" | |
| done | |
| printf "\n\n" | |
| printf "${BOLD}${BLUE} ▶ Phase 2 of 3 — Scan Report${RESET}\n\n" | |
| printf "${BOLD}┌─────────────────────────────────────────────────────────────────┐${RESET}\n" | |
| printf "${BOLD}│ 📊 FOUND ON DISK │${RESET}\n" | |
| printf "${BOLD}└─────────────────────────────────────────────────────────────────┘${RESET}\n" | |
| for g in $GROUPS_ORDERED; do | |
| group_exists "$g" || continue | |
| gt=$(cat "$SCRATCH/gtotals/$g" 2>/dev/null || printf '0') | |
| [ "$gt" -eq 0 ] && continue | |
| sc=$(size_color "$gt") | |
| glabel=$(group_label "$g") | |
| printf "\n ${BOLD}%s${RESET} %b${BOLD}%s${RESET}\n" "$glabel" "$sc" "$(human "$gt")" | |
| # Print each item in this group | |
| # Use fd 3 to read group file so it does not consume stdin | |
| while IFS= read -r key <&3; do | |
| key_exists "$key" || continue | |
| sz=$(get_size "$key") | |
| label=$(get_label "$key") | |
| orphan=$(get_orphan "$key") | |
| sc=$(size_color "$sz") | |
| orphan_tag="" | |
| [ "$orphan" = "1" ] && orphan_tag=" [ORPHAN — leftover from uninstalled app]" | |
| # Use %b for the color variable so \033 sequences are interpreted | |
| printf " %b%10s%b %s%s\n" "$sc" "$(human "$sz")" "$RESET" "$label" "$orphan_tag" | |
| done 3< "$SCRATCH/groups/$g" | |
| done | |
| printf "\n" | |
| printf "${BOLD} ─────────────────────────────────────────────────────────────────${RESET}\n" | |
| sc=$(size_color "$GRAND_TOTAL") | |
| printf " ${BOLD}💾 Total recoverable: %b%s${RESET}\n" "$sc" "$(human "$GRAND_TOTAL")" | |
| printf " ${DIM} Running as: %s — only this user's files are shown${RESET}\n" "$(whoami)" | |
| printf "${BOLD} ─────────────────────────────────────────────────────────────────${RESET}\n\n" | |
| if [ "$GRAND_TOTAL" -eq 0 ]; then | |
| printf " ${GREEN}✅ Nothing to clean — your system is already tidy!${RESET}\n\n" | |
| exit 0 | |
| fi | |
| printf " Continue to interactive cleanup? [y/n]: " | |
| read -r ans | |
| case "$ans" in | |
| [Yy]*) ;; | |
| *) printf " Aborted. Nothing was deleted.\n"; exit 0 ;; | |
| esac | |
| # ════════════════════════════════════════════════════════════ | |
| # PHASE 3 — INTERACTIVE CLEAN | |
| # | |
| # Iterates groups in same order as report. | |
| # SYSTEM group is auto-cleaned (always safe). | |
| # All others prompt y/n with size shown. | |
| # ════════════════════════════════════════════════════════════ | |
| printf "\n" | |
| printf "${BOLD}${BLUE} ▶ Phase 3 of 3 — Interactive Cleanup${RESET}\n" | |
| printf "${DIM} Answer y/n for each item. Size is shown to help you decide.${RESET}\n" | |
| for g in $GROUPS_ORDERED; do | |
| group_exists "$g" || continue | |
| gt=$(cat "$SCRATCH/gtotals/$g" 2>/dev/null || printf '0') | |
| [ "$gt" -eq 0 ] && continue | |
| printf "\n ${BOLD}%s${RESET}\n" "$(group_label "$g")" | |
| # SYSTEM: auto-clean, no prompt | |
| if [ "$g" = "SYSTEM" ]; then | |
| printf " ${DIM} System caches are always safe — cleaning automatically...${RESET}\n" | |
| while IFS= read -r key <&3; do | |
| key_exists "$key" || continue | |
| label=$(get_label "$key") | |
| paths_file="$SCRATCH/paths/$key" | |
| [ -f "$paths_file" ] || continue | |
| # Trash items get special handling — ask the user and use | |
| # osascript to empty via Finder (handles locked files properly) | |
| if [ "$key" = "SYS_TRASH" ] || [ "$key" = "SYS_TRASH_VOL" ]; then | |
| sz=$(get_size "$key") | |
| size_str=$(printf "%b%s%b" "$YELLOW" "$(human "$sz")" "$RESET") | |
| if ask "Empty Trash ($label) $size_str"; then | |
| printf " ${CYAN}⟳${RESET} Emptying Trash via Finder..." | |
| osascript -e 'tell application "Finder" to empty trash' >/dev/null 2>&1 || true | |
| # Fallback rm in case Finder is not running | |
| set -- | |
| while IFS= read -r p <&4; do | |
| set -- "$@" "$p" | |
| done 4< "$paths_file" | |
| for p in "$@"; do rm -rf "$p" 2>/dev/null || true; done | |
| printf "\r\033[K ${GREEN}✓${RESET} Trash emptied\n" | |
| else | |
| printf " ${DIM} ↳ Skipped${RESET}\n" | |
| fi | |
| continue | |
| fi | |
| # Downloads: this is PERSONAL DATA not a cache — maximum warning | |
| if [ "$key" = "SYS_DOWNLOADS" ]; then | |
| sz=$(get_size "$key") | |
| _label=$(get_label "$key") | |
| _red=$(printf "%b" "$RED") | |
| _yellow=$(printf "%b" "$YELLOW") | |
| _bold=$(printf "%b" "$BOLD") | |
| _dim=$(printf "%b" "$DIM") | |
| _reset=$(printf "%b" "$RESET") | |
| printf "\n" | |
| printf " %b╔══════════════════════════════════════════════╗%b\n" "$RED" "$RESET" | |
| printf " %b║ 🚨 DANGER — PERSONAL FILES 🚨 ║%b\n" "$RED" "$RESET" | |
| printf " %b╠══════════════════════════════════════════════╣%b\n" "$RED" "$RESET" | |
| printf " %b║ 📁 ~/Downloads ║%b\n" "$RED" "$RESET" | |
| printf " %b║ 📦 %s%-40s%b%b║%b\n" "$RED" "$BOLD" "$_label" "$RESET" "$RED" "$RESET" | |
| printf " %b║ 💾 Size: %-34s║%b\n" "$RED" "$(human "$sz")" "$RESET" | |
| printf " %b╠══════════════════════════════════════════════╣%b\n" "$RED" "$RESET" | |
| printf " %b║ ⚠️ This folder may contain: ║%b\n" "$RED" "$RESET" | |
| printf " %b║ • Files you still need ║%b\n" "$RED" "$RESET" | |
| printf " %b║ • Installers you haven't run yet ║%b\n" "$RED" "$RESET" | |
| printf " %b║ • Documents sent by others ║%b\n" "$RED" "$RESET" | |
| printf " %b║ • Backups and archives ║%b\n" "$RED" "$RESET" | |
| printf " %b╠══════════════════════════════════════════════╣%b\n" "$RED" "$RESET" | |
| printf " %b║ ❌ THIS CANNOT BE UNDONE ║%b\n" "$RED" "$RESET" | |
| printf " %b╚══════════════════════════════════════════════╝%b\n" "$RED" "$RESET" | |
| printf "\n" | |
| _open_hint=$(printf "%b%s%b" "$DIM" "(recommended before deleting)" "$RESET") | |
| if ask "Open ~/Downloads in Finder to review first? $_open_hint"; then | |
| osascript -e 'tell application "Finder" to open folder ((path to downloads folder) as string)' >/dev/null 2>&1 || true | |
| open "$HOME/Downloads" 2>/dev/null || true | |
| printf " ${DIM} Finder opened — press Enter when ready to continue...${RESET}" | |
| read -r _dummy | |
| fi | |
| _warn=$(printf "%b%s%b" "$RED" "⚠️ YES, DELETE EVERYTHING IN ~/Downloads PERMANENTLY" "$RESET") | |
| if ask "$_warn"; then | |
| printf " ${CYAN}⟳${RESET} Clearing Downloads..." | |
| rm -rf "$HOME/Downloads"/* "$HOME/Downloads"/.[!.]* 2>/dev/null || true | |
| printf "\r\033[K ${GREEN}✓${RESET} Downloads cleared\n" | |
| else | |
| printf " ${GREEN}✓${RESET} Smart choice — Downloads kept.\n" | |
| fi | |
| printf "\n" | |
| continue | |
| fi | |
| # Desktop: PERSONAL DATA — same maximum warning as Downloads | |
| if [ "$key" = "SYS_DESKTOP" ]; then | |
| sz=$(get_size "$key") | |
| _label=$(get_label "$key") | |
| _red=$(printf "%b" "$RED") | |
| _reset=$(printf "%b" "$RESET") | |
| printf "\n" | |
| printf " %b╔══════════════════════════════════════════════╗%b\n" "$RED" "$RESET" | |
| printf " %b║ 🚨 DANGER — PERSONAL FILES 🚨 ║%b\n" "$RED" "$RESET" | |
| printf " %b╠══════════════════════════════════════════════╣%b\n" "$RED" "$RESET" | |
| printf " %b║ 🖥️ ~/Desktop ║%b\n" "$RED" "$RESET" | |
| printf " %b║ 📦 %s%-40s%b%b║%b\n" "$RED" "$BOLD" "$_label" "$RESET" "$RED" "$RESET" | |
| printf " %b║ 💾 Size: %-34s║%b\n" "$RED" "$(human "$sz")" "$RESET" | |
| printf " %b╠══════════════════════════════════════════════╣%b\n" "$RED" "$RESET" | |
| printf " %b║ ⚠️ This folder may contain: ║%b\n" "$RED" "$RESET" | |
| printf " %b║ • DMG installers not yet installed ║%b\n" "$RED" "$RESET" | |
| printf " %b║ • Screenshots with sensitive info ║%b\n" "$RED" "$RESET" | |
| printf " %b║ • Notes and temporary documents ║%b\n" "$RED" "$RESET" | |
| printf " %b║ • ZIP archives not yet extracted ║%b\n" "$RED" "$RESET" | |
| printf " %b╠══════════════════════════════════════════════╣%b\n" "$RED" "$RESET" | |
| printf " %b║ ❌ THIS CANNOT BE UNDONE ║%b\n" "$RED" "$RESET" | |
| printf " %b╚══════════════════════════════════════════════╝%b\n" "$RED" "$RESET" | |
| printf "\n" | |
| _open_hint=$(printf "%b%s%b" "$DIM" "(recommended before deleting)" "$RESET") | |
| if ask "Open ~/Desktop in Finder to review first? $_open_hint"; then | |
| osascript -e 'tell application "Finder" to open folder ((path to desktop folder) as string)' >/dev/null 2>&1 || true | |
| open "$HOME/Desktop" 2>/dev/null || true | |
| printf " ${DIM} Finder opened — press Enter when ready to continue...${RESET}" | |
| read -r _dummy | |
| fi | |
| _warn=$(printf "%b%s%b" "$RED" "⚠️ YES, DELETE EVERYTHING ON ~/Desktop PERMANENTLY" "$RESET") | |
| if ask "$_warn"; then | |
| printf " ${CYAN}⟳${RESET} Clearing Desktop..." | |
| rm -rf "$HOME/Desktop"/* "$HOME/Desktop"/.[!.]* 2>/dev/null || true | |
| printf "\r\033[K ${GREEN}✓${RESET} Desktop cleared\n" | |
| else | |
| printf " ${GREEN}✓${RESET} Smart choice — Desktop kept.\n" | |
| fi | |
| printf "\n" | |
| continue | |
| fi | |
| # All other SYSTEM items: auto-clean without asking | |
| set -- | |
| while IFS= read -r p <&4; do | |
| set -- "$@" "$p" | |
| done 4< "$paths_file" | |
| do_rm "$label" "$@" | |
| done 3< "$SCRATCH/groups/$g" | |
| # Sweep container caches (sandboxed app caches under ~/Library) | |
| # These are per-app sandboxed folders — only Caches/ and tmp/ subfolders | |
| # are removed, never the app's actual data directories. | |
| printf " ${CYAN}⟳${RESET} Sweeping app container caches..." | |
| find "$HOME/Library/Containers" -type d -name "Caches" \ | |
| -exec sh -c 'rm -rf "$1"/* 2>/dev/null' _ {} \; 2>/dev/null || true | |
| find "$HOME/Library/Containers" -type d -name "tmp" \ | |
| -exec sh -c 'rm -rf "$1"/* 2>/dev/null' _ {} \; 2>/dev/null || true | |
| find "$HOME/Library/Group Containers" -type d -name "Caches" \ | |
| -exec sh -c 'rm -rf "$1"/* 2>/dev/null' _ {} \; 2>/dev/null || true | |
| printf "\r\033[K ${GREEN}✓${RESET} App container caches swept\n" | |
| continue | |
| fi | |
| # All other groups: prompt per item | |
| # We read from the groups file using a fd (file descriptor) redirect | |
| # on the while loop itself — this keeps stdin free for `read` inside ask(). | |
| while IFS= read -r key <&3; do | |
| key_exists "$key" || continue | |
| sz=$(get_size "$key") | |
| label=$(get_label "$key") | |
| orphan=$(get_orphan "$key") | |
| sc=$(size_color "$sz") | |
| orphan_tag="" | |
| [ "$orphan" = "1" ] && orphan_tag=" [ORPHAN]" | |
| # Print size with color using printf %b which interprets \033 sequences | |
| size_str=$(printf "%b%s%b" "$sc" "$(human "$sz")" "$RESET") | |
| if ask "$label$orphan_tag $size_str"; then | |
| paths_file="$SCRATCH/paths/$key" | |
| set -- | |
| while IFS= read -r p <&4; do | |
| set -- "$@" "$p" | |
| done 4< "$paths_file" | |
| do_rm "$label" "$@" | |
| else | |
| printf " ${DIM} ↳ Skipped${RESET}\n" | |
| fi | |
| done 3< "$SCRATCH/groups/$g" | |
| done | |
| # ── Docker ─────────────────────────────────────────────────── | |
| printf "\n ${BOLD}🐳 Docker${RESET}\n" | |
| if cmd_exists docker && docker info >/dev/null 2>&1; then | |
| if ask "docker system prune — stopped containers, dangling images, unused networks"; then | |
| printf " ${CYAN}⟳${RESET} Running docker system prune..." | |
| docker system prune -f >/dev/null 2>&1 || true | |
| printf "\r\033[K ${GREEN}✓${RESET} Docker system pruned\n" | |
| fi | |
| _hint=$(printf "%b%s%b" "$DIM" "(can free several GB)" "$RESET") | |
| if ask "Remove ALL unused Docker images $_hint"; then | |
| printf " ${CYAN}⟳${RESET} Pruning all unused images..." | |
| docker image prune -af >/dev/null 2>&1 || true | |
| printf "\r\033[K ${GREEN}✓${RESET} All unused images removed\n" | |
| fi | |
| _hint=$(printf "%b%s%b" "$YELLOW" "⚠️ skip if you store local DB data in Docker" "$RESET") | |
| if ask "Remove unused Docker volumes $_hint"; then | |
| printf " ${CYAN}⟳${RESET} Pruning volumes..." | |
| docker volume prune -f >/dev/null 2>&1 || true | |
| printf "\r\033[K ${GREEN}✓${RESET} Unused volumes removed\n" | |
| fi | |
| else | |
| printf " ${DIM} Docker not running or not installed — skipped.${RESET}\n" | |
| fi | |
| # ── Homebrew ───────────────────────────────────────────────── | |
| printf "\n ${BOLD}🍺 Homebrew${RESET}\n" | |
| if cmd_exists brew; then | |
| _hint=$(printf "%b%s%b" "$DIM" "(removes old formula versions)" "$RESET") | |
| if ask "brew cleanup --prune=all + autoremove $_hint"; then | |
| printf " ${CYAN}⟳${RESET} Running brew cleanup (may take a moment)..." | |
| brew cleanup --prune=all >/dev/null 2>&1 || true | |
| brew autoremove >/dev/null 2>&1 || true | |
| b=$(dir_bytes "$HOME/Library/Caches/Homebrew") | |
| rm -rf "$HOME/Library/Caches/Homebrew" 2>/dev/null || true | |
| TOTAL_FREED=$(( TOTAL_FREED + b )) | |
| printf "\r\033[K ${GREEN}✓${RESET} Homebrew cleaned — freed %s\n" "$(human "$b")" | |
| fi | |
| else | |
| printf " ${DIM} Homebrew not found — skipped.${RESET}\n" | |
| fi | |
| # ── Package manager native purge commands ──────────────────── | |
| # Some tools maintain internal metadata beyond what du captures. | |
| # Run their own purge commands for a thorough clean. | |
| printf "\n ${CYAN}⟳${RESET} Running package manager purge commands..." | |
| { cmd_exists npm && key_exists JS_NPM && npm cache clean --force; } >/dev/null 2>&1 || true | |
| { cmd_exists yarn && key_exists JS_YARN1 && yarn cache clean; } >/dev/null 2>&1 || true | |
| { cmd_exists pnpm && key_exists JS_PNPM && pnpm store prune; } >/dev/null 2>&1 || true | |
| { cmd_exists pip && key_exists PY_PIP && pip cache purge; } >/dev/null 2>&1 || true | |
| { cmd_exists poetry && key_exists PY_POETRY && poetry cache clear --all pypi; } >/dev/null 2>&1 || true | |
| { cmd_exists go && key_exists GO_MOD && go clean -modcache; } >/dev/null 2>&1 || true | |
| { cmd_exists gem && key_exists RB_GEM && gem cleanup; } >/dev/null 2>&1 || true | |
| { cmd_exists gradle && key_exists JVM_GRADLE && gradle --stop; } >/dev/null 2>&1 || true | |
| { cmd_exists mvn && key_exists JVM_MAVEN && mvn dependency:purge-local-repository; } >/dev/null 2>&1 || true | |
| { cmd_exists flutter && key_exists MOB_FLUTTER && flutter pub cache clean; } >/dev/null 2>&1 || true | |
| { cmd_exists conda && key_exists DATA_CONDA && conda clean --all --yes; } >/dev/null 2>&1 || true | |
| { cmd_exists dotnet && key_exists NET_NUGET && dotnet nuget locals all --clear; } >/dev/null 2>&1 || true | |
| { cmd_exists composer && key_exists PHP_COMPOSER && composer clear-cache; } >/dev/null 2>&1 || true | |
| { cmd_exists helm && key_exists OPS_HELM && helm repo update; } >/dev/null 2>&1 || true | |
| printf "\r\033[K ${GREEN}✓${RESET} Package manager indexes updated\n" | |
| # ── Jupyter checkpoints ────────────────────────────────────── | |
| # .ipynb_checkpoints folders accumulate silently in every notebook | |
| # directory. Safe to delete — Jupyter recreates them on next edit. | |
| printf "\n" | |
| if ask "🔬 Jupyter — remove all .ipynb_checkpoints folders (project-wide search)"; then | |
| printf " ${CYAN}⟳${RESET} Searching for Jupyter checkpoint folders..." | |
| count=$(find "$HOME" -type d -name ".ipynb_checkpoints" \ | |
| -not -path "*/.Trash/*" 2>/dev/null | wc -l | tr -d ' ') | |
| find "$HOME" -type d -name ".ipynb_checkpoints" \ | |
| -not -path "*/.Trash/*" \ | |
| -exec rm -rf {} + 2>/dev/null || true | |
| printf "\r\033[K ${GREEN}✓${RESET} Removed %s checkpoint folder(s)\n" "$count" | |
| fi | |
| # ── macOS system-level operations ─────────────────────────── | |
| printf "\n ${BOLD}🍎 macOS Extras${RESET}\n" | |
| if ask "Flush DNS cache?"; then | |
| printf " ${CYAN}⟳${RESET} Flushing DNS cache..." | |
| sudo dscacheutil -flushcache 2>/dev/null || true | |
| sudo killall -HUP mDNSResponder 2>/dev/null || true | |
| printf "\r\033[K ${GREEN}✓${RESET} DNS cache flushed\n" | |
| fi | |
| _hint=$(printf "%b%s%b" "$DIM" "(runs in background, takes a few minutes)" "$RESET") | |
| if ask "Rebuild Spotlight index? $_hint"; then | |
| printf " ${CYAN}⟳${RESET} Triggering Spotlight reindex..." | |
| sudo mdutil -E / 2>/dev/null || true | |
| printf "\r\033[K ${GREEN}✓${RESET} Spotlight reindex triggered (running in background)\n" | |
| fi | |
| # ════════════════════════════════════════════════════════════ | |
| # SUMMARY | |
| # ════════════════════════════════════════════════════════════ | |
| printf "\n" | |
| printf "${BOLD}╔══════════════════════════════════════════════════╗${RESET}\n" | |
| printf "${BOLD}║ ✅ All Done! ║${RESET}\n" | |
| printf "${BOLD}║ 💾 Freed: %-36s║${RESET}\n" \ | |
| "$(human "$TOTAL_FREED") (of $(human "$GRAND_TOTAL") found)" | |
| printf "${BOLD}╠══════════════════════════════════════════════════╣${RESET}\n" | |
| printf "${BOLD}║ 👉 Restart your Mac to reflect freed space ║${RESET}\n" | |
| printf "${BOLD}║ ${DIM}Other users must run this in their own account${RESET}${BOLD} ║${RESET}\n" | |
| printf "${BOLD}╚══════════════════════════════════════════════════╝${RESET}\n\n" |
Author
Thx a lot for your great effort.👏
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
🖥 How to use:
1. Download the attached script (cleanup.sh).
2. Open Terminal and navigate to the file.
3. Make it executable:
chmod +x cleanup.sh
4. Run it:
./cleanup.sh
5. Restart your Mac to see freed space.
Please try running it every 1-2 months and share how much space you saved!