Skip to content

bleach ​

Evidence-based cleanup for orphaned macOS application state

GitHub · bleach.emdzej.pl

About ​

~/Library fills up with directories belonging to apps uninstalled months ago, superseded versions of apps still in use, and caches nothing will ever read again. On the machine it was developed against that came to 160 GB.

The catch is that there is no link on disk from ~/Library/Application Support/Foo back to an app. Directory names are a convention, not a reference — some are reverse-DNS bundle identifiers, some are product names, some are neither. So "is this an orphan?" cannot be computed. It can only be argued for from evidence.

bleach gathers evidence from six independent inventory sources, records why it reached each conclusion, and sorts paths into tiers by how safe removal is. You read the reasoning; it never decides alone.

Written in Swift, no runtime dependencies, single binary.

What it actually finds ​

Orphaned apps are the obvious case. They turned out not to be the biggest one.

ClassExampleHow it's found
Orphaned owner — the app is goneWhisky, 854 MB, untouched 20 monthsno inventory source claims it
Version orphans — app installed, old version's state abandonedIntelliJIdea2025.3 beside 2026.2version-stripped sibling detection
Regenerable cachesCaches/lens-desktop-updater, 1 GBrules plus an owner-installed check
Intra-app dead state8 of 54 Claude Code session directories whose projects were deletedplugins
Unreachable by scope — large, and simply not looked at8.5 GB under ~/Library/Developer, 5.4 GB of pnpm storenamed scan roots, extended
Unactionable by protection — seen, but never proposable50 GB of apple/container storage behind the com.apple. prefixdelegated cleanups outrank the prefix heuristic

It also found roughly 50 GB belonging to tools that ship their own cleanup with their own retention policy. Those get reported with the command rather than reimplemented:

Delegate these — the tool knows its own retention policy:
  11.5G   Homebrew           brew cleanup --prune=all
   8.0G   .espressif         reinstall via the ESP-IDF installer when next needed
   3.6G   .m2                rm -rf ~/.m2/repository
   1.8G   pnpm               pnpm store prune

Safety model ​

The loss function is asymmetric: a missed orphan costs disk space, a false positive costs irreplaceable data. Those are not comparable, so the whole design leans one way.

  • Scanning is read-only. Always.
  • Reversible by default. apply renames paths into a quarantine — an O(1) metadata operation on APFS, so 10 GB moves instantly and the bytes never actually go anywhere. Trash and permanent delete exist, held to identical validation.
  • Confined to $HOME, checked both lexically and against fully resolved paths, so a symlinked parent directory is not a way out. Never uses sudo. System state under /Library is measured and reported, then left alone — a tool that can rm -rf in /Library as root is a categorically different risk.
  • The plan is a file you review. Scanning emits JSON; you read and edit it; apply executes it and does no analysis of its own.
  • apply re-derives every safety property from scratch, because a plan can be hours old, hand-edited, or copied from another machine.
  • The quarantine's own records get the same treatment. A manifest.json is a file too, so restore validates every path it reads out of one, and a batch is identified by its directory name rather than by what its manifest claims to be.
  • "Nothing references this" is not "you don't want this." Where a tool offers its own cleanup, bleach prints the command instead of reimplementing the policy — and where that command is sharper than its own reasoning, it refuses the path outright. container volume prune deletes volumes with no container reference, which a database volume for a project you are not running right now does not have, so bleach protects those and lets you be the one to decide.

Tiers ​

TierMeaningActionable
PROTECTEDSystem-owned, live process, credentials, iCloud mirrors, or a self-updating app's real binaryno
CACHE-SAFERegenerable; the owner rebuilds it on demandyes
ORPHAN?No inventory source claims it, and it's staleyes
REVIEWAmbiguous, or holds user-data-shaped fileswith an explicit flag
UNKNOWNInsufficient evidenceno

Inventory sources ​

Owner attribution is a union of sources, each weighted by how strongly it implies the owner is currently installed:

SourceWeightNote
Running processes1.00anything with a live process is untouchable
Filesystem walk, Spotlight0.95~450 app bundles, effectively instant
Homebrew Caskroom / Cellar0.90often the only name matching a human-named directory
launchd agents and daemons0.70a job pointing at a live binary proves the owner exists
LaunchServices registry0.60most complete, but retains stale entries
Installer receipts0.25survives uninstallation

That last row earns its weight. Slack had been uninstalled but left a pkg receipt behind; without down-weighting receipts its 1.1 GB container would look owned and stay protected forever.

Installation ​

bash
curl -fsSL https://github.com/emdzej/bleach/releases/latest/download/bleach-macos-universal.tar.gz | tar xz
sudo mv bleach /usr/local/bin/

Or from source, with Swift 6:

bash
git clone https://github.com/emdzej/bleach.git && cd bleach
swift build -c release

Usage ​

bash
bleach scan                          # read-only table, largest first
bleach scan --tier orphan --explain  # just orphans, with the evidence trail
bleach scan --by-owner               # one owner's state across all locations

bleach tui                           # browse, select, then plan or apply

bleach plan -o plan.json             # write a plan
bleach apply plan.json               # dry run
bleach apply plan.json --yes         # move to quarantine

bleach restore <batch>               # undo

--explain is the one worth learning first:

ORPHAN?     854M  20mo   app-support   com.isaacmarovitz.Whisky
    clean noOwnerFound: com.isaacmarovitz.Whisky looks like a bundle ID
          but no installed app claims it
    clean stale: nothing modified inside for 617 days

Plugins ​

Rules are YAML, not code. Resolvers are plain executables speaking JSON on stdio, so the valuable knowledge — which directory belongs to what, and which parts of it are dead — can be contributed without touching the safety machinery. A 40-line script is a complete plugin.

A plugin proposes. It cannot delete, never sees file contents, and can only speak about paths inside the scope it declared — confirmed against resolved paths, so neither .. nor a symlink gets it out. It cannot weaken a verdict either: a hint may sharpen UNKNOWN, which means the classifier reached no conclusion, or make a verdict stricter. It can never talk the classifier out of one it did reach.

Three examples ship with it:

  • claude-code-sessions decodes ~/.claude/projects directory names back into project paths and checks whether the project still exists. "The project this belongs to was deleted" is categorical; "untouched for 200 days" is merely circumstantial.
  • opencode-sessions splits ~/.local/share/opencode (2.8 GB, entirely invisible to a Library-only scan) by role: redownloadable language servers, per-session snapshots, session diffs, and the live database — which is deliberately never a candidate.
  • apple-container proposes nothing at all, and that is the point of it. container storage is content-addressable and the runtime keeps its own index of what references what, so moving any single directory aside would leave it pointing at digests that no longer exist. The plugin asks container system df and container ls -a instead, turning an opaque 50 GB row into "44 GB of your images are unused, here is the command". The interesting plugins are sometimes the ones that decline to act.

Status ​

FeatureStatus
Scan, resolve, classifyComplete
TUIComplete
Plans, quarantine, restoreComplete
Trash and delete modesComplete
Plugin protocol v1Complete
Safety audit of the whole codebaseComplete — 0.2.0
Xcode, simulator and pnpm coverageComplete — 0.3.0
apple/containerComplete — 0.4.0
Size caching (scans take ~1.5 min)Not started
Subdividing the REVIEW tierNot started
Signed / notarised buildsNot started

0.2.0 was an audit of the codebase against its own safety claims: sixteen defects, each with a regression test, and end-to-end coverage of the destructive path in CI. 0.3.0 and 0.4.0 then went after the opposite problem — not wrong answers but unreachable ones, whether invisible to the scan roots or hard-protected out of ever being proposed. The changelog names every one, including the rules that had never fired since the day they were written.

Known limitations are documented rather than glossed over: bleach.emdzej.pl/guide/limitations.