#!/usr/bin/env bash set -euo pipefail # Read-only disk insight for macOS. Safe to run via: curl -fsSL | bash # Reports volume usage, heaviest home directories, large files, and cleanup ideas. # Does not delete anything. if [[ "$(uname -s)" != "Darwin" ]]; then echo "disk-insight.sh is meant for macOS." >&2 exit 1 fi HOME_DIR="${HOME}" MIN_FILE_BYTES=$((100 * 1024 * 1024)) TOP_N=15 SUGGESTIONS=0 log() { printf '%s\n' "$*"; } hr() { log "----------------------------------------"; } bytes_human() { local bytes="$1" awk -v b="$bytes" 'BEGIN { split("B KB MB GB TB", u, " ") i = 1 while (b >= 1024 && i < 5) { b /= 1024; i++ } if (i == 1) printf "%d %s\n", b, u[i] else printf "%.1f %s\n", b, u[i] }' } # du of a huge tree can sit for minutes. Cap each folder so the script keeps moving. DIR_TIMEOUT_SECS=12 dir_bytes() { local path="$1" local timeout_secs="${2:-$DIR_TIMEOUT_SECS}" if [[ ! -e "$path" ]]; then echo "0" return fi local out pid elapsed out="$(mktemp)" du -sk "$path" 2>/dev/null | awk '{print $1 * 1024}' > "$out" & pid=$! elapsed=0 while kill -0 "$pid" 2>/dev/null; do if [[ "$elapsed" -ge "$timeout_secs" ]]; then kill "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true rm -f "$out" echo "timeout" return fi sleep 1 elapsed=$((elapsed + 1)) done wait "$pid" 2>/dev/null || true if [[ ! -s "$out" ]]; then rm -f "$out" echo "0" return fi cat "$out" rm -f "$out" } print_dir_size() { local path="$1" local bytes bytes="$(dir_bytes "$path")" if [[ "$bytes" == "timeout" ]]; then printf ' %-10s %s (still counting — skipped)\n' "large" "$path" return fi if [[ "$bytes" -gt 0 ]]; then printf ' %-10s %s\n' "$(bytes_human "$bytes")" "$path" fi } scan_home_top_level() { local item base bytes tmp tmp="$(mktemp)" log "Largest folders in ${HOME_DIR} (top level only)" log "Library is skipped here — it is broken down in the next section." shopt -s nullglob dotglob for item in "${HOME_DIR}"/*; do [[ -e "$item" ]] || continue base="${item##*/}" [[ "$base" == "." || "$base" == ".." ]] && continue if [[ "$base" == "Library" ]]; then continue fi printf ' measuring %s ...\r' "$base" >&2 bytes="$(dir_bytes "$item")" if [[ "$bytes" == "timeout" ]]; then printf '999999999999999\t%s\ttimeout\n' "$item" >> "$tmp" elif [[ "$bytes" -gt 0 ]]; then printf '%s\t%s\n' "$bytes" "$item" >> "$tmp" fi done shopt -u nullglob dotglob printf ' measuring done. \n' >&2 if [[ ! -s "$tmp" ]]; then log " No measurable top-level folders." rm -f "$tmp" return fi sort -nr "$tmp" | head -n "$TOP_N" | while IFS=$'\t' read -r size path flag; do if [[ "$flag" == "timeout" ]]; then printf ' %-10s %s\n' "large" "$path (timed out after ${DIR_TIMEOUT_SECS}s — likely huge)" else printf ' %-10s %s\n' "$(bytes_human "$size")" "$path" fi done || true rm -f "$tmp" } log "disk-insight — read only, nothing will be deleted" hr log "Volume usage" df -h / | awk 'NR==1 || /\/$/' hr scan_home_top_level hr log "Where space often hides" print_dir_size "${HOME_DIR}/Library/Caches" print_dir_size "${HOME_DIR}/Library/Logs" print_dir_size "${HOME_DIR}/Library/Developer/Xcode/DerivedData" print_dir_size "${HOME_DIR}/Library/Developer/Xcode/Archives" print_dir_size "${HOME_DIR}/Library/Developer/CoreSimulator" print_dir_size "${HOME_DIR}/Library/Application Support/MobileSync/Backup" print_dir_size "${HOME_DIR}/Library/Application Support/Docker" print_dir_size "${HOME_DIR}/Library/Containers/com.docker.docker" print_dir_size "${HOME_DIR}/Library/Application Support/Cursor" print_dir_size "${HOME_DIR}/Library/Application Support/Code" print_dir_size "${HOME_DIR}/Downloads" print_dir_size "${HOME_DIR}/.Trash" print_dir_size "${HOME_DIR}/.npm" print_dir_size "${HOME_DIR}/.cache" print_dir_size "${HOME_DIR}/.local/share" hr DOCKER_IMAGES=0 DOCKER_DANGLING=0 DOCKER_STALE=0 DOCKER_AVAILABLE=0 report_docker() { log "Docker images and stale containers" if ! command -v docker >/dev/null 2>&1; then log " Docker CLI not found. Skipping." hr return fi if ! docker info >/dev/null 2>&1; then log " Docker is installed, but the daemon is not running." log " Start Docker Desktop and re-run to inspect images and containers." hr return fi DOCKER_AVAILABLE=1 DOCKER_IMAGES="$(docker images -q 2>/dev/null | sed '/^$/d' | wc -l | tr -d ' ')" DOCKER_DANGLING="$(docker images -q -f dangling=true 2>/dev/null | sed '/^$/d' | wc -l | tr -d ' ')" local stale_file stale_file="$(mktemp)" { docker ps -aq --filter status=exited 2>/dev/null || true docker ps -aq --filter status=created 2>/dev/null || true docker ps -aq --filter status=dead 2>/dev/null || true } | sed '/^$/d' | sort -u > "$stale_file" DOCKER_STALE="$(wc -l < "$stale_file" | tr -d ' ')" log " Disk summary" docker system df 2>/dev/null | sed 's/^/ /' || true log if [[ "$DOCKER_IMAGES" -eq 0 ]]; then log " No Docker images found." else log " Images (${DOCKER_IMAGES})" docker images --format 'table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.ID}}' 2>/dev/null | sed 's/^/ /' || true if [[ "$DOCKER_DANGLING" -gt 0 ]]; then log log " Dangling unused images (${DOCKER_DANGLING}) — these are leftover layers with no tag" docker images -f dangling=true --format 'table {{.ID}}\t{{.Size}}\t{{.CreatedSince}}' 2>/dev/null | sed 's/^/ /' || true fi fi log if [[ "$DOCKER_STALE" -eq 0 ]]; then log " No stale containers (exited, created, or dead)." else log " Stale containers (${DOCKER_STALE}) — exited, created, or dead" while IFS= read -r id; do [[ -n "$id" ]] || continue docker ps -a --size --filter "id=${id}" --format ' {{.ID}} {{.Names}} {{.Image}} {{.Status}} {{.Size}}' 2>/dev/null || true done < "$stale_file" fi rm -f "$stale_file" hr } report_docker log "Largest files (about ${TOP_N} over 100 MB)" TMP_FILES="$(mktemp)" trap 'rm -f "$TMP_FILES"' EXIT if command -v mdfind >/dev/null 2>&1; then mdfind -onlyin "$HOME_DIR" "kMDItemFSSize >= ${MIN_FILE_BYTES}" 2>/dev/null \ | while IFS= read -r path; do [[ -f "$path" ]] || continue stat -f '%z %N' "$path" 2>/dev/null || true done \ | sort -nr \ | head -n "$TOP_N" > "$TMP_FILES" || true else find "$HOME_DIR" -type f -size +100M 2>/dev/null \ | while IFS= read -r path; do stat -f '%z %N' "$path" 2>/dev/null || true done \ | sort -nr \ | head -n "$TOP_N" > "$TMP_FILES" || true fi if [[ ! -s "$TMP_FILES" ]]; then log " No files over 100 MB found (or Spotlight has not indexed them yet)." else while IFS=$'\t' read -r size path; do printf ' %-10s %s\n' "$(bytes_human "$size")" "$path" done < "$TMP_FILES" fi hr log "Cleanup suggestions (review first — this script does not delete)" suggest() { local path="$1" local hint="$2" local bytes bytes="$(dir_bytes "$path")" if [[ "$bytes" -gt $((200 * 1024 * 1024)) ]]; then printf ' • %s is %s — %s\n' "$path" "$(bytes_human "$bytes")" "$hint" SUGGESTIONS=$((SUGGESTIONS + 1)) fi } suggest "${HOME_DIR}/.Trash" "Empty Trash if you do not need those files." suggest "${HOME_DIR}/Downloads" "Sort Downloads; old installers and zips add up." suggest "${HOME_DIR}/Library/Caches" "App caches can usually be cleared; apps will rebuild them." suggest "${HOME_DIR}/Library/Logs" "Old log files are often safe to remove." suggest "${HOME_DIR}/Library/Developer/Xcode/DerivedData" "Xcode derived data is rebuildable." suggest "${HOME_DIR}/Library/Developer/CoreSimulator" "Unused iOS simulators take a lot of disk." suggest "${HOME_DIR}/Library/Application Support/MobileSync/Backup" "Old iPhone backups are often forgotten." suggest "${HOME_DIR}/Library/Application Support/Docker" "Docker images and volumes grow quickly." suggest "${HOME_DIR}/.npm" "npm cache can be cleared with: npm cache clean --force" if [[ -s "$TMP_FILES" ]] && grep -q 'node_modules' "$TMP_FILES"; then log " • Large node_modules trees showed up. Remove unused project folders or run a dependency cleanup." SUGGESTIONS=$((SUGGESTIONS + 1)) fi if [[ "$DOCKER_AVAILABLE" -eq 1 ]]; then if [[ "$DOCKER_DANGLING" -gt 0 ]]; then log " • ${DOCKER_DANGLING} dangling Docker image(s). Review with: docker image prune" SUGGESTIONS=$((SUGGESTIONS + 1)) fi if [[ "$DOCKER_STALE" -gt 0 ]]; then log " • ${DOCKER_STALE} stale Docker container(s). Review with: docker container prune" SUGGESTIONS=$((SUGGESTIONS + 1)) fi if [[ "$DOCKER_IMAGES" -gt 0 ]]; then log " • ${DOCKER_IMAGES} Docker image(s) on disk. Unused ones: docker image prune -a" SUGGESTIONS=$((SUGGESTIONS + 1)) fi fi if [[ "$SUGGESTIONS" -eq 0 ]]; then log " No obvious 200 MB+ cleanup targets in the usual places. Check the largest folders above." fi log log "Done. Nothing was changed on disk."