bleach
Evidence-based cleanup for orphaned macOS application state
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.
| Class | Example | How it's found |
|---|---|---|
| Orphaned owner — the app is gone | Whisky, 854 MB, untouched 20 months | no inventory source claims it |
| Version orphans — app installed, old version's state abandoned | IntelliJIdea2025.3 beside 2026.2 | version-stripped sibling detection |
| Regenerable caches | Caches/lens-desktop-updater, 1 GB | rules plus an owner-installed check |
| Intra-app dead state | 8 of 54 Claude Code session directories whose projects were deleted | plugins |
| Unreachable by scope — large, and simply not looked at | 8.5 GB under ~/Library/Developer, 5.4 GB of pnpm store | named scan roots, extended |
| Unactionable by protection — seen, but never proposable | 50 GB of apple/container storage behind the com.apple. prefix | delegated 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 pruneSafety 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.
applyrenames 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 usessudo. System state under/Libraryis measured and reported, then left alone — a tool that canrm -rfin/Libraryas root is a categorically different risk. - The plan is a file you review. Scanning emits JSON; you read and edit it;
applyexecutes it and does no analysis of its own. applyre-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.jsonis a file too, sorestorevalidates 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 prunedeletes 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
| Tier | Meaning | Actionable |
|---|---|---|
PROTECTED | System-owned, live process, credentials, iCloud mirrors, or a self-updating app's real binary | no |
CACHE-SAFE | Regenerable; the owner rebuilds it on demand | yes |
ORPHAN? | No inventory source claims it, and it's stale | yes |
REVIEW | Ambiguous, or holds user-data-shaped files | with an explicit flag |
UNKNOWN | Insufficient evidence | no |
Inventory sources
Owner attribution is a union of sources, each weighted by how strongly it implies the owner is currently installed:
| Source | Weight | Note |
|---|---|---|
| Running processes | 1.00 | anything with a live process is untouchable |
| Filesystem walk, Spotlight | 0.95 | ~450 app bundles, effectively instant |
| Homebrew Caskroom / Cellar | 0.90 | often the only name matching a human-named directory |
| launchd agents and daemons | 0.70 | a job pointing at a live binary proves the owner exists |
| LaunchServices registry | 0.60 | most complete, but retains stale entries |
| Installer receipts | 0.25 | survives 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
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:
git clone https://github.com/emdzej/bleach.git && cd bleach
swift build -c releaseUsage
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 daysPlugins
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-sessionsdecodes~/.claude/projectsdirectory 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-sessionssplits~/.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-containerproposes nothing at all, and that is the point of it.containerstorage 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 askscontainer system dfandcontainer ls -ainstead, 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
| Feature | Status |
|---|---|
| Scan, resolve, classify | Complete |
| TUI | Complete |
| Plans, quarantine, restore | Complete |
| Trash and delete modes | Complete |
| Plugin protocol v1 | Complete |
| Safety audit of the whole codebase | Complete — 0.2.0 |
| Xcode, simulator and pnpm coverage | Complete — 0.3.0 |
| apple/container | Complete — 0.4.0 |
| Size caching (scans take ~1.5 min) | Not started |
| Subdividing the REVIEW tier | Not started |
| Signed / notarised builds | Not 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.
