Contributing
Contributions are welcome, especially new cache locations, better detection of what is safe to remove, and bug reports with a (redacted) lu-cleaner scan output. lu-cleaner deletes files on people's machines, so every change that can lead to a deletion gets a safety review.
Set up
Section titled “Set up”You need macOS, Go 1.25 or newer, and git. Node.js 22 is only needed for the documentation site.
git clone https://github.com/ludwig-pro/lu-cleanercd lu-cleanermake build # bin/lu-cleaner./bin/lu-cleaner scan --summary # read-only./bin/lu-cleaner clean --yes --smart --dry-runStick to scan, --dry-run and the picker's dry-run mode while you develop. make run ARGS="scan -c js --summary" builds and runs in one step.
Make targets
Section titled “Make targets”| Target | What it does |
|---|---|
make build |
Compile bin/lu-cleaner (version from git describe) |
make install |
Install into $(go env GOPATH)/bin |
make test |
go test ./... |
make vet |
go vet ./... |
make fmt |
gofmt -w . |
make lint |
go vet plus a gofmt check |
make run ARGS="…" |
Build, then run with the given arguments |
make clean |
Remove bin/ |
make docs |
Regenerate the reference pages of the site from the CLI |
make site-dev |
make docs, then the site on http://localhost:4321/lu-cleaner/ |
make site-build |
make docs, then build the site into site/dist |
Architecture tour
Section titled “Architecture tour”- cmd/lu-cleaner/main.go entry point, version set with
-ldflags Directoryinternal/
Directorycore/ data model:
Item,Risk,Method,Category,Provider,Env,Filter,Recommend,TopLevel- …
Directoryfsx/ concurrent directory sizing (allocated blocks, hardlink and APFS clone aware,
getattrlistbulk), protection of other apps' containers, size and age parsing- …
Directorysysx/ free disk space, running processes, working directories of processes, APFS local snapshots
- …
Directorysafety/ the
Guard: last line of defence before any removal- …
Directoryconfig/
~/.config/lu-cleaner/config.toml- …
Directoryengine/ runs the scanners concurrently and streams item upserts
- …
Directoryclean/ executor: re-checks, delete, Trash, commands,
git worktree remove, history- …
Directoryproviders/
- registry.go the list of scanners
Directorycatalog/ data-driven well-known paths, one
data_*.gofile per domain- …
Directoryworktrees/ git worktrees of AI tools and humans
- …
Directoryartifacts/ project build and dependency outputs
- …
Directoryapple/ Xcode and iOS simulators
- …
Directoryandroid/ AVDs, SDK packages, Gradle, Android Studio, JDKs
- …
Directoryaitools/ AI coding tools data
- …
Directoryjsdev/ node versions, package-manager stores, JS tool leftovers
- …
Directorysystem/ Trash, containers, Homebrew and toolchains, editors, Downloads, macOS facts
- …
Directorytui/ Bubble Tea: the picker and the analyzer
- …
Directorycli/ Cobra commands, output tables, JSON,
gen-docs- …
- docs/ARCHITECTURE.md
Directorysite/ this documentation (Astro Starlight)
- …
The flow is one-directional:
providers ──emit(*Item) upsert──▶ engine.Run ──▶ TUI picker / CLI table / JSON │ user selection ▼ clean.Run(items) ── Guard.Check each target ──▶ rm / Trash / command / git worktree remove └─▶ history.jsonl + statfs before/afterThe contracts every change must keep:
- A provider never deletes anything. It only emits
*core.Itemvalues. All removals go throughclean.Run. - Emitting the same
Item.IDagain replaces the previous value. Use it to show an item withSizing: truefirst, then its measured version. Never mutate an item after emitting it: emitit.Clone()if you keep working on it. Sizeis the allocated size (st_blocks × 512, hardlinks counted once). SetReclaim(throughSetReclaim) when deleting frees less because the data is shared with files outside the item: hardlinks (pnpm store) and APFS clones (bun and pnpm installs,cp -c).fsx.Sizecomputes both.LastUsedis the best signal of real activity, not a folder's modification time. It drives ages,--older-thanand smart select.WarnandNoRecommendkeep an item out of smart select.NoRecommendis a veto for what must only be cleaned on explicit selection (irreversible, or of uncertain origin). An item with aWarnis also held back byclean --yesunless--risk caution.RequireForcemakes the executor refuse the item without--force, in a dry run too (orphaned worktree folders).- Every target is re-validated by
safety.Guardright before removal, independently of the scanner that produced it. It refuses any directory holding a.gitentry (directory, file or symlink) or laid out as a bare repository, unless the item setsAllowGitRepo. - Never read below another app's container without Full Disk Access. On macOS 14 and later, the first
open()orstat()inside~/Library/Containers/<app>or~/Library/Group Containers/<group>blocks on a permission prompt. Checkfsx.AppDataProtected(orfsx.GlobPrefixProtectedfor a glob) first;fsx.Sizealready returnsfsx.ErrNeedsFullDiskAccessthere. - Compare paths with
safety.Key(case- and Unicode-normalization-insensitive, like APFS), never with plain strings. - Human output never prints a raw control character: names, paths and messages go through
sanitize(CLI) orsafeText(TUI), which turn control characters and bidi overrides into visible escapes.
internal/cli receives all of its dependencies through cli.App (providers, config, cleaner, TUI, system queries), so its tests run the real commands against fakes. The How it works and Safety model pages describe the behavior in more detail.
Add a catalog entry
Section titled “Add a catalog entry”Most new cache locations are a few lines of data. Use the catalog when the location is fixed (a glob under ~/ or $TMPDIR/) and deciding whether to propose it needs no logic beyond age, size, a running app or "keep the newest N".
-
Pick the data file of the domain in
internal/providers/catalog/:data_apple.go,data_androiddev.go,data_jsdev.go,data_ai.goordata_system.go. Check the comment at the top of the file: some locations are deliberately owned by a dynamic scanner and must not be added to the catalog. -
Add an
Entryto theadd(...)call:Entry{ID: "js-example-tool-cache", Category: core.CatJS, Name: "Example tool cache",Paths: []string{"~/Library/Caches/example-tool"}, Risk: core.RiskSafe,Note: "Downloads cached by example-tool; fetched again on its next run.",},Entry{ID: "js-example-tool-versions", Category: core.CatJS, Name: "Example tool versions",Paths: []string{"~/.example-tool/versions/*"}, Risk: core.RiskModerate,Mode: Each, KeepLatest: 1,Note: "Old versions of example-tool; the newest is kept and `example-tool install` downloads others again.",}, -
Run the catalog tests, then try the entry on your machine:
Terminal window go test ./internal/providers/catalog/make build./bin/lu-cleaner catalog -k js-example-tool./bin/lu-cleaner scan -k js-example-tool-cache -
Regenerate the reference pages with
make docsand commit the updatedsite/src/content/docs/reference/catalog.md. CI fails when it is out of date.
Entry fields
Section titled “Entry fields”| Field | Required | Meaning |
|---|---|---|
ID |
yes | Unique, kebab-case; becomes the item kind (--kind). Prefix it with the domain (js-, ai-, xcode-…) |
Category |
yes | One of the core.Cat* categories |
Name |
yes | Human label |
Paths |
yes | Globs starting with ~/ or $TMPDIR/, filepath.Glob syntax (*, ?, […]), no **. Without Full Disk Access, a path inside another app's container becomes a report-only item with a warning |
Note |
yes | One sentence: what it is, and how it comes back after cleaning |
Risk |
yes | core.RiskSafe, RiskModerate, RiskCaution or RiskNever. The zero value is RiskSafe: choose deliberately |
Method |
no | Default MethodDelete. MethodCommand needs Command; MethodReport never cleans; MethodWorktree is not allowed here |
Command, Requires |
no | Argv of the cleanup command; binary that must be on PATH for the entry to be evaluated at all |
Exclude |
no | Base-name globs of matches to drop |
Mode |
no | Group (default: all matches form one item) or Each (one item per match) |
KeepLatest |
no | With Mode: Each, keep the N most recently modified matches out of the list (raised to the keep_latest setting when that is higher) |
OlderThan |
no | Only matches whose modification time is older than this |
ProcessGuard |
no | Processes that must not run while it is cleaned (exact executable name, or a path substring containing /) |
MinBytes |
no | Below this size the item is not selectable (and hidden in Each mode) |
Files |
no | Matches are files, not directories |
AllowGitRepo |
no | Allow a match that is a git repository (rare: a cloned spec repository) |
Recommended |
no | Force smart select for this entry, whatever its size or age (a warning still blocks it) |
What the tests check
Section titled “What the tests check”TestEntriesAreValid runs over every entry and fails on a non-kebab-case or duplicate ID, an unknown category, a missing name or note, missing paths, a command method without a command, a worktree method, a path that does not start with ~/ or $TMPDIR/, a ** or an invalid glob, a plain (non-glob) path the safety guard protects, or KeepLatest without Mode: Each.
TestDataFilesAreCompiled protects against a trap of Go's build system. A file name ending in _<GOOS>.go or _<GOARCH>.go is an implicit build constraint: data_js.go would only be compiled for GOOS=js, data_android.go only for Android, data_ios.go only for iOS. The file would be silently ignored on macOS, and its entries would vanish without any error. That is why the JS and Android files are named data_jsdev.go and data_androiddev.go. The test fails if any data_*.go file is excluded from darwin builds; if you create a new data file, give it a name that is not a GOOS or GOARCH value.
Add a scanner
Section titled “Add a scanner”Write a scanner when deciding needs logic: which version is in use, whether a folder still belongs to something alive, what a tool's own state says. A fixed path belongs in the catalog.
-
Create a package in
internal/providers/<name>/implementingcore.Provider:package exampleimport ("context""path/filepath""github.com/ludwig-pro/lu-cleaner/internal/core""github.com/ludwig-pro/lu-cleaner/internal/fsx")type Provider struct{}func New() *Provider { return &Provider{} }func (p *Provider) ID() string { return "example" }func (p *Provider) Title() string { return "Example tool" }func (p *Provider) Categories() []core.Category { return []core.Category{core.CatJS} }// Scan must honour ctx and never modify anything.func (p *Provider) Scan(ctx context.Context, env *core.Env, emit core.Emit) error {dir := filepath.Join(env.Home, ".example-tool", "state")if !fsx.IsDir(dir) || env.Excluded(dir) || env.IsProtected(dir) {return nil}it := &core.Item{ID: "example:example-state:" + dir, Category: core.CatJS,Kind: "example-state", Name: "Example tool state", Path: dir,Risk: core.RiskModerate, Method: core.MethodDelete, Selectable: true,Note: "State of example-tool; rebuilt on its next run (slower first start).",Sizing: true,}emit(it.Clone()) // placeholder, shown right awayst, _ := fsx.Size(ctx, dir, nil)if ctx.Err() != nil {return ctx.Err()}it.Sizing = falseit.Size, it.Files, it.LastUsed = st.Bytes, st.Files, st.Newestit.SetReclaim(st.Reclaim)emit(it) // same ID: replaces the placeholderreturn nil} -
Register it in
internal/providers/registry.go. The order ofAll()is the display priority. -
Test it with fixtures built in
t.TempDir()and a fakecore.Runnerfor external commands (git,xcrun…), so tests never depend on the machine. Add areal_test.gogated behindLU_REAL=1that scans the real machine read-only and prints what it finds. -
Document its kinds in
site/src/content/docs/reference/scanners.md: what each kind is, its risk, its method and when it is recommended.
Guidelines for scanners:
- Run external commands through
env.Output(theRunner), with a timeout, and only read-only ones:brew cleanup -n,watchman --no-spawn,sqlite3 -readonlyon an immutable URI.env.Runneralready disables git's optional locks. - Measure with
fsx.Sizewithout options: the engine gives every scan a shared cache, so a folder measured by two scanners is walked once. - Consult
env.IsProtectedandenv.Excludedbefore proposing a path. Report items living on another volume withMethodReportinstead of proposing them. - Set
Warnfor anything a human should look at first (an app running, a process inside, a version pinned by a project). A warning keeps the item out of smart select, and out ofclean --yeswithout--risk caution. - Set
NoRecommendwhen the item must never be recommended whatever its age: data that does not come back, or whose owner may still want it. SettingRecommended = falseis not a veto. - Check
fsx.AppDataProtectedbefore touching anything under~/Library/Containersor~/Library/Group Containers, and report such items instead of reading them. - Use
ProcessGuard,RequireSiblingandRecheckto express conditions the executor must re-verify at clean time.Recheckmust fail closed. - Prefer the owning tool's cleanup command (
MethodCommand) when the tool keeps its own bookkeeping. - A panic inside
Scanis caught and shown as a scanner error, but not in goroutines the scanner starts itself. Return errors instead of panicking, and stop quickly whenctxis cancelled.
make test # everythinggo test ./internal/safety/ # one packagego test ./internal/cli/ -run TestCleanYes -v # tests matching a namemake lint # vet + gofmt checkReal-machine tests scan your own Mac, read-only, and print what they find. They are skipped unless you opt in:
LU_REAL=1 go test ./internal/providers/apple -run TestRealMachine -vLU_REAL=1 go test ./internal/providers/worktrees -run TestRealMachine -vCI runs on macOS: gofmt, go vet, go test ./..., make build, and make docs followed by a check that the generated reference pages are committed.
Reference pages and the site
Section titled “Reference pages and the site”The documentation lives in site/ (Astro Starlight). English pages are in site/src/content/docs/, French ones in site/src/content/docs/fr/.
Two kinds of pages are generated and must never be edited by hand:
reference/commands/*.md, one per command, from the Cobra definitions (Short,Long,Example, flags);reference/catalog.md, from the catalog entries.
To change them, edit the command's help text or the catalog entry, then run:
make docs # go run ./cmd/lu-cleaner gen-docs --out site/src/content/docs/referenceTo work on the site:
cd site && npm install && npm run dev # http://localhost:4321/lu-cleaner/make site-dev does the same after regenerating the reference pages. Internal links must be absolute, include the /lu-cleaner/ base and end with a slash (/lu-cleaner/concepts/safety/); the build validates them. Every page needs a title, a one-sentence description (also used for llms.txt) and a sidebar.order.
Safety review
Section titled “Safety review”Any pull request that adds or changes something that can be deleted is reviewed against these questions. Answer them in the description.
- What exactly is proposed, and how does it come back? The note must say it. If it does not come back (user files, recordings, sessions), the risk is caution at least.
- Is the risk the lowest honest level? safe means a pure cache: no user data, regenerated automatically. When in doubt, go one level up.
- What if it is in use? Declare a
ProcessGuard, a warning, or aRecheckfor state that can change between scan and clean. Checks fail closed. - Could the path be something else? Generic names need markers, content signatures, or git-ignore checks. Glob patterns must not match more than intended.
- Is the scanner read-only? No writes and no commands with side effects at scan time.
- Is smart select still conservative? Forcing
Recommendedneeds a reason stronger than size. Irreversible or uncertain data getsNoRecommend. - Could the scan block on a macOS prompt? Nothing below another app's container is read without Full Disk Access.
- Does the guard still hold? Changes to
internal/safety/guard.goneed tests ininternal/safety/. Adding a protected path is always welcome; removing one needs a very good reason.
Test your change with --dry-run on your own machine, and paste the relevant part of scan or clean --dry-run output in the pull request, with your own paths and names redacted (~/dev/my-app, feature/login).