Clean React Native projects
A React Native checkout carries several gigabytes of generated files: node_modules, ios/Pods, the Xcode and Gradle builds, and native C++ intermediates. Multiply that by every project you touched this year and every AI agent worktree, and it adds up fast. The artifacts scanner finds these folders in all your projects, like npkill or kondo, but it knows where React Native, Expo and Xcode put their output. It never touches your sources.
lu-cleaner artifacts # picker over every project and worktreelu-cleaner artifacts ~/dev/my-app # only this folder (no worktree or extra folders)lu-cleaner artifacts --list --all # print the full list insteadlu-cleaner artifacts --older-than 30d # projects untouched for a monthIn the dashboard (lu-cleaner), the same items are in the Project artifacts category.
What it finds in a React Native project
Section titled “What it finds in a React Native project”| Folder | Item kind | Risk | Must sit next to it | Comes back with |
|---|---|---|---|---|
node_modules |
node_modules |
moderate | package.json |
Your package manager's install (see below) |
ios/Pods |
ios-pods |
moderate | Podfile or Podfile.lock |
pod install, npx pod-install or npx expo run:ios (1 to 5 minutes with a warm CocoaPods cache) |
ios/build |
ios-build |
safe, moderate with codegen | *.xcodeproj, *.xcworkspace or Podfile |
The next Xcode or npx expo run:ios build |
android/app/build, android/build |
android-build |
safe | build.gradle or build.gradle.kts |
The next Gradle or npx expo run:android build (3 to 10 minutes) |
android/.gradle |
android-gradle |
safe | settings.gradle, gradlew or build.gradle |
The next Gradle run |
android/.kotlin |
android-kotlin |
safe | Same as above | The next Gradle build |
.cxx, .externalNativeBuild |
android-cxx |
safe | CMakeLists.txt or build.gradle |
The next native build (several minutes of C++). Deleting it also fixes stale .cxx build errors. |
.expo |
expo |
safe | package.json, app.json or app.config.* |
expo start or expo run:* |
.metro-cache, .metro |
metro-cache |
safe | package.json or metro.config.* |
Nothing to do: the first bundle is just slower |
.next |
next |
safe | next.config.* or package.json |
next dev or next build |
.turbo |
turbo |
safe | package.json or turbo.json |
Nothing to do: tasks run uncached once |
dist, build, web-build of a JS package |
dist, js-build, web-build |
safe, moderate for library outputs | package.json |
The project's build script |
.yarn/cache, .yarn/unplugged |
yarn-cache, yarn-unplugged |
moderate | .yarnrc.yml |
yarn install |
coverage |
coverage |
safe | package.json |
The next coverage run |
playwright-report, test-results |
playwright-report |
moderate | playwright.config.* or package.json |
The next test run (traces and videos of past runs are lost) |
vendor/bundle |
ruby-vendor-bundle |
moderate | Gemfile |
bundle install |
The scanner also knows artifacts of other stacks, such as Flutter, Rust, Swift Package Manager, Python virtualenvs, CMake and .NET. See the scanners reference. Use the item kind with --target/-t on artifacts, or with --kind/-k anywhere:
lu-cleaner artifacts -t node_modules -t ios-pods-t only accepts artifact kinds and a few aliases (pods, pod, cocoapods, node-modules): a typo is a usage error, not an empty scan.
How it decides that a folder is disposable
Section titled “How it decides that a folder is disposable”A folder named build can be Xcode output, or a folder of source code that someone named build. lu-cleaner only proposes a folder when three checks pass. Each check exists to rule out a specific mistake.
| Check | Example | Why |
|---|---|---|
| A marker file sits next to it | package.json next to node_modules, a Podfile next to Pods, a build.gradle next to android/app/build |
Proves the folder belongs to a project of that type. The marker is checked again right before deletion. If it has disappeared, the item is skipped. |
| It holds no file tracked by git | Committed ios/Pods, a vendored node_modules, a Yarn zero-install cache |
Someone committed these files on purpose. Deleting them would change the repository, not free a cache. |
| Generic names must be git-ignored | build, dist, out, coverage, .yarn/*, vendor/bundle |
A project that ignores the folder declares it disposable. Unambiguous build output also qualifies, such as Xcode's XCBuildData or a CACHEDIR.TAG file, as long as the folder is untracked. Outside a git repository, generic folders must contain recognizable build output. |
Outside git, a JS output folder (build, dist, out, web-build) that only holds what hand-written files also look like (.js files, index.html, assets), with nothing a bundler alone writes (source maps, hashed bundle names such as main.3f2a1b9c.js, asset-manifest.json, _next…), is only proposed as caution. It carries the warning not under git: nothing proves it is build output — check before deleting and is never recommended.
The scanner also has hard limits:
- A folder that holds a
.gitentry of any kind (a repository, a linked worktree such as agh-pagesbranch checked out asdist, a submodule) or a bare repository is a checkout, never an artifact. A generic output with a checkout somewhere inside is report-only:contains a git repository (…) — not proposed. - It never follows symlinks, and it never walks into another volume from a root.
- It never walks into an artifact, a
.gitfolder,~/Library, the Trash, or app bundles such as.appand.xcodeproj. - It skips hidden folders, except
.claude/worktreesand.worktrees, where agents keep their checkouts. - An artifact on an external volume is listed but cannot be selected, because deleting it frees nothing on your internal disk.
- Right before deletion, a folder that became a checkout since the scan (
git init,git cloneorgit worktree addinto it) is left alone.
Some folders match a rule's name and markers but hold tracked files, such as committed ios/Pods. They are listed as report-only with a reason, for example not proposed: contains files tracked by git (ios/Pods/Manifest.lock). They have no size, so to see them, open the picker with lu-cleaner artifacts --min-size 0 and press H.
See the safety model for the checks that apply to every deletion.
ios/build and React Native codegen
Section titled “ios/build and React Native codegen”With the New Architecture, React Native codegen can write pod specs to ios/build/generated/ios, and Podfile.lock then points to them. When lu-cleaner finds build/generated/ios in Podfile.lock, it raises ios/build to moderate and adds a codegen flag. The note tells you what to do after deleting it: run pod install, then build again. If you rebuild without pod install, CocoaPods cannot find the generated specs.
Library outputs
Section titled “Library outputs”In a monorepo, a shared package often points its package.json (main, module, types, exports, bin or files) into its own dist folder. Apps and Expo config plugins that use that package break until it is built again. lu-cleaner detects this, raises the folder to moderate and tags it library_output. After deleting it, run the package's build or prepare script.
Warnings that keep an item out of smart select
Section titled “Warnings that keep an item out of smart select”| Warning | Meaning |
|---|---|
in use: process 4242 runs inside the project (dev server, agent, shell?) |
Something runs in the project, such as Metro or an agent. Its dependencies could disappear under it. |
xcodebuild is running — quit it before cleaning |
ios/Pods, ios/build and DerivedData are skipped while xcodebuild runs. |
not under git: nothing proves it is build output — check before deleting |
A generic JS output outside git with only weak evidence (see above). |
contains built app binaries (app-release.apk…) |
A build folder directly holds .apk, .aab or .ipa files. Keep it if you still need that build. |
Items with a warning can still be selected by hand. --yes holds them back unless you pass --risk caution.
Monorepos
Section titled “Monorepos”The scanner walks the whole repository. It does not stop at the first package.json, so it finds every workspace package's own artifacts. Items are named <repository> › <path inside it>, and the project type (expo, react-native, next…) comes from the package that owns the folder:
Directorymy-monorepo/
- pnpm-lock.yaml
- package.json
Directorynode_modules/ my-monorepo › node_modules
- …
Directoryapps/
Directorymobile/
- package.json
Directorynode_modules/ reinstall with pnpm i at the workspace root
- …
Directoryios/
- Podfile
DirectoryPods/ my-monorepo › apps/mobile/ios/Pods
- …
Directorybuild/ Xcode output, moderate if codegen specs live here
- …
Directoryandroid/
Directory.gradle/
- …
Directoryapp/
- build.gradle
Directorybuild/ Gradle output
- …
Directory.cxx/ native C++ intermediates
- …
Directorypackages/
Directoryui/
- package.json main points to dist/index.js
Directorydist/ library output, moderate
- …
Each node_modules is a separate item. Its note gives the command to reinstall it and where to run it: lu-cleaner looks for the lockfile from the package folder up to the repository root. In the tree above, the note reads reinstall with pnpm i at the workspace root.
Hardlinks, clones and the "really freed" figure
Section titled “Hardlinks, clones and the "really freed" figure”pnpm, bun and Yarn Berry can install node_modules as links into a shared store, or as APFS clones of it (copy-on-write copies that share disk blocks until one side changes; bun does this by default on macOS), instead of real copies. Deleting such a node_modules only frees the data that exists nowhere else. lu-cleaner measures both mechanisms and shows two figures:
- Size is the allocated size, with each hardlinked file counted once.
- Reclaim is what deleting the folder really frees. It leaves out files that also have links outside the folder, such as in the pnpm store, and counts a clone only for its private blocks, unless every copy of its data is inside the folder.
When the two differ:
- the
scantable marks the size with*; - the picker's details show
only 120 MB really freed (of 1.40 GB): shared with other files (hardlinks or APFS clones); - the item note names the store, for example
Shared with other files (hardlinks or APFS clones of the pnpm store): only 120 MB is really freed; - the JSON output has
reclaimandfreedfields (JSON output).
The totals and the plan use the reclaim figure.
The reverse is also true: while projects still link to the global store, deleting the store frees little. See JS toolchain.
Choose by age
Section titled “Choose by age”An artifact's age is the age of its project, not of the folder itself. Installs and builds touch the artifact folder all the time, so its own date says nothing about whether you still work on the project. lu-cleaner takes the most recent modification time of these, and ignores the artifact folders themselves:
- the project's top-level files (lockfiles, configs…);
- the top-level files of the package that owns the artifact;
- the git index,
HEADand reflog; - every file with uncommitted changes, and every untracked file, anywhere in the work tree: editing
src/components/Button.tsxcounts even before you commit it.
Folder dates are ignored: deleting android/.gradle changes the date of android/, and would otherwise make the project look active right after a clean. Outside a git repository, lu-cleaner looks for the newest source file in a bounded walk of the project instead.
--older-than accepts h, d, w, m (30 days) and y:
# Picker limited to projects untouched for 30 dayslu-cleaner artifacts --older-than 30d
# Table of everything in projects idle for 3 monthslu-cleaner artifacts --list --all --older-than 3m
# Preview: node_modules of projects idle for 60 dayslu-cleaner artifacts -y -t node_modules --older-than 60d -n
# Preview: safe build outputs of projects idle for 2 weekslu-cleaner clean -y -c artifacts --risk safe --older-than 2w -nItems whose age is unknown never match --older-than.
Smart select (the a key, or --smart) applies these rules to artifacts:
- It never picks anything from a project that was active in the last 24 hours.
- It picks safe outputs (Xcode and Gradle builds,
.expo,.cxx…) once the project has been idle for 1 day. - It picks moderate items (
node_modules,Pods…) once the project has been idle for longer thanstale_after, which is 14 days by default. - It picks a Yarn Berry
.yarn/cachethat Yarn no longer reads (.yarnrc.ymlsetsenableGlobalCache: trueas a top-level key) without waiting forstale_after. - It never picks items with a warning or of caution risk.
Rebuild when you need the project again
Section titled “Rebuild when you need the project again”For node_modules, the item note gives the command that matches your lockfile:
| lu-cleaner finds (from the package up to the repository root) | Command in the note |
|---|---|
bun.lock or bun.lockb |
bun i |
pnpm-lock.yaml |
pnpm i |
yarn.lock (with .yarnrc.yml: Yarn Berry) |
yarn |
package-lock.json or npm-shrinkwrap.json |
npm ci |
| No lockfile | From the packageManager field of package.json; npm install if none |
A typical React Native project comes back with:
cd ~/dev/my-apppnpm i # node_modules: use the command from the notecd ios && pod install && cd .. # ios/Pods (or: npx pod-install)npx expo run:ios # rebuilds ios/build (or: npx react-native run-ios)npx expo run:android # rebuilds android/app/build, .gradle and .cxxlu-cleaner has no rule for the ios/ and android/ folders themselves. With Expo's Continuous Native Generation, those folders are git-ignored. One of them only shows up if it holds more than 200 MB beyond its known artifacts, and then as a caution ignored-dir item (next section). npx expo prebuild generates both folders again.
Heavy git-ignored folders (ignored-dir)
Section titled “Heavy git-ignored folders (ignored-dir)”Projects also accumulate large folders that no rule knows, and AI agents make this worse: QA screenshots and recordings, staging copies, scratch exports. When a git-ignored folder holds more than 200 MB besides the known artifacts inside it, lu-cleaner lists it as my-app › tmp-recordings (git-ignored), with kind ignored-dir:
- The risk is caution, because lu-cleaner cannot tell what the folder contains. It is never smart-selected.
clean --yesleaves it out unless you pass--risk caution, and the picker asks you to typeyes. - Its age is the date of the last file written inside, not the project's activity.
- If it contains a git checkout or a worktree, it is report-only.
- Tool and editor folders are never reported:
.git,.claude,.cursor,.codex,.conductor,.vscode,.idea,.env,worktreesand similar. - Its size includes the known artifacts found inside it. Those artifacts are also listed as items of their own.
Open it with o (reveal in Finder) before you decide.
Where the scanner looks
Section titled “Where the scanner looks”| Source | What it adds |
|---|---|
| Project roots | roots in the configuration, or, when empty, existing folders among ~/dev, ~/Developer, ~/Projects, ~/code, ~/src, ~/repos, ~/conductor/repos and others. |
| Worktree folders | ~/.codex/worktrees, ~/.cursor/worktrees, ~/conductor/workspaces, ~/.claude/worktrees and the others listed in AI agent worktrees, plus worktree_roots |
| Extra folders | ~/Documents/Codex, ~/conductor/archived-contexts and ~/.gemini/antigravity/scratch, where tools copy projects. Only dependency and build rules apply there. |
Roots given on the command line (the arguments of lu-cleaner artifacts, or --root) replace all three sources for one run: only those folders are scanned, and artifacts outside them are dropped. / is refused as a root. They narrow the artifacts scan only: the other scanners keep your configured roots to learn which Node, Gradle or NDK versions your projects use.
The walk goes down to max_depth levels, 8 by default, below each root. Each linked worktree it meets gets the full depth again. Artifacts inside iCloud-synced Desktop and Documents get a note, because deleting them also removes them from iCloud.
To add your own artifact names, use extra_artifacts in the config. Inside a git repository, these folders must be git-ignored:
extra_artifacts = ["tmp-build", "e2e-artifacts"]Related
Section titled “Related”- AI agent worktrees: remove whole worktrees instead of their artifacts
- iOS simulators & Xcode: DerivedData, device support, simulators
- Android: Gradle caches, emulators, SDK packages
lu-cleaner artifactsreference