storage-pressure.sh 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. #!/usr/bin/env bash
  2. # mac-ops :: storage-pressure.sh
  3. # "Disk is full but I deleted everything" — explain macOS's purgeable space
  4. # accounting and surface the actual consumers (APFS snapshots, local Time
  5. # Machine backups, Spotlight index, iCloud cached files, etc).
  6. set -u
  7. VOL="/"
  8. while [[ $# -gt 0 ]]; do
  9. case "$1" in
  10. -v|--volume) VOL="$2"; shift 2 ;;
  11. --help|-h)
  12. cat <<EOF
  13. Usage: $0 [options]
  14. -v, --volume PATH Volume to analyze (default: /)
  15. --json, --redact, --quiet, --verbose
  16. Why "About This Mac → Storage" doesn't match du:
  17. - APFS local Time Machine snapshots: data deleted but retained for TM
  18. - iCloud cached files: shown as "Purgeable" — frees automatically under pressure
  19. - Spotlight index: ~.Spotlight-V100 hidden dir
  20. - Cached files in ~/Library/Caches, /var/folders
  21. - Sleepimage, swap files (in dynamic_pager dirs)
  22. Common reclaims:
  23. tmutil thinlocalsnapshots / # remove eligible TM snapshots
  24. tmutil deletelocalsnapshots <name> # specific snapshot
  25. diskutil apfs deleteSnapshot diskNsM <name>
  26. EOF
  27. exit 0 ;;
  28. *) shift ;;
  29. esac
  30. done
  31. source "$(dirname "$0")/_lib/common.sh"
  32. parse_common_flags "$@"
  33. maybe_filter_self "$@"
  34. if [[ ! -d "$VOL" ]]; then
  35. echo "Error: $VOL is not a directory" >&2
  36. exit 3
  37. fi
  38. note " Volume: $VOL"
  39. # ----------------------------------------------------------------------------
  40. section "1. df vs APFS reality"
  41. # ----------------------------------------------------------------------------
  42. df -h "$VOL" 2>/dev/null | head -2 | sed 's/^/ /'
  43. # diskutil info gives the APFS-aware view including snapshot space
  44. note ""
  45. note " diskutil info (APFS-aware):"
  46. disk_id=$(diskutil info "$VOL" 2>/dev/null | awk -F': *' '/Device Identifier/{print $2; exit}')
  47. if [[ -n "$disk_id" ]]; then
  48. diskutil info "$disk_id" 2>/dev/null | grep -E "Allocation Block Size|Container Total Space|Container Free Space|Volume Used Space|Volume Free Space|APFS Snapshot|Capacity In Use" | sed 's/^/ /'
  49. fi
  50. # ----------------------------------------------------------------------------
  51. section "2. APFS SNAPSHOTS"
  52. # ----------------------------------------------------------------------------
  53. snap_count=$(tmutil listlocalsnapshots "$VOL" 2>/dev/null | grep -c "com.apple" | tr -d ' \n')
  54. snap_count="${snap_count:-0}"
  55. if (( snap_count > 0 )); then
  56. log_info "Local Time Machine snapshots" "$snap_count"
  57. note " Recent (last 10):"
  58. tmutil listlocalsnapshots "$VOL" 2>/dev/null | tail -10 | sed 's/^/ /'
  59. # Calculate approximate space held by snapshots
  60. if [[ -n "$disk_id" ]]; then
  61. snap_space=$(diskutil apfs list 2>/dev/null | awk -v d="$disk_id" '
  62. $0 ~ d {found=1}
  63. found && /Snapshot/ {print; if (++n >= 5) exit}
  64. ' | head -8)
  65. if [[ -n "$snap_space" ]]; then
  66. note ""
  67. note " Snapshot space (from diskutil apfs list):"
  68. echo "$snap_space" | sed 's/^/ /'
  69. fi
  70. fi
  71. if (( snap_count > 20 )); then
  72. log_warn "Snapshot count" "$snap_count — consider 'tmutil thinlocalsnapshots $VOL'"
  73. fi
  74. else
  75. log_pass "Local Time Machine snapshots" "0"
  76. fi
  77. # ----------------------------------------------------------------------------
  78. section "3. iCLOUD CACHED FILES"
  79. # ----------------------------------------------------------------------------
  80. icloud_dir="$HOME/Library/Mobile Documents"
  81. if [[ -d "$icloud_dir" ]]; then
  82. icloud_size=$(du -sh "$icloud_dir" 2>/dev/null | awk '{print $1}')
  83. log_info "iCloud Drive cache size" "${icloud_size:-?}"
  84. note " These are typically marked 'Purgeable' — macOS evicts under pressure."
  85. fi
  86. # ----------------------------------------------------------------------------
  87. section "4. CACHE / TEMPORARY DIRECTORIES"
  88. # ----------------------------------------------------------------------------
  89. note " User caches:"
  90. for d in "$HOME/Library/Caches" "$HOME/Library/Application Support/Caches"; do
  91. if [[ -d "$d" ]]; then
  92. size=$(du -sh "$d" 2>/dev/null | awk '{print $1}')
  93. printf " %s = %s\n" "$d" "${size:-?}"
  94. fi
  95. done
  96. note ""
  97. note " System caches:"
  98. for d in /Library/Caches /var/folders /private/var/log; do
  99. if [[ -d "$d" ]]; then
  100. size=$(sudo -n du -sh "$d" 2>/dev/null | awk '{print $1}')
  101. if [[ -z "$size" ]]; then
  102. # No sudo — try without
  103. size=$(du -sh "$d" 2>/dev/null | awk '{print $1}')
  104. fi
  105. printf " %s = %s\n" "$d" "${size:-?}"
  106. fi
  107. done
  108. # ----------------------------------------------------------------------------
  109. section "5. SLEEPIMAGE + SWAP"
  110. # ----------------------------------------------------------------------------
  111. if [[ -f /private/var/vm/sleepimage ]]; then
  112. size=$(ls -lh /private/var/vm/sleepimage 2>/dev/null | awk '{print $5}')
  113. log_info "Sleep image" "${size:-?} — equals RAM size; safe to ignore"
  114. fi
  115. swap_files=$(ls /private/var/vm/swapfile* 2>/dev/null | wc -l | tr -d ' ')
  116. if [[ "$swap_files" -gt 0 ]]; then
  117. swap_total=$(ls -lh /private/var/vm/swapfile* 2>/dev/null | awk '{sum+=$5}END{print sum/1024/1024" GB"}')
  118. log_info "Swap files" "$swap_files files (~$swap_total) — grows under memory pressure"
  119. fi
  120. # ----------------------------------------------------------------------------
  121. section "6. SPOTLIGHT INDEX SIZE"
  122. # ----------------------------------------------------------------------------
  123. spot_dir="$VOL/.Spotlight-V100"
  124. if [[ -d "$spot_dir" ]]; then
  125. spot_size=$(sudo -n du -sh "$spot_dir" 2>/dev/null | awk '{print $1}')
  126. [[ -z "$spot_size" ]] && spot_size="(needs sudo to size)"
  127. log_info "Spotlight index size" "$spot_size"
  128. fi
  129. # ----------------------------------------------------------------------------
  130. section "7. TOP 10 LARGEST DIRECTORIES IN ~ (heuristic)"
  131. # ----------------------------------------------------------------------------
  132. note " This walks ~ — may take a moment on large home dirs."
  133. du -sh "$HOME"/* 2>/dev/null | sort -rh | head -10 | sed 's/^/ /'
  134. # ----------------------------------------------------------------------------
  135. emit_summary
  136. if [[ "$JSON_MODE" -eq 0 ]]; then
  137. echo
  138. note " Reclaim playbook:"
  139. note " tmutil thinlocalsnapshots $VOL # trim eligible local TM snapshots"
  140. note " rm -rf ~/Library/Caches/* # clear per-user caches"
  141. note " docker system prune -a # Docker images/volumes"
  142. note " brew cleanup -s # Homebrew cached downloads"
  143. note " sudo periodic daily weekly monthly # rotate system logs"
  144. fi