Skip to content

Instantly share code, notes, and snippets.

@coccoinomane
Last active March 3, 2026 14:52
Show Gist options
  • Select an option

  • Save coccoinomane/05366181517fee2b48a1913f9c36ce70 to your computer and use it in GitHub Desktop.

Select an option

Save coccoinomane/05366181517fee2b48a1913f9c36ce70 to your computer and use it in GitHub Desktop.
Interactive disk cleanup script for macOS/Linux — caches, Docker, node_modules, Python venvs, nvm versions
#!/usr/bin/env bash
# =============================================================================
# disk-cleanup.sh
# Interactive disk cleanup for macOS and Linux.
# Removes caches, Docker waste, old Node versions, stale node_modules,
# and Python virtual environments. Safe to run periodically.
#
# Usage:
# ./disk-cleanup.sh # interactive mode (approve each removal)
# ./disk-cleanup.sh --force # no confirmations, remove everything
# ./disk-cleanup.sh -t 500 # only prompt for items >= 500 MB
# ./disk-cleanup.sh -t 0 # show everything regardless of size
# ./disk-cleanup.sh --force -t 200 # auto-remove everything >= 200 MB
#
# Options:
# -f, --force Skip all confirmations and remove everything
# -t, --threshold MB Minimum size in MB to prompt for removal (default: 100)
# -h, --help Show help message
#
# What it cleans:
# 1. npm cache (~/.npm/_cacache)
# 2. Docker: unused images, volumes, stopped containers, build cache
# 3. ~/.cache: whisper, huggingface, puppeteer, prisma, and more
# 4. ~/Library/Caches (macOS only): Chrome, Spotify, Brave, Cypress, etc.
# 5. Homebrew downloads (brew cleanup --prune=all)
# 6. Old Node.js versions installed via nvm (keeps the currently active one)
# 7. Stale node_modules not accessed in 30+ days
# 8. Stale Python venvs (.venv/.env with pyvenv.cfg, not accessed in 30+ days)
# 9. uv cache (Python package manager)
# 10. pre-commit cache
#
# Exceptions ("never delete"):
# When prompted for removal, you can type "never" instead of y/N to
# permanently exclude that item from future cleanups. The exception is
# saved to:
#
# ~/.config/disk-cleanup/exceptions.txt
#
# Each line in the file is a path or identifier that will be silently
# skipped on every subsequent run. To undo an exception, simply remove
# the corresponding line from the file.
#
# Examples of what gets saved when you type "never":
# /Users/you/.cache/huggingface # never clean huggingface models
# /Users/you/myproject/node_modules # never clean this specific node_modules
# /Users/you/.npm/_cacache # never clean npm cache
# docker # never prune Docker
# brew # never run brew cleanup
#
# =============================================================================
set -uo pipefail
# ── Constants ────────────────────────────────────────────────────────────────
HOME_DIR="$HOME"
NODE_MODULES_SEARCH_DEPTH=5
NODE_MODULES_MIN_AGE_DAYS=30 # only remove node_modules not accessed in this many days
NVM_DIR="${NVM_DIR:-$HOME_DIR/.nvm}"
EXCLUDE_PATHS=("*/Library/*" "*/CloudStorage/*" "*/.npm/*" "*/.nvm/*" "*/.pnpm/*" "*/.vscode/*" "*/.cursor/*")
BYTES_PER_KB=1024
BYTES_PER_MB=1048576
BYTES_PER_GB=1073741824
DEFAULT_THRESHOLD_MB=100
THRESHOLD_BYTES=$((DEFAULT_THRESHOLD_MB * BYTES_PER_MB))
EXCEPTIONS_FILE="$HOME_DIR/.config/disk-cleanup/exceptions.txt"
# Cache dirs inside ~/.cache that are safe to wipe
# Note: "uv" and "pre-commit" are handled separately via their CLI commands
CACHE_DIRS_TO_CLEAN=(
"whisper"
"huggingface"
"puppeteer"
"act"
"openai-python"
"prisma"
"winetricks"
"actcache"
)
# Library/Caches entries safe to wipe
# Note: "Homebrew" is handled separately via brew cleanup
LIBRARY_CACHE_DIRS_TO_CLEAN=(
"Google"
"com.spotify.client"
"Cypress"
"ms-playwright"
"BraveSoftware"
"Mozilla"
)
# ── Parse flags ──────────────────────────────────────────────────────────────
FORCE=false
while [[ $# -gt 0 ]]; do
case "$1" in
--force|-f) FORCE=true ;;
--threshold|-t)
shift
if [[ -z "${1:-}" || ! "$1" =~ ^[0-9]+$ ]]; then
echo "Error: --threshold requires a numeric value in MB"
exit 1
fi
THRESHOLD_BYTES=$(( $1 * BYTES_PER_MB ))
;;
--help|-h)
echo "Usage: $0 [--force] [--threshold MB]"
echo " --force, -f Skip confirmations, remove everything"
echo " --threshold, -t Min size in MB to prompt for removal (default: ${DEFAULT_THRESHOLD_MB})"
exit 0
;;
*)
echo "Unknown option: $1"
echo "Usage: $0 [--force] [--threshold MB]"
exit 1
;;
esac
shift
done
# ── Helpers ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
total_freed=0
total_skipped=0
human_size() {
local bytes=$1
if (( bytes >= BYTES_PER_GB )); then
printf "%.1f GB" "$(echo "scale=1; $bytes / $BYTES_PER_GB" | bc)"
elif (( bytes >= BYTES_PER_MB )); then
printf "%.0f MB" "$(echo "scale=0; $bytes / $BYTES_PER_MB" | bc)"
else
printf "%.0f KB" "$(echo "scale=0; $bytes / $BYTES_PER_KB" | bc)"
fi
}
dir_size_bytes() {
du -sk "$1" 2>/dev/null | awk '{print $1 * 1024}' || echo 0
}
# Check if a path is in the exceptions list.
is_exception() {
local path="$1"
[[ -f "$EXCEPTIONS_FILE" ]] || return 1
grep -qxF "$path" "$EXCEPTIONS_FILE" 2>/dev/null
}
# Add a path to the exceptions list.
add_exception() {
local path="$1"
mkdir -p "$(dirname "$EXCEPTIONS_FILE")"
echo "$path" >> "$EXCEPTIONS_FILE"
echo -e " ${CYAN}⊘${NC} Added to exceptions (${EXCEPTIONS_FILE})"
}
# Prompt the user for confirmation. Returns 0 (yes) or 1 (no).
# In --force mode, always returns 0.
# Accepts an optional second arg: the path to allow "never" exceptions.
confirm() {
local prompt="$1"
local path="${2:-}"
if $FORCE; then
return 0
fi
if [[ -n "$path" ]]; then
echo -ne " ${YELLOW}?${NC} ${prompt} [y/N/never] "
else
echo -ne " ${YELLOW}?${NC} ${prompt} [y/N] "
fi
read -r answer </dev/tty
case "$answer" in
[yY]|[yY][eE][sS]) return 0 ;;
[nN][eE][vV][eE][rR])
if [[ -n "$path" ]]; then
add_exception "$path"
fi
return 1
;;
*) return 1 ;;
esac
}
# Tracks whether the last remove_and_tally[_bulk] call showed a prompt.
ITEM_SHOWN=0
# Remove a path after confirmation; tally freed space.
# Shows the folder path and size before asking.
remove_and_tally() {
local path="$1"
local label="$2"
ITEM_SHOWN=0
if [[ -e "$path" ]]; then
if is_exception "$path"; then
return
fi
local size
size=$(dir_size_bytes "$path")
if (( size < THRESHOLD_BYTES )); then
return
fi
ITEM_SHOWN=1
local hsize
hsize=$(human_size "$size")
echo -e " ${BOLD}${label}${NC} — ${hsize}"
if confirm "Remove?" "$path"; then
command -v chflags &>/dev/null && chflags -R nouchg "$path" 2>/dev/null
chmod -R u+rwX "$path" 2>/dev/null
rm -rf "$path" 2>/dev/null || rm -rf "$path"
total_freed=$((total_freed + size))
echo -e " ${GREEN}✓${NC} Removed"
else
total_skipped=$((total_skipped + size))
echo -e " ${CYAN}↷${NC} Skipped"
fi
echo ""
fi
}
# Associative array of auto-approved parent directories.
# When a user says "remove all in parent/grandparent", we store the path here.
declare -A AUTO_APPROVED_DIRS
# Prompt with 4 options for project-based removals (node_modules, venvs).
# Sets BULK_REPLY to: "y", "n", "parent", or "grandparent".
# In --force mode, always sets "y".
confirm_bulk() {
local prompt="$1"
local path="$2"
local parent
parent=$(dirname "$path")
local grandparent
grandparent=$(dirname "$parent")
BULK_REPLY="n"
if $FORCE; then
BULK_REPLY="y"
return
fi
# Check if already auto-approved by a previous "all in parent/grandparent" choice
for approved in "${!AUTO_APPROVED_DIRS[@]}"; do
if [[ "$path" == "$approved"/* ]]; then
BULK_REPLY="y"
return
fi
done
local parent_label="${parent/#$HOME_DIR/~}"
local grandparent_label="${grandparent/#$HOME_DIR/~}"
echo -e " ${YELLOW}?${NC} ${prompt}"
echo -e " ${BOLD}y${NC}) Remove"
echo -e " ${BOLD}n${NC}) Skip"
echo -e " ${BOLD}p${NC}) Remove all in ${BOLD}${parent_label}/${NC}"
echo -e " ${BOLD}g${NC}) Remove all in ${BOLD}${grandparent_label}/${NC}"
echo -e " ${BOLD}never${NC}) Never delete this"
echo -ne " Choice [y/n/p/g/never]: "
read -r answer </dev/tty
case "$answer" in
[yY]) BULK_REPLY="y" ;;
[pP])
AUTO_APPROVED_DIRS["$parent"]=1
BULK_REPLY="y"
;;
[gG])
AUTO_APPROVED_DIRS["$grandparent"]=1
BULK_REPLY="y"
;;
[nN][eE][vV][eE][rR])
add_exception "$path"
BULK_REPLY="n"
;;
*) BULK_REPLY="n" ;;
esac
}
# Remove a path with bulk options (y/n/parent/grandparent).
# Shows the folder path and size before asking.
remove_and_tally_bulk() {
local path="$1"
local label="$2"
ITEM_SHOWN=0
if [[ -e "$path" ]]; then
if is_exception "$path"; then
return
fi
local size
size=$(dir_size_bytes "$path")
if (( size < THRESHOLD_BYTES )); then
return
fi
ITEM_SHOWN=1
local hsize
hsize=$(human_size "$size")
echo -e " ${BOLD}${label}${NC} — ${hsize}"
confirm_bulk "Remove?" "$path"
if [[ "$BULK_REPLY" == "y" ]]; then
command -v chflags &>/dev/null && chflags -R nouchg "$path" 2>/dev/null
chmod -R u+rwX "$path" 2>/dev/null
rm -rf "$path" 2>/dev/null || rm -rf "$path"
total_freed=$((total_freed + size))
echo -e " ${GREEN}✓${NC} Removed"
else
total_skipped=$((total_skipped + size))
echo -e " ${CYAN}↷${NC} Skipped"
fi
echo ""
fi
}
section() {
echo ""
echo -e "${CYAN}${BOLD}── $1 ──${NC}"
}
# ── Header ───────────────────────────────────────────────────────────────────
echo ""
threshold_human=$(human_size "$THRESHOLD_BYTES")
if $FORCE; then
echo -e "${BOLD}Disk Safe Cleanup${NC} (${RED}force mode${NC} — no confirmations, threshold: ${threshold_human})"
else
echo -e "${BOLD}Disk Safe Cleanup${NC} (interactive, threshold: ${threshold_human})"
fi
# ── 1. npm cache ─────────────────────────────────────────────────────────────
section "npm cache"
if command -v npm &>/dev/null; then
npm_cache_path="$HOME_DIR/.npm/_cacache"
size_before=$(dir_size_bytes "$npm_cache_path" 2>/dev/null || echo 0)
if is_exception "$npm_cache_path"; then
: # silently skip
elif (( size_before >= THRESHOLD_BYTES )); then
echo -e " ${BOLD}~/.npm/_cacache${NC} — $(human_size "$size_before")"
if confirm "Clean npm cache?" "$npm_cache_path"; then
npm cache clean --force 2>/dev/null
total_freed=$((total_freed + size_before))
echo -e " ${GREEN}✓${NC} Cleaned npm cache"
else
total_skipped=$((total_skipped + size_before))
echo -e " ${CYAN}↷${NC} Skipped npm cache"
fi
else
echo -e " ${YELLOW}⊘${NC} npm cache is already empty"
fi
else
echo -e " ${YELLOW}⊘${NC} npm not found, skipping"
fi
# ── 2. Docker cleanup ───────────────────────────────────────────────────────
section "Docker"
if is_exception "docker"; then
: # silently skip
elif command -v docker &>/dev/null && docker info &>/dev/null 2>&1; then
# All images not used by running containers
running_images=$(docker ps --format "{{.Image}}" 2>/dev/null || true)
unused_images=""
while IFS= read -r line; do
[[ -z "$line" ]] && continue
repo_tag=$(echo "$line" | awk '{print $1}')
# Check if this image is used by a running container
is_used=false
while IFS= read -r running; do
[[ -z "$running" ]] && continue
if [[ "$repo_tag" == "$running" || "$repo_tag" == "$running:latest" ]]; then
is_used=true
break
fi
done <<< "$running_images"
if ! $is_used; then
unused_images+=" $line"$'\n'
fi
done < <(docker images --format "{{.Repository}}:{{.Tag}} {{.Size}}" 2>/dev/null)
# List volumes not used by running containers
unused_volumes=""
while IFS= read -r vol; do
[[ -z "$vol" ]] && continue
# Check if volume is in use by a running container
in_use=$(docker ps -q --filter "volume=$vol" 2>/dev/null)
if [[ -z "$in_use" ]]; then
vol_size=$(docker system df -v 2>/dev/null | grep "^$vol" | awk '{print $NF}' || echo "?")
unused_volumes+=" $vol $vol_size"$'\n'
fi
done < <(docker volume ls -q 2>/dev/null)
if [[ -n "$unused_images" ]]; then
echo -e " ${BOLD}Images to remove:${NC}"
echo -n "$unused_images"
echo ""
fi
if [[ -n "$unused_volumes" ]]; then
echo -e " ${BOLD}Volumes to remove:${NC}"
echo -n "$unused_volumes"
echo ""
fi
if [[ -z "$unused_images" && -z "$unused_volumes" ]]; then
echo -e " ${YELLOW}⊘${NC} Nothing to prune"
else
if confirm "Prune Docker (unused images + volumes + build cache)?" "docker"; then
# Remove stopped containers first, so their volumes become unused
docker container prune -f 2>/dev/null | tail -1
# Remove all unused volumes (including named ones)
docker volume prune -a -f 2>/dev/null | tail -1
# Remove unused images
docker image prune -a -f 2>/dev/null | tail -1
# Remove build cache
docker builder prune -f 2>/dev/null | tail -1
echo -e " ${GREEN}✓${NC} Docker pruned"
else
echo -e " ${CYAN}↷${NC} Skipped Docker prune"
fi
fi
else
echo -e " ${YELLOW}⊘${NC} Docker not running or not installed, skipping"
fi
# ── 3. ~/.cache directories ─────────────────────────────────────────────────
section "$HOME_DIR/.cache"
any_shown=0
for dir in "${CACHE_DIRS_TO_CLEAN[@]}"; do
remove_and_tally "$HOME_DIR/.cache/$dir" "$HOME_DIR/.cache/$dir"
(( ITEM_SHOWN )) && any_shown=1
done
if [[ $any_shown -eq 0 ]]; then
echo -e " ${YELLOW}⊘${NC} Nothing to delete"
fi
# ── 4. Library/Caches (macOS) ──────────────────────────────────────────────
if [[ -d "$HOME_DIR/Library/Caches" ]]; then
section "Library/Caches"
any_shown=0
for dir in "${LIBRARY_CACHE_DIRS_TO_CLEAN[@]}"; do
remove_and_tally "$HOME_DIR/Library/Caches/$dir" "Library/Caches/$dir"
(( ITEM_SHOWN )) && any_shown=1
done
if [[ $any_shown -eq 0 ]]; then
echo -e " ${YELLOW}⊘${NC} Nothing to delete"
fi
# Also run brew cleanup if available
if command -v brew &>/dev/null && ! is_exception "brew"; then
brew_cache_dir="$(brew --cache 2>/dev/null || echo "$HOME_DIR/Library/Caches/Homebrew")"
if [[ -d "$brew_cache_dir" ]]; then
brew_size=$(dir_size_bytes "$brew_cache_dir")
if (( brew_size >= THRESHOLD_BYTES )); then
echo -e " ${BOLD}${brew_cache_dir}${NC} — $(human_size "$brew_size")"
if confirm "Run brew cleanup --prune=all?" "brew"; then
# Go module cache files are read-only by default; fix permissions first
if [[ -d "$brew_cache_dir/go_mod_cache" ]]; then
chmod -R u+w "$brew_cache_dir/go_mod_cache" 2>/dev/null
fi
brew cleanup --prune=all 2>/dev/null
echo -e " ${GREEN}✓${NC} brew cleanup --prune=all"
else
echo -e " ${CYAN}↷${NC} Skipped brew cleanup"
fi
fi
fi
fi
fi
# ── 5. Old Node.js versions (nvm) ───────────────────────────────────────────
section "Old Node.js versions (nvm)"
if [[ -d "$NVM_DIR/versions/node" ]]; then
# Source nvm so we can use it
export NVM_DIR
# shellcheck disable=SC1091
[[ -s "$NVM_DIR/nvm.sh" ]] && source "$NVM_DIR/nvm.sh"
current_version=$(node -v 2>/dev/null || echo "")
echo -e " Current Node version: ${BOLD}${current_version}${NC}"
for version_dir in "$NVM_DIR/versions/node"/v*; do
[[ -d "$version_dir" ]] || continue
version=$(basename "$version_dir")
if [[ "$version" == "$current_version" ]]; then
echo -e " ${CYAN}↳${NC} Keeping $version (active)"
else
remove_and_tally "$version_dir" "Node $version"
fi
done
# Also clean nvm cache
remove_and_tally "$NVM_DIR/.cache" "nvm download cache"
else
echo -e " ${YELLOW}⊘${NC} nvm not found, skipping"
fi
# ── 6. Stale node_modules ───────────────────────────────────────────────────
section "Stale node_modules (not accessed in ${NODE_MODULES_MIN_AGE_DAYS}+ days)"
# Build the -not -path exclusions
exclude_args=()
for pattern in "${EXCLUDE_PATHS[@]}"; do
exclude_args+=(-not -path "$pattern")
done
AUTO_APPROVED_DIRS=()
any_shown=0
while IFS= read -r nm_dir; do
[[ -z "$nm_dir" ]] && continue
remove_and_tally_bulk "$nm_dir" "$nm_dir"
(( ITEM_SHOWN )) && any_shown=1
done < <(find "$HOME_DIR" \
-maxdepth "$NODE_MODULES_SEARCH_DEPTH" \
-name "node_modules" \
-type d \
-atime +"$NODE_MODULES_MIN_AGE_DAYS" \
"${exclude_args[@]}" \
2>/dev/null || true)
if [[ $any_shown -eq 0 ]]; then
echo -e " ${YELLOW}⊘${NC} Nothing to delete"
fi
# ── 7. Stale Python virtual environments ─────────────────────────────────────
section "Stale Python venvs (not accessed in ${NODE_MODULES_MIN_AGE_DAYS}+ days)"
VENV_NAMES=(".venv" ".env")
AUTO_APPROVED_DIRS=()
any_shown=0
for venv_name in "${VENV_NAMES[@]}"; do
while IFS= read -r venv_dir; do
[[ -z "$venv_dir" ]] && continue
# Only match actual Python venvs (must contain pyvenv.cfg)
if [[ -f "$venv_dir/pyvenv.cfg" ]]; then
remove_and_tally_bulk "$venv_dir" "$venv_dir"
(( ITEM_SHOWN )) && any_shown=1
fi
done < <(find "$HOME_DIR" \
-maxdepth "$NODE_MODULES_SEARCH_DEPTH" \
-name "$venv_name" \
-type d \
-atime +"$NODE_MODULES_MIN_AGE_DAYS" \
"${exclude_args[@]}" \
2>/dev/null || true)
done
if [[ $any_shown -eq 0 ]]; then
echo -e " ${YELLOW}⊘${NC} Nothing to delete"
fi
# ── 8. uv cache (Python) ─────────────────────────────────────────────────────
section "uv cache (Python)"
uv_shown=false
if command -v uv &>/dev/null && ! is_exception "$HOME_DIR/.cache/uv"; then
uv_cache_dir="$HOME_DIR/.cache/uv"
if [[ -d "$uv_cache_dir" ]]; then
uv_size=$(dir_size_bytes "$uv_cache_dir")
if (( uv_size >= THRESHOLD_BYTES )); then
uv_shown=true
echo -e " ${BOLD}${uv_cache_dir}${NC} — $(human_size "$uv_size")"
if confirm "Run uv cache clean?" "$uv_cache_dir"; then
# Check for running uv processes that hold the cache lock
uv_procs=$(pgrep -fl 'uv run' || true)
if [[ -n "$uv_procs" ]]; then
echo -e " ${YELLOW}!${NC} uv processes are using the cache:"
while IFS= read -r proc; do
[[ -z "$proc" ]] && continue
uv_pid=$(echo "$proc" | awk '{print $1}')
uv_cmd=$(echo "$proc" | awk '{$1=""; print substr($0,2)}')
echo -e " PID ${BOLD}${uv_pid}${NC} ${uv_cmd}"
done <<< "$uv_procs"
if confirm "Kill these processes and proceed?"; then
while IFS= read -r proc; do
[[ -z "$proc" ]] && continue
kill "$(echo "$proc" | awk '{print $1}')" 2>/dev/null
done <<< "$uv_procs"
sleep 1
else
total_skipped=$((total_skipped + uv_size))
echo -e " ${CYAN}↷${NC} Skipped uv cache clean"
uv_size=0 # prevent double tally below
fi
fi
if (( uv_size > 0 )); then
uv cache clean 2>/dev/null
total_freed=$((total_freed + uv_size))
echo -e " ${GREEN}✓${NC} uv cache clean"
fi
else
total_skipped=$((total_skipped + uv_size))
echo -e " ${CYAN}↷${NC} Skipped uv cache clean"
fi
fi
fi
fi
if ! $uv_shown; then
echo -e " ${YELLOW}⊘${NC} Nothing to delete"
fi
# ── 9. pre-commit cache ─────────────────────────────────────────────────────
section "pre-commit cache"
pc_shown=false
if command -v pre-commit &>/dev/null && ! is_exception "$HOME_DIR/.cache/pre-commit"; then
precommit_cache_dir="$HOME_DIR/.cache/pre-commit"
if [[ -d "$precommit_cache_dir" ]]; then
pc_size=$(dir_size_bytes "$precommit_cache_dir")
if (( pc_size >= THRESHOLD_BYTES )); then
pc_shown=true
echo -e " ${BOLD}${precommit_cache_dir}${NC} — $(human_size "$pc_size")"
if confirm "Run pre-commit clean?" "$precommit_cache_dir"; then
pre-commit clean 2>/dev/null
total_freed=$((total_freed + pc_size))
echo -e " ${GREEN}✓${NC} pre-commit clean"
else
total_skipped=$((total_skipped + pc_size))
echo -e " ${CYAN}↷${NC} Skipped pre-commit clean"
fi
fi
fi
fi
if ! $pc_shown; then
echo -e " ${YELLOW}⊘${NC} Nothing to delete"
fi
# ── Summary ──────────────────────────────────────────────────────────────────
echo ""
echo -e "${BOLD}════════════════════════════════════════${NC}"
echo -e "${GREEN}${BOLD} Space freed: $(human_size "$total_freed")${NC}"
if (( total_skipped > 0 )); then
echo -e "${YELLOW}${BOLD} Space skipped: $(human_size "$total_skipped")${NC}"
fi
echo -e "${BOLD}════════════════════════════════════════${NC}"
echo ""
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment