storage-pressure.sh 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  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" || echo 0)
  54. if [[ "$snap_count" -gt 0 ]]; then
  55. log_info "Local Time Machine snapshots" "$snap_count"
  56. note " Recent (last 10):"
  57. tmutil listlocalsnapshots "$VOL" 2>/dev/null | tail -10 | sed 's/^/ /'
  58. # Calculate approximate space held by snapshots
  59. if [[ -n "$disk_id" ]]; then
  60. snap_space=$(diskutil apfs list 2>/dev/null | awk -v d="$disk_id" '
  61. $0 ~ d {found=1}
  62. found && /Snapshot/ {print; if (++n >= 5) exit}
  63. ' | head -8)
  64. if [[ -n "$snap_space" ]]; then
  65. note ""
  66. note " Snapshot space (from diskutil apfs list):"
  67. echo "$snap_space" | sed 's/^/ /'
  68. fi
  69. fi
  70. if [[ "$snap_count" -gt 20 ]]; then
  71. log_warn "Snapshot count" "$snap_count — consider 'tmutil thinlocalsnapshots $VOL'"
  72. fi
  73. else
  74. log_pass "Local Time Machine snapshots" "0"
  75. fi
  76. # ----------------------------------------------------------------------------
  77. section "3. iCLOUD CACHED FILES"
  78. # ----------------------------------------------------------------------------
  79. icloud_dir="$HOME/Library/Mobile Documents"
  80. if [[ -d "$icloud_dir" ]]; then
  81. icloud_size=$(du -sh "$icloud_dir" 2>/dev/null | awk '{print $1}')
  82. log_info "iCloud Drive cache size" "${icloud_size:-?}"
  83. note " These are typically marked 'Purgeable' — macOS evicts under pressure."
  84. fi
  85. # ----------------------------------------------------------------------------
  86. section "4. CACHE / TEMPORARY DIRECTORIES"
  87. # ----------------------------------------------------------------------------
  88. note " User caches:"
  89. for d in "$HOME/Library/Caches" "$HOME/Library/Application Support/Caches"; do
  90. if [[ -d "$d" ]]; then
  91. size=$(du -sh "$d" 2>/dev/null | awk '{print $1}')
  92. printf " %s = %s\n" "$d" "${size:-?}"
  93. fi
  94. done
  95. note ""
  96. note " System caches:"
  97. for d in /Library/Caches /var/folders /private/var/log; do
  98. if [[ -d "$d" ]]; then
  99. size=$(sudo -n du -sh "$d" 2>/dev/null | awk '{print $1}')
  100. if [[ -z "$size" ]]; then
  101. # No sudo — try without
  102. size=$(du -sh "$d" 2>/dev/null | awk '{print $1}')
  103. fi
  104. printf " %s = %s\n" "$d" "${size:-?}"
  105. fi
  106. done
  107. # ----------------------------------------------------------------------------
  108. section "5. SLEEPIMAGE + SWAP"
  109. # ----------------------------------------------------------------------------
  110. if [[ -f /private/var/vm/sleepimage ]]; then
  111. size=$(ls -lh /private/var/vm/sleepimage 2>/dev/null | awk '{print $5}')
  112. log_info "Sleep image" "${size:-?} — equals RAM size; safe to ignore"
  113. fi
  114. swap_files=$(ls /private/var/vm/swapfile* 2>/dev/null | wc -l | tr -d ' ')
  115. if [[ "$swap_files" -gt 0 ]]; then
  116. swap_total=$(ls -lh /private/var/vm/swapfile* 2>/dev/null | awk '{sum+=$5}END{print sum/1024/1024" GB"}')
  117. log_info "Swap files" "$swap_files files (~$swap_total) — grows under memory pressure"
  118. fi
  119. # ----------------------------------------------------------------------------
  120. section "6. SPOTLIGHT INDEX SIZE"
  121. # ----------------------------------------------------------------------------
  122. spot_dir="$VOL/.Spotlight-V100"
  123. if [[ -d "$spot_dir" ]]; then
  124. spot_size=$(sudo -n du -sh "$spot_dir" 2>/dev/null | awk '{print $1}')
  125. [[ -z "$spot_size" ]] && spot_size="(needs sudo to size)"
  126. log_info "Spotlight index size" "$spot_size"
  127. fi
  128. # ----------------------------------------------------------------------------
  129. section "7. TOP 10 LARGEST DIRECTORIES IN ~ (heuristic)"
  130. # ----------------------------------------------------------------------------
  131. note " This walks ~ — may take a moment on large home dirs."
  132. du -sh "$HOME"/* 2>/dev/null | sort -rh | head -10 | sed 's/^/ /'
  133. # ----------------------------------------------------------------------------
  134. emit_summary
  135. if [[ "$JSON_MODE" -eq 0 ]]; then
  136. echo
  137. note " Reclaim playbook:"
  138. note " tmutil thinlocalsnapshots $VOL # trim eligible local TM snapshots"
  139. note " rm -rf ~/Library/Caches/* # clear per-user caches"
  140. note " docker system prune -a # Docker images/volumes"
  141. note " brew cleanup -s # Homebrew cached downloads"
  142. note " sudo periodic daily weekly monthly # rotate system logs"
  143. fi