How it works
lu-cleaner does three distinct things, and keeps them strictly apart:
- Scanners look at your disk and describe what they find as items. They never delete anything.
- You choose items, in the interactive picker or with filters on the command line.
- The clean pipeline re-checks each chosen item at the last moment, then deletes it, runs a command, or removes a git worktree, and writes a history line.
scanners ──emit(item)──▶ engine ──stream──▶ picker · scan table · --json │ your selection ▼ clean pipeline ── re-checks ──▶ delete · Trash · command · git worktree remove └──▶ history.jsonl + free space before/afterBecause only one component can remove files, every safety rule lives in one place. A bug in a scanner can at worst propose something it should not. It cannot delete it: the pipeline re-validates every path before touching it. See Safety model.
Scanning
Section titled “Scanning”Scanners run concurrently and stream items
Section titled “Scanners run concurrently and stream items”A scan starts every scanner (internally called a provider) in parallel. Each one publishes items as soon as it finds them, so the picker fills up within seconds while slow measurements continue in the background.
Items are upserted by ID: a scanner first emits an item with its size still unknown (shown as …), then emits it again with the measured size. The UI replaces the old version in place. A scanner that crashes is reported as a warning (warning: provider apple: …) instead of taking the whole program down.
| Scanner ID | Covers | Categories |
|---|---|---|
worktrees |
Git linked worktrees (Codex, Cursor, Conductor, Claude Code, manual…) and stale worktree metadata | worktrees |
artifacts |
Build and dependency outputs inside projects: node_modules, ios/Pods, android/app/build, .expo, dist… |
artifacts |
apple |
DerivedData, archives, DeviceSupport, simulators, runtimes, extra Xcode installs | xcode, simulators |
android |
AVDs, snapshots, SDK packages, Gradle user home, Android Studio, JDKs | android |
ai |
Claude Code, Codex, Cursor, Conductor… sessions by age, orphaned data, old binaries, local models | ai, ide |
js |
Node versions, npx, pnpm and Yarn Berry stores, Expo Go builds, Playwright browsers, temp dirs, stale watchman watches | js |
system |
Trash, Docker and VMs, Homebrew, Go/Rust/Ruby, VS Code, JetBrains, Zed, Downloads, macOS facts | system, containers, langs, ide |
catalog |
About 200 well-known cache, log and tool-data locations | every category except worktrees and artifacts |
The scanner ID is also accepted by --kind: lu-cleaner scan -k apple lists only what the apple scanner found. Only the scanners able to produce the categories you ask for are started, so lu-cleaner scan -c js runs the js and catalog scanners and nothing else. The same applies to disabled_categories (see Configuration): their items are never shown, and scanners that only produce disabled categories are not started.
Catalog entries and dynamic scanners
Section titled “Catalog entries and dynamic scanners”There are two kinds of knowledge in lu-cleaner:
- The catalog is data: a list of well-known paths such as
~/.npm/_cacache,~/Library/Caches/CocoaPodsor$TMPDIR/metro-cache, each with a category, a risk, a cleaning method and a note explaining how the data comes back. Adding a cache that lives at a fixed path is a few lines of Go data, no logic. Browse it in the catalog reference or withlu-cleaner catalog. - Dynamic scanners handle everything that needs judgment: is this node version the default one? Does this worktree hold uncommitted changes? Is this simulator runtime still used by a device? Does this DerivedData folder belong to a worktree that no longer exists? The scanners reference lists every item kind they produce.
Where lu-cleaner looks
Section titled “Where lu-cleaner looks”- Project roots hold your checkouts. They come from the
rootsconfig key, or are auto-detected (~/dev,~/Developer,~/Projects,~/code,~/src,~/conductor/repos…). The artifacts and worktrees scanners walk them, up tomax_depthlevels (8 by default). - Worktree roots are the folders where AI tools create worktrees:
~/.codex/worktrees,~/.cursor/worktrees,~/conductor/workspaces,~/.claude/worktrees,~/.worktrees… plus anyworktree_rootsyou configure. They are also scanned for project artifacts. - Fixed locations in
~/Library, dot-folders of your home and your per-user temporary folder ($TMPDIR), for caches, SDKs, simulators and tool data.
Roots given on the command line (--root <dir>, or lu-cleaner artifacts <dir>) narrow the project artifacts scan to exactly those folders: no worktree roots, no built-in extra folders, and any artifact outside them is dropped. The other scanners keep the configured roots, because they read them to learn what your projects use (Node, Ruby and Android SDK versions, main repositories of worktrees): with --root ~/dev/one-project alone, every version used by your other projects would look unused. / is refused as a root.
Anything whose real location is on another volume (an external SSD behind a symlink, for example) is shown for information only, with the warning on external volume — no internal gain: deleting it would not free space on your internal disk.
Without Full Disk Access, the data of other apps (~/Library/Containers/<app>, ~/Library/Group Containers/<group>) is never read: on macOS 14 and later, the first access would block on a system permission prompt. Catalog entries there are listed as report-only with the warning needs Full Disk Access. See Installation.
Measuring sizes
Section titled “Measuring sizes”Sizes answer one question: how much free space will I get back? That drives several choices.
- Allocated size, not apparent size. lu-cleaner counts the blocks a file really occupies on disk (
st_blocks × 512), likedu. A sparse 64 GB disk image that uses 3 GB counts as 3 GB. - Hardlinks are counted once. A file with several hardlinks inside the same tree is counted once. When some of its links live outside the measured tree, deleting the tree frees nothing for that file. lu-cleaner then reports a smaller really freed size (
reclaimin JSON). Typical case: anode_modulesinstalled by pnpm is hardlinked to the pnpm store. It may measure 1.2 GB while deleting it frees 80 MB. - APFS clones count only their private bytes. A cloned file (bun and pnpm installs,
cp -c, Finder duplicates) shares its data blocks with the original: deleting it frees only the blocks it no longer shares, unless every file sharing that data is inside the deleted tree. The really freed size is an estimate: data shared between two trees measured separately (the bun cache and anode_modulesinstalled from it) comes back only once both are deleted. - Symlinks are never followed and other mounted filesystems are never entered while measuring.
- Units are decimal (1 GB = 1,000,000,000 bytes), like Finder and
df -H.
Two techniques keep a full scan fast:
- On macOS, directories are read with
getattrlistbulk(2), which returns the size, type, inode, link count and modification time of many entries in a single system call, instead of onelstatper file. Walks run in parallel across directories, with a global limit on concurrent readers. - A per-scan shared cache deduplicates work: when two scanners measure the same directory (the worktrees scanner and the artifacts scanner both measure a worktree's
node_modules), the directory is walked once and both get the result. The cache lives only for the duration of one scan, so the next scan always measures fresh data.
What an item tells you
Section titled “What an item tells you”Every line in the picker, the scan table and the JSON output is an item. The picker's details pane (toggled with enter on an item) shows all of its fields.
| Shown as | JSON field | Meaning |
|---|---|---|
| Name | name |
Short human label, e.g. my-app · feature/login or node v18.20.4 (nvm). |
| Path / Location | path, paths, location |
What gets removed. A group item removes several paths at once (Metro / Haste file maps (18)); a command item shows where its command acts. |
| Size | size |
Allocated bytes on disk, hardlinks counted once. |
| Reclaim ("really freed") | reclaim, freed |
What deleting really frees when it is less than the size (data shared through hardlinks or APFS clones). scan marks such sizes with *. |
| Age / last used | last_used, age_days |
Best estimate of the last real activity, not the folder's modification date. |
| Risk | risk |
What you lose: safe, moderate, caution or never. See Risk levels and smart select. |
| Method | method |
How it is cleaned: delete, trash, command, worktree or report. |
| ★ | recommended |
Picked by smart select (the a key, --smart). |
Needs --force |
require_force |
Refused without --force, in a dry run too (orphaned worktree folders). |
| Warning | warn |
Something that deserves attention (dirty worktree, app running, newest version kept…). |
| Note | note |
What the item is and how it comes back after cleaning. |
| Meta | meta |
Scanner-specific details: branch, dirty count, API level, projects using it… |
Last used deserves a word. Folder modification times are unreliable (tools touch them constantly), so each scanner uses the best signal it has:
- project artifacts use the activity of the project (sources, lockfiles, git index), not of
node_modulesitself; - worktrees use git activity (commits, index, HEAD) and the sessions of the agent that created them;
- DerivedData uses Xcode's own
LastAccessedDate; - caches use the newest file written inside them.
This date drives --older-than, the stale marking (yellow age in the picker) and which moderate items smart select recommends.
Report-only items (method report, shown as info in the scan RISK column) cannot be selected. They explain where space goes when lu-cleaner will not remove it itself: Time Machine local snapshots, extra Xcode installs, an Android SDK on an external drive, a Docker disk image. Their note tells you how to reclaim that space by hand.
The clean pipeline
Section titled “The clean pipeline”-
Selection. In the picker nothing is selected at first: you select items (space, or a for smart select) and press d. With
clean --yes, the filters and--smartdecide (see thecleancommand). Nested selections are merged: if you select a worktree and thenode_modulesinside it, only the worktree is processed and nothing is counted twice. -
Confirmation (picker only). A dialog sums up the selection by risk, the method (delete permanently, move to Trash or dry-run), the commands that will run and the apps currently running that would block some items. If the selection holds a
cautionitem (selected, or inside a selected item) or an item that needs--force, you must typeyes. -
Re-checks. Right before acting on an item, the pipeline re-validates it: running apps, the item's own re-check (for example "this simulator is still shut down"), then for every path its inode identity, open SQLite databases, the safety guard, marker files, mounted volumes and processes working inside, and for a worktree the git checks (lock, uncommitted changes, commits on no branch, nested repositories). A failed check skips the item with a reason; it never deletes part of it. Details in Safety model.
-
Execution. File deletions and Trash moves run four at a time. Commands (
xcrun simctl delete …,pnpm store prune…) and git worktree removals run one after the other, each command with a 15-minute timeout. In Trash mode, worktrees and commands are skipped (not possible in Trash mode), since they would delete permanently. Ctrl+C cancels: items not started yet are reported as skipped (cancelled). Withclean --yes, a second Ctrl+C stops lu-cleaner at once, even in the middle of a long deletion. -
Summary. Each item ends as
done,skippedorfailed. lu-cleaner reports two numbers: the estimated space freed (sum of the items' really-freed sizes, including what a failed deletion freed before failing) and the measured space freed (the free space of your home volume before and after, as reported by the filesystem). Moves to the Trash are totalled apart and never counted as freed. When the measured number is much lower,lu-cleaner doctortells you why: local snapshots, the Trash, open files, clones. See Disk space not freed.
With --dry-run (-n), steps 1 to 3 run for real, including every re-check and the git checks of worktree removal, and step 4 only prints what would happen (would delete …, would run: xcrun simctl delete …). A dry run is therefore a faithful preview: an item that would be skipped for a safety reason, or refused for lack of --force, is reported as skipped. Only git's own refusals at removal time cannot be previewed.
History
Section titled “History”Every real clean appends one JSON line per processed item to ~/.local/state/lu-cleaner/history.jsonl ($XDG_STATE_HOME/lu-cleaner/ when that variable is set): time, kind, category, name, path or command (the location and first paths of a group item), method, status, size, the bytes really freed, the error and the message (why an item was skipped, for example). Dry runs are not recorded, and neither are items that had already disappeared.
lu-cleaner history # the last 30 actions, newest first, and the total really freedlu-cleaner history --limit 0 # everythinglu-cleaner history --json # for scriptsThe format is described in JSON output.