If you do iOS and Android development on a Mac, your disk is quietly dying.
Between Xcode's DerivedData, old iOS DeviceSupport folders, Android SDK build-tools release candidates, stale node_modules, CocoaPods cache and Docker leftovers — it's not unusual to have 20–50 GB of junk that your machine accumulated over months without ever asking.
I got tired of manually hunting these down, so I wrote clean-mac: a single zsh script that cleans 18 categories of dev waste, with a --dry-run mode that shows you exactly what would be deleted before touching anything.
curl -fsSL https://raw.githubusercontent.com/milyzc/clean-mac/main/clean.sh \
-o clean.sh && chmod +x clean.sh
./clean.sh --dry-run # always start here
./clean.sh
What it cleans
| Category | What happens |
|---|---|
| npm / yarn / pnpm cache |
cache clean + store prune
|
| Homebrew |
cleanup --prune=all + autoremove
|
| Gradle cache |
~/.gradle/caches — generated by Android builds |
| CocoaPods cache | pod cache clean --all |
| Xcode DerivedData | intermediate build artifacts |
| Unavailable iOS simulators | xcrun simctl delete unavailable |
| iOS DeviceSupport | keeps only the 2 most recent versions |
| Android SDK build-tools | removes versions < 34 and all RCs |
| Android SDK cmdline-tools | removes old versions, keeps latest |
| node_modules | removes folders not accessed in 60+ days |
| iOS simulator data | wipes app data, keeps devices |
| Android AVD snapshots | removes snapshot dirs from each AVD |
| Swift PM cache | ~/Library/Caches/org.swift.swiftpm |
| Diagnostic Reports |
.crash and .ips files older than 30 days |
| Git repos |
git gc --prune=now on all local repos |
| Docker | dangling images and stopped containers only |
| VS Code cache | editor cache folder |
| Trash | ~/.Trash |
The script prints disk usage before and after each category so you always know what's actually gone.
The interesting bugs I hit writing it
This is the part I actually want to talk about, because the script looked simple at first.
1. zsh arrays are 1-indexed
Coming from bash, my first version of the iOS DeviceSupport cleanup looked like this:
VERSIONS=($(ls "$DS_DIR" | sort -Vr))
to_delete=("${VERSIONS[@]:2}") # bash: skip first 2
This silently does the wrong thing in zsh. Arrays start at 1, not 0, so the slice is off by one. The fix is explicit loop bounds:
VERSIONS=(${(f)"$(ls "$DS_DIR" 2>/dev/null | sort -Vr)"})
COUNT=${#VERSIONS[@]}
if (( COUNT > 2 )); then
for (( i=3; i<=COUNT; i++ )); do
rm -rf "$DS_DIR/${VERSIONS[$i]}"
done
fi
Also note ${(f)...} — the f flag splits on newlines instead of spaces, which matters because iOS DeviceSupport folder names contain spaces (16.4 (20E247)).
2. Glob errors on an empty directory
The Trash cleanup was:
rm -rf ~/.Trash/*
When Trash is empty, zsh expands * and finds nothing — and unlike bash, it throws an error instead of passing a literal *. 2>/dev/null doesn't help because the error happens at expansion time, before the command runs.
Fix: the (N) glob qualifier enables NULL_GLOB for that specific pattern:
rm -rf ~/.Trash/*(N)
If Trash is empty, *(N) expands to nothing and rm is never called. Clean.
3. ${(q)} quoting collapses array args
For the sdkmanager --uninstall call I originally built the command as a string to preview it in dry-run mode:
CMD="$SDKMANAGER --uninstall ${(q)TO_UNINSTALL[@]}"
run_sh "$CMD"
${(q)} adds shell quoting to each element — but inside a double-quoted string, the spaces between elements get backslash-escaped, so all packages collapse into a single argument. sdkmanager receives one giant string and silently does nothing.
The fix is to stop building a string and pass the array directly:
if $DRY_RUN; then
echo " [dry-run] $SDKMANAGER --uninstall ${TO_UNINSTALL[*]}"
else
"$SDKMANAGER" --uninstall "${TO_UNINSTALL[@]}"
fi
4. -mtime vs -atime on APFS
The node_modules finder originally used -mtime +60 to find folders not modified in 60 days. But on APFS (which is what every modern Mac uses), a node_modules that you only read — npm install, running tests, starting a dev server — won't update mtime. You'd keep it forever.
-atime tracks last access, which is what you actually want here:
find ~ -maxdepth 8 -name "node_modules" -type d -prune -atime +60 ...
Design decisions
It never removes things that are hard to get back. Active Android SDK platforms, system images, platform-tools, named Docker volumes, available simulators, and any node_modules accessed in the last 60 days are all untouched.
Dry-run is a first-class feature, not an afterthought. Every destructive operation goes through a run() function that intercepts it in dry-run mode. Nothing gets deleted unless you run it without the flag.
It tells you what it found before removing it. iOS DeviceSupport versions, node_modules paths, Android packages — all printed before deletion so there are no surprises.
Try it
curl -fsSL https://raw.githubusercontent.com/milyzc/clean-mac/main/clean.sh \
-o clean.sh && chmod +x clean.sh
./clean.sh --dry-run
Feedback welcome — especially if there are other categories worth adding, or if something behaves differently on your setup.
Top comments (0)