<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mili Cardenas</title>
    <description>The latest articles on DEV Community by Mili Cardenas (@milizc).</description>
    <link>https://dev.to/milizc</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1621279%2Fe1a7aa15-0157-4045-af31-4dc4d01ecc65.png</url>
      <title>DEV Community: Mili Cardenas</title>
      <link>https://dev.to/milizc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/milizc"/>
    <language>en</language>
    <item>
      <title>I built a zsh cleanup script for macOS dev machines — and learned more than I expected</title>
      <dc:creator>Mili Cardenas</dc:creator>
      <pubDate>Wed, 27 May 2026 05:19:02 +0000</pubDate>
      <link>https://dev.to/milizc/i-built-a-zsh-cleanup-script-for-macos-dev-machines-and-learned-more-than-i-expected-2ff3</link>
      <guid>https://dev.to/milizc/i-built-a-zsh-cleanup-script-for-macos-dev-machines-and-learned-more-than-i-expected-2ff3</guid>
      <description>&lt;p&gt;If you do iOS and Android development on a Mac, your disk is quietly dying.&lt;/p&gt;

&lt;p&gt;Between Xcode's DerivedData, old iOS DeviceSupport folders, Android SDK build-tools release candidates, stale &lt;code&gt;node_modules&lt;/code&gt;, CocoaPods cache and Docker leftovers — it's not unusual to have &lt;strong&gt;20–50 GB of junk&lt;/strong&gt; that your machine accumulated over months without ever asking.&lt;/p&gt;

&lt;p&gt;I got tired of manually hunting these down, so I wrote &lt;a href="https://github.com/milyzc/clean-mac" rel="noopener noreferrer"&gt;&lt;code&gt;clean-mac&lt;/code&gt;&lt;/a&gt;: a single zsh script that cleans 18 categories of dev waste, with a &lt;code&gt;--dry-run&lt;/code&gt; mode that shows you exactly what would be deleted before touching anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/milyzc/clean-mac/main/clean.sh &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; clean.sh &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;chmod&lt;/span&gt; +x clean.sh
./clean.sh &lt;span class="nt"&gt;--dry-run&lt;/span&gt;  &lt;span class="c"&gt;# always start here&lt;/span&gt;
./clean.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What it cleans
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;npm / yarn / pnpm cache&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cache clean&lt;/code&gt; + &lt;code&gt;store prune&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Homebrew&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cleanup --prune=all&lt;/code&gt; + &lt;code&gt;autoremove&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gradle cache&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;~/.gradle/caches&lt;/code&gt; — generated by Android builds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CocoaPods cache&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pod cache clean --all&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Xcode DerivedData&lt;/td&gt;
&lt;td&gt;intermediate build artifacts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unavailable iOS simulators&lt;/td&gt;
&lt;td&gt;&lt;code&gt;xcrun simctl delete unavailable&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS DeviceSupport&lt;/td&gt;
&lt;td&gt;keeps only the 2 most recent versions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android SDK build-tools&lt;/td&gt;
&lt;td&gt;removes versions &amp;lt; 34 and all RCs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android SDK cmdline-tools&lt;/td&gt;
&lt;td&gt;removes old versions, keeps latest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;node_modules&lt;/td&gt;
&lt;td&gt;removes folders not accessed in 60+ days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS simulator data&lt;/td&gt;
&lt;td&gt;wipes app data, keeps devices&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android AVD snapshots&lt;/td&gt;
&lt;td&gt;removes snapshot dirs from each AVD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Swift PM cache&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~/Library/Caches/org.swift.swiftpm&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Diagnostic Reports&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.crash&lt;/code&gt; and &lt;code&gt;.ips&lt;/code&gt; files older than 30 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Git repos&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;git gc --prune=now&lt;/code&gt; on all local repos&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker&lt;/td&gt;
&lt;td&gt;dangling images and stopped containers only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VS Code cache&lt;/td&gt;
&lt;td&gt;editor cache folder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trash&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~/.Trash&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The script prints disk usage before and after each category so you always know what's actually gone.&lt;/p&gt;




&lt;h2&gt;
  
  
  The interesting bugs I hit writing it
&lt;/h2&gt;

&lt;p&gt;This is the part I actually want to talk about, because the script looked simple at first.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. zsh arrays are 1-indexed
&lt;/h3&gt;

&lt;p&gt;Coming from bash, my first version of the iOS DeviceSupport cleanup looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;VERSIONS&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DS_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-Vr&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;to_delete&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;VERSIONS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;:2&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;  &lt;span class="c"&gt;# bash: skip first 2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;VERSIONS&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="p"&gt;(f)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DS_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-Vr&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;COUNT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;VERSIONS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt; COUNT &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 2 &lt;span class="o"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  for&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt; &lt;span class="nv"&gt;i&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3&lt;span class="p"&gt;;&lt;/span&gt; i&amp;lt;&lt;span class="o"&gt;=&lt;/span&gt;COUNT&lt;span class="p"&gt;;&lt;/span&gt; i++ &lt;span class="o"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DS_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;VERSIONS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;done
fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also note &lt;code&gt;${(f)...}&lt;/code&gt; — the &lt;code&gt;f&lt;/code&gt; flag splits on newlines instead of spaces, which matters because iOS DeviceSupport folder names contain spaces (&lt;code&gt;16.4 (20E247)&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Glob errors on an empty directory
&lt;/h3&gt;

&lt;p&gt;The Trash cleanup was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; ~/.Trash/&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When Trash is empty, zsh expands &lt;code&gt;*&lt;/code&gt; and finds nothing — and unlike bash, it throws an error instead of passing a literal &lt;code&gt;*&lt;/code&gt;. &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; doesn't help because the error happens at expansion time, before the command runs.&lt;/p&gt;

&lt;p&gt;Fix: the &lt;code&gt;(N)&lt;/code&gt; glob qualifier enables &lt;code&gt;NULL_GLOB&lt;/code&gt; for that specific pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; ~/.Trash/&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;N&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Trash is empty, &lt;code&gt;*(N)&lt;/code&gt; expands to nothing and &lt;code&gt;rm&lt;/code&gt; is never called. Clean.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. &lt;code&gt;${(q)}&lt;/code&gt; quoting collapses array args
&lt;/h3&gt;

&lt;p&gt;For the &lt;code&gt;sdkmanager --uninstall&lt;/code&gt; call I originally built the command as a string to preview it in dry-run mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;CMD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SDKMANAGER&lt;/span&gt;&lt;span class="s2"&gt; --uninstall &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="p"&gt;(q)TO_UNINSTALL[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
run_sh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CMD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;${(q)}&lt;/code&gt; 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. &lt;code&gt;sdkmanager&lt;/code&gt; receives one giant string and silently does nothing.&lt;/p&gt;

&lt;p&gt;The fix is to stop building a string and pass the array directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nv"&gt;$DRY_RUN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  [dry-run] &lt;/span&gt;&lt;span class="nv"&gt;$SDKMANAGER&lt;/span&gt;&lt;span class="s2"&gt; --uninstall &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TO_UNINSTALL&lt;/span&gt;&lt;span class="p"&gt;[*]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SDKMANAGER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--uninstall&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TO_UNINSTALL&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. &lt;code&gt;-mtime&lt;/code&gt; vs &lt;code&gt;-atime&lt;/code&gt; on APFS
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;node_modules&lt;/code&gt; finder originally used &lt;code&gt;-mtime +60&lt;/code&gt; to find folders not modified in 60 days. But on APFS (which is what every modern Mac uses), a &lt;code&gt;node_modules&lt;/code&gt; that you only &lt;em&gt;read&lt;/em&gt; — &lt;code&gt;npm install&lt;/code&gt;, running tests, starting a dev server — won't update &lt;code&gt;mtime&lt;/code&gt;. You'd keep it forever.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-atime&lt;/code&gt; tracks last access, which is what you actually want here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find ~ &lt;span class="nt"&gt;-maxdepth&lt;/span&gt; 8 &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"node_modules"&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; d &lt;span class="nt"&gt;-prune&lt;/span&gt; &lt;span class="nt"&gt;-atime&lt;/span&gt; +60 ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Design decisions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It never removes things that are hard to get back.&lt;/strong&gt; Active Android SDK platforms, system images, platform-tools, named Docker volumes, available simulators, and any &lt;code&gt;node_modules&lt;/code&gt; accessed in the last 60 days are all untouched.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dry-run is a first-class feature, not an afterthought.&lt;/strong&gt; Every destructive operation goes through a &lt;code&gt;run()&lt;/code&gt; function that intercepts it in dry-run mode. Nothing gets deleted unless you run it without the flag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It tells you what it found before removing it.&lt;/strong&gt; iOS DeviceSupport versions, &lt;code&gt;node_modules&lt;/code&gt; paths, Android packages — all printed before deletion so there are no surprises.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/milyzc/clean-mac/main/clean.sh &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; clean.sh &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;chmod&lt;/span&gt; +x clean.sh
./clean.sh &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;→ &lt;a href="https://github.com/milyzc/clean-mac" rel="noopener noreferrer"&gt;github.com/milyzc/clean-mac&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Feedback welcome — especially if there are other categories worth adding, or if something behaves differently on your setup.&lt;/p&gt;

</description>
      <category>devtools</category>
      <category>shell</category>
      <category>productivity</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
