DEV Community

Mili Cardenas
Mili Cardenas

Posted on

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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/*
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

${(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
Enter fullscreen mode Exit fullscreen mode

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 ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

github.com/milyzc/clean-mac

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

Top comments (0)