Skip to content

How it works

lu-cleaner does three distinct things, and keeps them strictly apart:

  1. Scanners look at your disk and describe what they find as items. They never delete anything.
  2. You choose items, in the interactive picker or with filters on the command line.
  3. 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/after

Because 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.

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.

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/CocoaPods or $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 with lu-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.
  • Project roots hold your checkouts. They come from the roots config key, or are auto-detected (~/dev, ~/Developer, ~/Projects, ~/code, ~/src, ~/conductor/repos…). The artifacts and worktrees scanners walk them, up to max_depth levels (8 by default).
  • Worktree roots are the folders where AI tools create worktrees: ~/.codex/worktrees, ~/.cursor/worktrees, ~/conductor/workspaces, ~/.claude/worktrees, ~/.worktrees… plus any worktree_roots you 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.

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), like du. 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 (reclaim in JSON). Typical case: a node_modules installed 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 a node_modules installed 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 one lstat per 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.

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_modules itself;
  • 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.

  1. 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 --smart decide (see the clean command). Nested selections are merged: if you select a worktree and the node_modules inside it, only the worktree is processed and nothing is counted twice.

  2. 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 caution item (selected, or inside a selected item) or an item that needs --force, you must type yes.

  3. 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.

  4. 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). With clean --yes, a second Ctrl+C stops lu-cleaner at once, even in the middle of a long deletion.

  5. Summary. Each item ends as done, skipped or failed. 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 doctor tells 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.

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.

Terminal window
lu-cleaner history # the last 30 actions, newest first, and the total really freed
lu-cleaner history --limit 0 # everything
lu-cleaner history --json # for scripts

The format is described in JSON output.