VOOZH about

URL: https://dev.to/milizc/i-built-a-zsh-cleanup-script-for-macos-dev-machines-and-learned-more-than-i-expected-2ff3

⇱ I built a zsh cleanup script for macOS dev machines — and learned more than I expected - DEV Community


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 readnpm 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

github.com/milyzc/clean-mac

Feedback welcome — especially if there are other categories worth adding, or if something behaves differently on your setup.