repo-tidy · git:20261101.4b341c0 · 2026-11-01 · sha256 c1a9e6a060de8204
repo-tidy git:20261101.4b341c0A
Immutable. This exact content never changes and is served at /api/v1/blob/c1a9e6a060de8204.
---
name: repo-tidy
description: "Tidy up the repository — build artifacts, caches, temp files, empty dirs"
metadata:
domain: "repo"
---
<!-- repo-skills:codex-managed -->
<!-- Generated by Repo Skills install.sh from commands/repo/tidy.md. Edit the source, not this file. -->
> Codex form of the `/repo:tidy` command. Other commands appear as `$repo-<verb>` skills.
> Helper scripts and `.claude/` paths below are relative to the repository root and exist
> when the Claude Code surface is installed alongside this one.
# $repo-tidy — Tidy Up
Sweep the working tree for clutter and clean it up. Inventory everything,
categorize by confidence, then by default delete the SAFE category (pure junk
that holds no unique work) and report what was freed. Regenerable **caches**
(compilation/tool/build output) are kept by default and only cleared when you
pass `--caches` — deleting them is harmless but forces a costly rebuild, so it
is opt-in. Items that could be real work (ASK) are always presented for a human
call, never auto-deleted.
## Usage
```
$repo-tidy # Inventory, delete SAFE junk (caches kept), report; ASK items presented
$repo-tidy --caches # Also clear regenerable caches (__pycache__/, dist/, .mypy_cache/, …)
$repo-tidy --ask # Walk every category interactively before deleting anything
$repo-tidy --sizes # Also measure worktree root sizes (slow: du has no prune)
$repo-tidy --fast # Skip the reference scans entirely; unscanned candidates land in ASK, not SAFE
$repo-tidy --deep # One reference scan per candidate instead of one batched pass (audit only)
$repo-tidy packages/core # Scope to one subtree
```
(`--apply` is accepted as a synonym for the default, for muscle memory. `--caches`
composes with `--ask`: `--ask` still walks every category, `--caches` just moves
the cache tier into the auto-delete set for the non-interactive default.
`--sizes` only adds the per-worktree size column described in step 1 — it never
changes what is deleted.)
**`--fast` / `--deep` are the two ends of the reference-scan cost dial, and
neither is the default.** The default is **one batched reference pass** over the
whole candidate set, described in step 2 — that is what every run does unless
one of these flags is passed:
- **`--fast`** skips the reference scans entirely. Candidates that would have
been scanned are still inventoried and reported, but they are routed to
**ASK** rather than SAFE/CACHE, because a check that was not run is not a
check that passed (safety rule 11).
- **`--deep`** restores the pre-batching behaviour: one scan per candidate.
It exists to **audit** the batched pass, not to get a different answer —
batching is a traversal optimization, so if `--deep` and the default ever
disagree, that is a bug in the batched pass and not a feature of `--deep`.
- They are mutually exclusive. If both are passed, `--fast` wins: skipping is
both the cheaper option and the one that cannot delete more than the default
would.
## Steps
### 1. Inventory
Gather candidates without deleting anything:
```bash
# Ignored files that exist on disk (usually build output/caches).
#
# STREAM this and bucket as you go — never collect the raw output. Every line
# whose path is at or under one of the CACHE-tier directory names below folds
# into a SINGLE entry for that directory; the per-file lines are discarded
# without being categorized individually. On a repo whose multi-gigabyte
# regenerable tree is ignored file-by-file (rather than by a directory-level
# rule git can collapse), that is the difference between one CACHE line and
# hundreds of thousands of them. Bound it like the `--sizes` du below: a walk
# that exceeds the budget must degrade the ignored-file inventory to partial
# (say so in the report) rather than stall the whole step.
timeout 120 git clean -ndX # bucket-as-you-stream; see the CACHE-tree note below
# The CACHE-tier trees, at DIRECTORY granularity — the cheap path that makes the
# bucketing above complete without anyone enumerating their contents. `-prune`
# stops the walk AT the match, so a 3 GB dist/ costs one stat, not a descent.
find . \( -name .git -o -name node_modules -o -name .venv \) -prune \
-o -type d \( -name dist -o -name .turbo -o -name .astro \
-o -name __pycache__ -o -name .pytest_cache \
-o -name .mypy_cache -o -name .ruff_cache \
-o -name htmlcov \) -prune -print
# Untracked files (may include work-in-progress — treat carefully)
git clean -nd
# Empty directories
find . \( -name .git -o -name node_modules -o -name target \
-o -name dist -o -name .venv \) -prune \
-o -type d -empty -print
# Large files in the working tree (>10 MB, tracked or not)
find . \( -name .git -o -name node_modules -o -name target \
-o -name dist -o -name .venv \) -prune \
-o -type f -size +10M -print
# Orphaned environment content — packages still on disk that the current
# lockfile no longer references. Detect the lockfile and its package manager;
# the prune itself is an ASK item (see Categorize), never run from here.
[ -f pnpm-lock.yaml ] && echo "prunable: pnpm" # pnpm prune
[ -f package-lock.json ] && echo "prunable: npm" # npm prune
# Git worktree roots — authoritative and tool-agnostic. RETAIN this output as a
# path set for step 2's denylist; do not just print it. `--porcelain` gives one
# `worktree <path>` line per entry, including worktrees that live outside this
# repo root (`../repo-wt-fix123`, `/private/tmp/…`, `/private/var/folders/…`),
# which `git clean` never sees.
git worktree list --porcelain
# The worktree paths alone, for step 3's WORKTREES block. Extract them with
# `sed`, not `awk '{print $2}'`: awk splits on whitespace and would truncate
# `/repos/my checkout/wt` to `/repos/my`. The `worktree ` prefix is fixed, so
# stripping it with sed and reading whole lines keeps paths with spaces intact
# (paths containing newlines need `git worktree list --porcelain -z`).
git worktree list --porcelain | sed -n 's/^worktree //p'
# Worktree SIZES ARE NOT COLLECTED BY DEFAULT — only when `--sizes` is passed.
# See below for why. When it is passed, bound each root so a slow one degrades
# the size column instead of stalling the step:
git worktree list --porcelain | sed -n 's/^worktree //p' \
| while IFS= read -r wt; do
timeout 20 du -sh "$wt" 2>/dev/null \
|| printf '%s\t%s\n' 'size unavailable' "$wt"
done
```
**The CACHE-tier trees are excluded from the ignored-file listing, because the
directory-level `find` above already reports them.** `git clean -ndX` is the
expensive half of this step: it visits every ignored tree, and a multi-gigabyte
regenerable tree that is ignored file-by-file (rather than by a directory-level
rule git can collapse to one line) is enumerated in full — the walk that blew
the five-minute command timeout in the reported case. The seven CACHE-tier trees
step 2 names — `dist`, `.turbo`, `.astro`, `__pycache__`, `.pytest_cache`,
`.mypy_cache`, `.ruff_cache` (plus `htmlcov`) — are the ones worth excluding,
because they are re-entered for no new information: the CACHE tier already
treats each of them as a **whole directory** in both the report
(`dist/ (gitignored, 380 MB)`) and the apply step (`git clean -fdX -- dist/`),
so their contents never appear individually in any output. The exclusion
therefore changes **how** a CACHE tree is discovered, never **whether** it is
reported or what tier it lands in — the same invariant the prune list below
states for the `find` walks.
The mechanism is **bucket-as-you-stream plus the directory-level `find`**, and
it is that way because the obvious alternatives are verified not to work:
- **Pathspec exclude magic does not apply.** `git clean -ndX -- .
':(exclude)dist'` (and `':!dist/'`, and `':(exclude,glob)**/dist/**'`) still
prints `Would remove dist/` — checked on git 2.55.0. `git clean` collapses a
wholly-ignored directory to a single entry and matches it by leading
directory, which exclude magic does not suppress. Do not "fix" this step by
adding one; it reads as an exclusion and silently is not one.
- **`-e <pattern>` is the wrong direction.** `git clean -e` *adds* a pattern to
the ignore rules in effect, so under `-X` it makes **more** paths eligible for
removal, not fewer.
So: pipe `git clean -ndX` instead of collecting it, fold every line at or under
a CACHE-tier directory name into one entry for that directory, and take the
authoritative CACHE list from the pruned directory-level `find` — which costs
one `stat` per tree instead of a descent. Bound the `git clean` walk with
`timeout` the way `--sizes` bounds `du`, and if it trips, report the
ignored-file inventory as **partial** in the report rather than silently
dropping it or stalling the step.
The `find` walks `-prune` the heavy trees rather than filtering them out with
`-not -path`. `-not -path` only suppresses *printing* — `find` still descends
into `.git/`, `node_modules/`, and every other excluded directory, which is why
the inventory stalls on a repo with a multi-GB build tree. Pruning is a
**traversal optimization only and must never change what is reported**: junk
outside the pruned trees (an empty `build/`, an 11 MB file under `src/`) is
still listed exactly as before. When editing the prune list:
- **Keep the empty-directory and large-file walks' lists identical.** If they
drift, one command silently reintroduces the stall. The CACHE-tier walk is
deliberately **not** in that pair: it must *match* `dist/` and the other cache
dirs to report them, so it prunes only `.git`, `node_modules`, and `.venv` and
then prunes at each match instead.
- **Draw entries from the denylist and CACHE categories already named in step 2**
(`node_modules/`, `.venv/`, `dist/`, plus `target/` for Rust builds) instead
of growing a second, inconsistent list.
- **Match by `-name`, not `-path`.** `-name` prunes at any depth, so nested
copies like `packages/foo/node_modules/` and a vendored `vendor/foo/.git/`
are covered — the old `-not -path './node_modules/*'` only ever matched the
top-level one.
- **Coordination roots (`.loom/`, `.anvil/`, `.wrangler/`) are deliberately not
pruned.** They are small, and step 2 needs to see their empty directories in
order to route them to ASK.
**Worktree sizes are opt-in (`--sizes`); the default inventory reports count and
paths only.** `du` has no `-prune`: sizing a worktree root re-enters the very
`node_modules/`, `target/`, `dist/`, and `.venv/` trees the two `find` walks
above were rewritten to skip, once per root — and the reported case that
motivated all of this is 66 GB of worktrees inside a 94 GB tree, so sizing the
roots is most of a `du` over the whole repo. The cost is bounded by inode count
under those roots, not by the number of worktrees, so "there are only a handful
of them" is not a bound at all. That makes an eager `du` the same unbounded walk
that blew a 120-second timeout and stalled this step before.
Making it a flag is the same call the command already makes for `--caches`:
work whose *result* is useful but whose *cost* is high is presented, not
performed, until asked for. It also keeps the fix for the failure this block
exists to prevent — a report of "nothing to tidy" on a tree with tens of
gigabytes in worktrees — because the count and the paths are what carry that
signal, and they are free (`git worktree list` reads
`.git/worktrees/`, it does not walk the trees). The size column is the
refinement, not the point.
**Expected cost of this step, and where the reference scans fit.** The three
pieces that scale with repo size, in the order they bite:
| Piece | Cost | Bound |
|---|---|---|
| `git clean -ndX` | one pass over every ignored tree | `timeout`, minus the CACHE-tier trees excluded above; degrades to a partial ignored-file inventory |
| the `find` walks | one pass over the tree, pruned at the heavy roots | the prune list |
| step 2's reference scans | **one** batched pass over tracked files, whatever the candidate count | `--fast` skips them; `--deep` opts back into one pass per candidate |
| `du` over worktree roots | one unpruned pass per root | opt-in behind `--sizes`, `timeout 20` per root |
The reference scans are the piece that used to be unbounded in the *number of
candidates* rather than in repo size: written as "for each candidate, scan the
tree", six empty directories meant six full-tree scans (twelve, when each was
scanned for both its path and its basename) and blew a further two-minute
budget on top of the inventory. Step 2 now specifies **one** pass over the whole
candidate set instead, which is the same call `--sizes` and `--caches` already
make — bound the cost first, then offer the expensive shape as an explicit flag
(`--deep`) rather than as the default. `--fast` is the other end of the same
dial: skip the scans, and route what they would have checked to ASK.
When `--sizes` is passed, the sizes are **best-effort**: wrap each root in
`timeout 20` and print `size unavailable` for any root that exceeds it rather
than letting one enormous worktree hang the inventory. (`timeout` is GNU
coreutils — on macOS it is `gtimeout` from `brew install coreutils`. If neither
is available, report sizes as unavailable rather than running the walk
unbounded.) Do not prune inside the `du`: an "excluding regenerable trees"
number would understate exactly the footprint the operator is looking for. The
honest choices are a bounded full number or none.
The **first** `worktree` entry is the **main** working tree — always, regardless
of where the command runs from. It is *not* necessarily
`git rev-parse --show-toplevel`: from inside a linked worktree (which is where
Loom agents run, e.g. `.loom/worktrees/issue-42`), `--show-toplevel` reports the
linked worktree's own root while the first porcelain entry is still the main
repo. Do not treat the two as the same path.
What to drop from the list is **the tree being tidied** — the entry whose path
equals `git rev-parse --show-toplevel` — since that is the repo this run is
sweeping, not a worktree it is protecting from itself. Every remaining entry,
including the main working tree when tidy is invoked from a linked worktree, is
a live checkout to report and protect.
`git worktree list --porcelain` is **authoritative**. A directory whose `.git`
is a **file** (not a directory) containing `gitdir: …/.git/worktrees/…` is also
a worktree root, and that check catches worktrees belonging to *other*
checkouts of the same project; but where the two disagree — e.g. a stale `.git`
pointer whose target is gone — trust `git worktree list --porcelain` and do not
let a speculative `.git`-file scan fail the step.
Also look for junk by pattern, wherever it lives:
- OS/editor droppings: `.DS_Store`, `Thumbs.db`, `*~`, `*.swp`, `.#*`
- Python: `__pycache__/`, `*.pyc`, `.pytest_cache/`, `.mypy_cache/`, `.ruff_cache/`
- JS: `node_modules/` outside package roots, stale `dist/`, `.turbo/`, coverage output
- Logs and temp files: `*.log`, `*.tmp`, `tmp/` contents older than a week
- Merge/patch leftovers: `*.orig`, `*.rej`, `*.BACKUP.*`
### 2. Categorize
**Gitignored ≠ safe to delete.** `git clean -ndX` lists *every* gitignored file
on disk — including secrets (`.env`) and expensive-to-rebuild trees (`.venv/`),
which are gitignored *precisely because* they're precious and local. Do not
treat "gitignored" as a synonym for "regenerable." SAFE and CACHE are
**allowlists** of recognized clutter (SAFE = pure junk, auto-deleted; CACHE =
regenerable build output, kept unless `--caches`); a **never-delete denylist**
overrides both; everything else gitignored falls through to ASK.
Apply these tests in order — **denylist first, then the SAFE and CACHE
allowlists, then fall through to ASK**. For empty directories and for
CACHE-tier build-output directories, a **reference scan runs after the
allowlist match, as an additional net, not a replacement for it** — see the
SAFE empty-directory bullet and the CACHE bullet below; the denylist/allowlist
check still runs first and still wins:
**Never-delete denylist (always ASK, never SAFE or CACHE — checked first,
overrides everything below, regardless of gitignore status):**
- Secrets / credentials: `.env`, `.env.*` (but **not** `.env.example` /
`.env.sample`, which are templates safe to keep), `*.pem`, `*.key`,
`*.keystore`, `*.p12`, `*.pfx`, `id_rsa*`
- Expensive-to-rebuild environments: `.venv/`, `venv/`, `env/`, and
`node_modules/` — reinstalling them costs time and network, so they are never
auto-deleted and `--caches` does **not** reach them (they are environments, not
caches). Surface them under ASK for an explicit human call.
- Tool scaffolding / coordination roots: runtime-state directories a tool
expects to exist and manages itself — anything under `.loom/` (`locks/`,
`worktrees/`, `sweep-run/`, `sweep-checkpoint/`, …), `.anvil/`, `.wrangler/`
(e.g. `.wrangler/tmp/`), and the equivalent runtime dirs under any other
tool's dot-directory. **Match by parent tool-directory prefix, not by an
enumerated leaf list**, so coordination subdirectories added later are covered
without editing this file. For these, **empty is the normal, healthy state** —
an empty `.loom/locks/` means no lock is currently held, not that the
directory is abandoned — so emptiness is never evidence of junk here (see the
empty-directory rule under SAFE). (The cache dot-directories already named
under CACHE — `.pytest_cache/`, `.mypy_cache/`, `.ruff_cache/`, `.turbo/`,
`.astro/` — are **not** coordination roots: they are regenerable output and
stay in CACHE, so `--caches` still clears them. The discriminator is that a
coordination root is one where *empty is the normal state*; a cache directory
is one whose entire contents can be rebuilt by re-running the tool.)
- Git worktree roots — **any** path listed by `git worktree list --porcelain`
in step 1, **or** any directory whose `.git` is a **file** (not a directory)
whose contents match `gitdir: .*/\.git/worktrees/.*`. A worktree root is a
live checkout that can hold uncommitted, unpushed, one-of-a-kind work; it is
never SAFE and never CACHE, regardless of gitignore status. This test is
**tool-agnostic and independent of the tool-scaffolding prefixes above** — it
is what covers `.claude/worktrees/`, `.codex/worktrees/`, and any other
tool's worktree cache dir that is not named in the `.loom/` / `.anvil/` /
`.wrangler/` list, as well as ad hoc worktrees under no dot-directory at all
(`git worktree add ../repo-wt-fix123`, `/private/tmp/…`,
`/private/var/folders/…`). Do not rely on the prefix match to reach these.
Emptiness never promotes a worktree root to SAFE either (safety rule 8) — a
checked-out worktree is not legitimately empty, and a parent directory that
*contains* worktrees still routes through the existing empty-directory rule.
A nested worktree shows up in `git clean -ndX` as `Would skip repository
<path>` — plain `git clean -fdX` leaves it alone, but `-ffdX` (double force)
deletes it outright, so never widen the force flag to make a listing "go
away". Report these (with their size when `--sizes` is passed — see below);
deciding which worktrees are *reclaimable* needs merge state and agent
liveness that `$repo-tidy` does not have, so it stays a report, never a
deletion.
- Anything else that looks credential-like or holds unique local state
(local SQLite DBs, local-only config, sample-data caches)
A denylist match routes to **ASK** (never auto-deleted) — not KEEP, which is
reserved for tracked files.
- **SAFE** — pure junk, regenerable with certainty and holding no unique work,
matched by an explicit **allowlist** (never "everything `git clean -ndX` lists
minus a couple of exclusions"). Auto-deleted by default. A file is SAFE only if
it does **not** match the denylist above **and** matches one of:
- OS/editor droppings: `.DS_Store`, `Thumbs.db`, `*~`, `*.swp`, `.#*`
- Merge/patch leftovers: `*.orig`, `*.rej`, `*.BACKUP.*`
- Empty directories **whose path matches no never-delete denylist entry** —
the denylist is checked first here exactly as it is for the file patterns
above. An empty directory under a tool-scaffolding / coordination root
(`.loom/`, `.anvil/`, `.wrangler/`, …) routes to **ASK**, not SAFE:
emptiness is that tool's normal operating state, not evidence of junk. Every
other empty directory (an empty `build/`, say) is SAFE. Note that the
`find . -type d -empty` inventory in step 1 is a raw path scan — it consults
neither gitignore nor the denylist — so this check must be applied to its
output before anything is deleted.
**Reference scan (additional net, after the denylist check, not instead of
it) — ONE batched pass over every candidate, never one scan per directory.**
The denylist above is an enumerated/prefix-matched allowlist of known
tools (`.loom/`, `.anvil/`, `.wrangler/`, git worktree roots); it does not
cover a tool that is not on that list — a custom app's own state/spool dir,
or a future tool's coordination root not yet added here. So every empty
directory that clears the denylist check gets cross-referenced against
tracked files before SAFE is final. **Collect the candidates first, then
scan once** — the scan is one pass over the tree whatever the candidate
count, not a pass per candidate:
1. **Collect.** Build the candidate list: every empty directory from step
1 that cleared the denylist check. Nothing is scanned yet.
2. **Short-circuit on empty.** If the candidate list is empty, the scan is
a **no-op** — skip it entirely. Never invoke the scan with an empty
pattern file: an empty `-f` file is not portably "matches nothing", and
an empty pattern is "matches everything", so either shape would report
every candidate as referenced or error out.
3. **Build one pattern file.** One fixed string per line: each candidate's
repo-relative path, plus its bare basename when the path is generic
enough that a config is likely to name only the leaf. This is the same
pair of patterns the per-directory form used to search for; it is now
two *lines*, not two *scans*.
4. **Scan once.**
`git grep -I -n -F -f "$PATTERNS" -- .` — a single pass over tracked
files for all candidates at once. `-F` keeps every pattern a fixed
string (a path with `.` in it must not be read as a regex), and `-n`
gives `path:line:text` so the next step can attribute hits.
5. **Attribute.** For each hit line, the candidate is whichever pattern the
matched text contains; count distinct referencing files per candidate.
A candidate with at least one hit is demoted; a candidate with none is
not. If the candidate set is large enough that one pattern file is
unwieldy, **chunk it** — the bound is one pass per chunk, never one pass
per candidate.
**Batching is a traversal optimization only and must never change what is
reported** — the same invariant step 1's prune list states for the `find`
walks. Any hit — a script, config, or source file that names the directory —
demotes it from SAFE to ASK, reported with its reason (e.g. "referenced by N
files"), regardless of whether the directory matched a named
tool-scaffolding prefix, exactly as before. A directory with no reference
hit and no denylist match remains SAFE. This scan never *promotes* anything
the denylist already routed to ASK — it only ever demotes a would-be SAFE
empty directory, and only when the allowlist match already let it through.
`--deep` runs the pre-batching shape instead: one scan per candidate, for
both its path and its basename. It is an **audit** of the batched pass, and
the two must agree on every candidate; a disagreement is a bug in the
batched pass. `--fast` skips the scan entirely, in which case every
denylist-cleared empty directory is reported under **ASK** with the reason
`reference scan skipped (--fast)` — never auto-deleted as SAFE on the
strength of a check that did not run (safety rule 11).
Nothing in this category may be tracked by git or match a source-code
extension.
- **CACHE** — regenerable compilation/tool/build output. Same certainty as SAFE
(definitely regenerable, no unique work), but deleting it forces a potentially
slow rebuild, so it is **kept by default** and cleared **only** when `--caches`
is passed (see Apply). A file is CACHE if it does **not** match the denylist
and matches one of:
- Python caches: `__pycache__/`, `*.pyc`, `.pytest_cache/`, `.mypy_cache/`,
`.ruff_cache/`
- Build output: stale `dist/`, `.turbo/`, `.astro/`, `htmlcov/`, `.coverage`,
coverage output, `site/dist`
**Reference scan (additional net, after the allowlist match, not instead of
it) — the same mechanism as the empty-directory reference scan above,
applied to a different question.** "Regenerable" and "harmless to delete
right now" are different properties: a `dist/` can be truly rebuildable by
re-running the tool and still be the exact bytes a live, registered process
is reading from disk this second — clearing it would not lose source, but it
would break that process until the next build. Every path that clears the
CACHE allowlist match is cross-referenced against registered MCP server
configs before CACHE is final — and, like the empty-directory scan,
**collect the candidates first, then read each config source exactly once
per run, never once per candidate.** The four sources below are a handful of
small JSON files: read them once into a single set of referenced paths, then
match every CACHE candidate against that in-memory set. The sources are:
1. `.mcp.json` in the repo root, if present —
`jq -r '.mcpServers[]?.args[]?' .mcp.json`
2. `~/.claude.json` `.mcpServers[]?.args[]?` (top-level, user-scope servers)
3. `~/.claude.json` `.projects["<repo-root-abs-path>"].mcpServers[]?.args[]?`
(project-scoped servers) — resolve `<repo-root-abs-path>` the same way
this command already resolves the repo root elsewhere (step 1's `git
rev-parse --show-toplevel`), never hardcoded
4. Best-effort: any installed tool's tracked `install-metadata.json` whose
`installed_files` (or equivalent) names a path under the candidate
directory
This is a **path-prefix** match, not an exact one — a candidate directory
demotes when any scanned path starts with it (`mcp-loom/dist/` demotes on a
hit for `mcp-loom/dist/index.js`). A hit on any source demotes the path from
CACHE to **ASK**, reported with the reason, mirroring the empty-directory
wording: `← live MCP bundle (referenced by <config-path>)`. Like the
empty-directory scan, this is purely a **demotion** — it never promotes
anything the denylist already routed to ASK, and a CACHE entry with no
reference hit stays CACHE exactly as before, so `--caches` still clears
every regenerable dir that nothing is currently loading from.
The same cost dial applies here as to the empty-directory scan, with the same
safety asymmetry. `--deep` re-reads the config sources per candidate (an audit
of the batched read — the two must agree). `--fast` skips the scan, and every
unscanned CACHE candidate is reported under **ASK** with the reason
`reference scan skipped (--fast)` instead of being cleared by `--caches`:
under `--fast`, `--caches` clears strictly less than it would by default,
never more (safety rule 11).
Like SAFE, nothing here may be tracked by git or match a source-code extension.
(`node_modules/` and virtualenvs are **not** CACHE — they are denylisted
environments and stay in ASK even with `--caches`.)
- **ASK** — probably junk but needs a human call. This covers:
- Untracked files that aren't gitignored (could be unsaved work!), large
files, stale-looking logs, old `tmp/` contents.
- **Any gitignored file that matches the never-delete denylist** (secrets,
virtualenvs, tool-scaffolding roots) — surfaced here, never auto-deleted.
- **Any empty directory whose path matches the denylist** (a `.loom/`,
`.anvil/`, or `.wrangler/` coordination or runtime-state dir) — emptiness
is that tool's normal state, so it lands here rather than in SAFE.
- **Any empty directory demoted by the reference scan** (see the SAFE
empty-directory bullet above) — a tracked file references its path even
though it matched no denylist entry. Report it with the reason, e.g.
"referenced by N files", rather than silently skipping it.
- **Any CACHE-tier directory demoted by the MCP-config reference scan**
(see the CACHE bullet above) — its path is referenced by `.mcp.json`,
`~/.claude.json` `mcpServers`, or an installed tool's
`install-metadata.json`, so a registered MCP server loads from it right
now. Report it with the reason, e.g. "referenced by ~/.claude.json"; it is
regenerable but not currently harmless to delete, so `--caches` must not
reach it.
- **Any candidate whose reference scan was skipped because `--fast` was
passed** — an empty directory that cleared the denylist, or a CACHE-tier
directory that cleared the allowlist, but that was never cross-referenced.
Report it with the reason `reference scan skipped (--fast)`. A check that
was not run is not a check that passed, so these land here rather than in
SAFE or CACHE (safety rule 11).
- **Any git worktree root** detected in step 1 — surfaced on its own
`worktree:` inventory line (see Report), never auto-deleted, whether or not
it is gitignored and whether or not it sits under a recognized tool
dot-directory.
- **Any gitignored file that does not match the SAFE or CACHE allowlist** (a
novel/unrecognized cache dir, unrecognized local state) — when in doubt, it
lands here, not in SAFE or CACHE.
- **An orphaned-environment prune offer**, when step 1 detected a lockfile
and its package manager. This is the one ASK entry that is a **verb, not a
path** — see below.
**The prune offer (ASK-tier, and why).** Dependency churn leaves packages in
`node_modules/` that the current lockfile no longer references. After a round
of major bumps, one real repo's `node_modules/` went from 60 MB to 106 MB
because `node_modules/.pnpm` retained both the old and new TypeScript side by
side — ~46 MB unreferenced by the lockfile. That is exactly the dead weight
from dependency churn that tidy exists to catch, and until now there was no
tier that could reach it: `node_modules/` is denylisted as an *environment*,
so the only options were keep-the-whole-tree or delete-the-whole-tree.
The prune is the missing middle. It removes **only** packages the lockfile
does not reference, so it cannot break the working install and forces no
reinstall of anything in use. It nonetheless lands in **ASK, not CACHE**:
it mutates an environment in place, and ASK is the tier whose contract is
*never automatic under any flag* (Apply, below). `--caches` deliberately does
not reach it. This **adds a tier and relaxes nothing** — `node_modules/`
full-tree deletion stays denylisted and ASK exactly as before.
Two things not to overstate:
- **pnpm is the real case; npm is usually a near-no-op.** `pnpm prune` clears
the `.pnpm` link farm, where the duplication actually accumulates. `npm
install` already reconciles extraneous packages as part of its own run, so
`npm prune` typically reclaims little. Offer it, but do not present the two
as equivalent wins. In a pnpm workspace the prune must be run recursively
(`-r`) or it only touches the root package.
- **A pre-run size estimate is optional.** Diffing `node_modules/.pnpm`
entries against lockfile references to predict the reclaim is real parsing
work on every run, for a number that can be wrong. Reporting the current
`node_modules/` size and the **actual** bytes freed afterwards — what every
other category effectively does — is enough. Compute the estimate only if
it is cheap and reliable in the repo at hand; never block the offer on it.
Deciding which worktrees, branches, and stashes are *stale* remains
[[reset]]'s job — point there instead of pruning them here. `$repo-tidy`'s
job with worktrees is only **visibility and protection**: report every root
(and its size under `--sizes`) so the operator can see the footprint, and
never delete one.
- **KEEP** — flagged only as information: tracked files that look like they
don't belong. Nothing here is ever deleted — safety rule 1 is absolute — but
"looks like it doesn't belong" covers two shapes whose remedies are
**opposite**, so KEEP is reported as two named sub-cases and never as one
collapsed list:
- **KEEP (generated)** — a tracked file that looks like build output or some
other regenerable artifact that got committed by accident
(`assets/build.min.js`). It is inert: nothing reads it as source, it is
just noise in the history and the diff. The remedy is to stop tracking it
and ignore it going forward, which is [[gitignore]]'s job — point there.
This is what KEEP has always meant, and this sub-case is unchanged.
- **KEEP (name collision)** — a tracked file whose **trailing extension is a
live source extension in this repo**, so every tool that walks the project
by extension (compilers, EDA tools, linters, IDEs) opens it as real source.
This one is not inert, and [[gitignore]] is the **wrong** pointer for it:
the file is already tracked, already on disk, and already being parsed, so
adding an ignore rule changes nothing about the harm. The only remedy is a
deliberate `git rm` — see **The printed recipe** below.
Detection is a **naming heuristic, not a content check**: no file is opened
or parsed to make this call. It runs over **tracked files only**, since an
untracked or gitignored file is another tier's problem:
```bash
# The candidate set and the sibling set are both drawn from here.
git ls-files
```
A tracked file is a name collision when **all three** conditions hold:
1. Its basename carries a backup/copy marker **in the stem — before the
final `.`**: the substring `backup`, `copy`, or `orig`, matched
**case-insensitively** (`Connectors_BACKUP_20260427.kicad_sch`,
`schematic copy.kicad_sch`, `parser.orig.rs`).
2. Its trailing extension has **real siblings**: at least one *other*
tracked file ends in the same `.<ext>` and carries **no** such marker.
That sibling test is what makes `<ext>` a live source extension *for
this repo*. Never match against a hardcoded global extension list — a
`.kicad_sch` collision matters only in a repo that has real `.kicad_sch`
files, and in a repo with no such siblings the same filename is just a
file with an unusual name. **An extension that itself carries a marker
is never live**, however many files share it: `.backup-20260427_163100`
and `.orig` are provenance suffixes, nothing parses them as source, and
a `*.orig` merge leftover is the SAFE tier's leftover rule, not this
one — that exclusion is what keeps the two rules from overlapping in a
repo whose `*.rs.orig` leftovers would otherwise make `orig` look live.
3. The marker reads as a **provenance stamp on an existing file**, not as
the file's subject. Strip the **marker run** off the *end* of the stem
— the marker word, the separator run (space, `_`, `-`, `.`) in front of
it, and any timestamp or copy index (digits and separators) behind it —
and what remains must be a non-empty stem that names a **base sibling**:
another tracked file `<base>.<ext>`, same extension, **same directory**,
matched **case-insensitively** (same rationale as condition 1 — this
preserves real-world filesystem behavior on macOS/Windows, where
`Connectors.kicad_sch` and `connectors.kicad_sch` name the same file).
`connectors_backup_20260427_163100.kicad_sch` → `connectors.kicad_sch`,
`schematic copy.kicad_sch` → `schematic.kicad_sch`, `sheet - Copy
2.kicad_sch` → `sheet.kicad_sch`, `parser.orig.rs` → `parser.rs`. The
base sibling is the file this one is a copy *of*; if it cannot be
named, this is not a collision.
Condition 1 is deliberately about the **stem**, and that is what keeps the
inert shape out. `connectors_backup_20260427_163100.kicad_sch` collides:
its trailing extension really is `.kicad_sch`, so KiCad loads it as a
schematic sheet and its contents are counted a second time.
`connectors.kicad_sch.backup-20260427_163100` does **not** collide: its
trailing extension is `.backup-20260427_163100`, the marker *is* the
extension rather than part of the stem, no other tracked file shares that
extension, and no extension-walking tool will ever open it. Flagging the
second shape alongside the first would bury the signal, which is the whole
point of the sub-case: separate the backups that are actively being parsed
from the ones that are harmlessly sitting there.
Condition 3 is what tells a **stamp** from a **topic**, and without it
conditions 1 and 2 flag ordinary source in any repo that merely discusses
backups or copying. `src/backup.py` and `src/copy.py` strip to nothing.
`copyright.py`, `BackupManager.ts`, `useCopyToClipboard.ts` and
`deepcopy_helpers.py` have no marker *run* at all — the marker is glued
into a longer word, with no separator in front of it and no
separator-or-digits behind it. `copy_utils.ts` carries the marker at the
**front**, with no base in front of it to be a copy of.
`docs/backup-strategy.md` has both problems. None of these are backups of
anything and none may appear in this sub-case: it prints `git rm`, and a
pasted false positive here is the one recipe in `$repo-tidy` that costs a
source file.
The trade is deliberate — precision bought with recall. A genuine backup
whose base file was since renamed or deleted, or that was moved into a
`backups/` directory away from its original, has no base sibling and is
**not** reported here even though a tool would still parse it. It is not
lost: like the inert shapes, it stays in the **generated** sub-case with
the [[gitignore]] pointer. Only the alarm and the `git rm` recipe are
withheld, and they are withheld exactly when tidy cannot say truthfully
what the file is a backup of — which is the same circumstance in which the
`why:` line below could not be written honestly.
**The printed recipe.** For each collision, print a literal,
copy-pasteable `git rm <path>` line plus a one-line reason naming the tool
class that parses the file and what that costs. The reason is only ever
written from what conditions 2 and 3 established — the sibling count that
made the extension live, and the base sibling the file is a copy of. **If
that sentence cannot be written truthfully, the file is not a collision
and must not be reported here**; never assert that a file is a backup of
something tidy could not name. `$repo-tidy` **prints this string and
nothing else** — it never runs `git rm`, never stages it, and never
offers to run it, not under `--ask`, `--apply`, or any other flag.
Removing a tracked file is a commit the user makes deliberately (safety
rule 1); the recipe exists so that decision is one paste away instead of a
research task, exactly as the [[gitignore]] pointer is for the generated
sub-case.
Print each sub-header **only when it has entries**. A repo with nothing
colliding must not grow an empty `name collision` heading — an empty tier
reads as a finding, and this one is meant to read as an alarm.
### 3. Report
```
## Repo Clean — inventory
SAFE (would free 32 MB — deleted by default):
.DS_Store × 14
3 *.orig merge leftovers
6 empty directories
CACHE (would free 402 MB — kept by default; pass --caches to clear):
__pycache__/ × 22 dirs
.mypy_cache/ (gitignored, 22 MB)
dist/ (gitignored, 380 MB)
ASK:
.env gitignored, 1 KB ← credentials, never auto-deleted
.venv/ gitignored, 240 MB ← virtualenv, expensive to rebuild
node_modules/ gitignored, 310 MB ← environment, reinstall via npm; not a --caches target
prune node_modules pnpm-lock.yaml detected ← run `pnpm prune` to drop lockfile-unreferenced packages
mcp-loom/dist/ gitignored, 796K ← live MCP bundle (referenced by ~/.claude.json); --caches will not clear it
.loom/locks/ gitignored, empty ← coordination root, empty is normal state
.wrangler/tmp/ gitignored, empty ← tool runtime dir, empty between builds
notes-scratch.md untracked, 3 KB, modified today ← might be real work
sim-output-old/ untracked, 1.2 GB, untouched 60 days
WORKTREES (4 roots — never auto-deleted, listed for visibility; --sizes to measure):
worktree: .loom/worktrees/issue-42 ← live git worktree
worktree: .claude/worktrees/scratch-wt ← live git worktree
worktree: ../repo-wt-fix123 ← live git worktree (outside repo root)
worktree: /private/tmp/wt-bisect ← live git worktree (outside repo root)
Pruning stale worktrees is $repo-reset's call, not tidy's.
KEEP (informational) — tracked files, never deleted by tidy (safety rule 1):
generated — committed build output; stop tracking it going forward:
assets/build.min.js tracked but looks generated — see $repo-gitignore
name collision — tracked AND parsed as real source; gitignoring fixes nothing:
connectors_backup_20260427_163100.kicad_sch
why: backup of connectors.kicad_sch, and 14 real .kicad_sch files make
that a live extension here — KiCad opens this backup as a schematic
sheet too, so its contents are counted twice
run deliberately (tidy will not run this for you):
git rm connectors_backup_20260427_163100.kicad_sch
```
The two KEEP sub-blocks are formatted differently on purpose: the generated
sub-case is a one-line pointer at another command, while the name-collision
sub-case gets its own indented `why:` line and a `git rm` recipe on a line of
its own, because the operator has to read the reason before running it. Print
whichever sub-blocks have entries and omit the others entirely — on the common
repo where nothing collides, the output is the single `generated` block and is
byte-for-byte what it has always been. The `git rm` line above is **report
text**: it is printed for the operator to run, never executed by `$repo-tidy`.
With `--sizes`, the same block gains a right-aligned size column and a total
(`size unavailable` for any root that hit the `timeout`):
```
WORKTREES (66 GB across 4 roots — never auto-deleted, listed for visibility):
worktree: .loom/worktrees/issue-42 32 GB ← live git worktree
worktree: .claude/worktrees/scratch-wt 3.1 GB ← live git worktree
worktree: ../repo-wt-fix123 12 GB ← live git worktree (outside repo root)
worktree: /private/tmp/wt-bisect 19 GB ← live git worktree (outside repo root)
Pruning stale worktrees is $repo-reset's call, not tidy's.
```
With `--fast`, the tiers are the same shape but the SAFE and CACHE tiers shrink
and ASK grows by exactly the candidates whose reference scan did not run, each
carrying the skip as its reason:
```
ASK:
build/ empty ← reference scan skipped (--fast)
packages/ui/dist/ gitignored, 88 MB ← reference scan skipped (--fast)
```
Print that reason literally rather than folding these into a generic "needs a
human call" line: the operator has to be able to tell a candidate tidy *checked
and could not clear* from one it *never checked*, and the remedy for the second
is simply to re-run without `--fast`.
The `WORKTREES` block is a **distinct inventory section**, not folded into the
generic denylist ASK lines, and under `--sizes` its bytes are summed
**separately** from the SAFE/CACHE/ASK totals (a worktree's contents are neither
freed nor freeable by this command); roots whose size is unavailable are
excluded from the total and the total is marked as a lower bound. Print the
block whenever at least one worktree root exists — including on an
otherwise-clean run and including without `--sizes`, so `$repo-tidy` never
reports "nothing to tidy" on a tree where tens of gigabytes are sitting in
worktrees with no hint of where the space went. The count and the paths are what
carry that signal; the sizes only quantify it.
If the repo documents its **own** worktree-management tooling — a script,
`package.json` command, `Makefile` target, or binary the repo's own docs point
at for this purpose — mention it after the block as a soft pointer (e.g.
`Reclaimable worktrees: try 'loom-daemon clean --safe --dry-run'.`). Only
whichever the repo actually documents; `$repo-tidy` has no reliable way to
discover arbitrary third-party tooling, so **omit the line entirely rather than
guessing** at a command that may not exist.
### 4. Apply
- Default: delete the SAFE category immediately, report the CACHE tier as kept
(with the bytes `--caches` would free), then present ASK items for a decision.
Never auto-delete anything in ASK, no matter the flags.
- With `--caches`: the CACHE tier joins the auto-delete set — delete SAFE **and**
CACHE immediately, then present ASK. `--caches` never widens what counts as
deletable beyond the CACHE allowlist; denylisted paths (secrets, virtualenvs,
`node_modules/`) stay in ASK regardless.
- With `--ask`: walk through every category with the user, including SAFE and
CACHE; delete only what they approve. (`--ask` already surfaces caches for a
decision, so `--caches` is redundant with it — the flag only affects the
non-interactive default.)
- With `--fast`: the tiers are unchanged, but the reference scans in step 2 do
not run, so every candidate that would have been scanned sits in ASK with the
reason `reference scan skipped (--fast)`. The auto-delete set is therefore a
**subset** of what the default run would delete — `--fast` can only ever
delete less, never more or something different. `--deep` is the reverse trade
(same set, more scans) and changes nothing about this step.
- **KEEP is never part of the apply step, under any flag.** Both sub-cases are
tracked files, so both are report-only: `$repo-tidy` never runs `git rm`,
never stages a tracked-file deletion, and never prompts to do either — not
even for a name collision, and not even under `--ask`. The `git rm <path>`
line in the report is a string printed for the operator, in exactly the same
sense as the `see $repo-gitignore` pointer beside it. If a future change ever
needs tidy to *perform* it, that is a different command with a different
safety story, not a flag on this one.
**The prune offer, if accepted, runs the package manager's own verb and
nothing else** — `pnpm prune` (add `-r` in a workspace) or `npm prune`, from
the repo root. Never `git clean`, never `rm` inside `node_modules/`, and never
a hand-rolled walk of `.pnpm`: the whole safety argument for this tier is that
the package manager decides what is unreferenced, so bypassing it forfeits the
guarantee. Report the actual bytes freed by measuring `node_modules/` before
and after. If the prune command fails, report the failure and leave the tree
alone — a partially pruned `node_modules/` is the package manager's to
reconcile on the next install, not tidy's to repair.
The default auto-delete is scoped to **SAFE-allowlisted paths only** (plus the
CACHE allowlist when `--caches` is passed). Never pass a denylisted path
(secrets, virtualenvs, `node_modules/`, tool-scaffolding roots, git worktree
roots — **including when it is empty**) or an unrecognized gitignored path to
`git clean -fdX` — those are ASK items and require an explicit human call. Build
the explicit `<paths>` list from the SAFE category (and CACHE under `--caches`)
and nothing else; do **not** run a blanket `git clean -fdX` that would sweep
whatever `git clean -ndX` lists.
Use `git clean -fdX -- <paths>` for gitignored artifacts and plain `rm` only
for pattern-matched junk you listed in the report. After deleting, re-run the
inventory to confirm and report bytes freed.
## Safety Rules
1. **Never delete tracked files** — that's a git operation the user does deliberately
2. **Never touch `.git/`** internals
3. **Untracked ≠ junk** — an untracked file modified recently is presumed to be
unsaved work and always lands in ASK
4. **Everything deleted must have appeared in the report first**
5. When scoped to a subtree, do not delete anything outside it
6. **Gitignored ≠ safe to delete** — the never-delete denylist (secrets like
`.env`/`*.pem`/`*.key`, environments like `.venv/`/`venv/`/`env/` and
`node_modules/`, tool-scaffolding roots like `.loom/`/`.anvil/`/
`.wrangler/`, and git worktree roots) always overrides SAFE and CACHE and
routes to ASK, regardless of what `git clean -ndX` lists. Unrecognized
gitignored files fall through to ASK, never SAFE or CACHE.
7. **Caches are opt-in** — the CACHE tier (`__pycache__/`, `dist/`, `.mypy_cache/`,
and the other compilation/tool/build patterns) is never auto-deleted by
default; it is cleared only when `--caches` is passed (or approved item-by-item
under `--ask`). Deleting a cache is safe but forces a rebuild, so the default
keeps it.
8. **Empty ≠ abandoned** — for lock, coordination, and runtime-state
directories, empty is the *normal operating state*, not clutter: an empty
`.loom/locks/` means nothing is currently held, and `.loom/worktrees/`,
`.loom/sweep-run/`, `.loom/sweep-checkpoint/`, or `.wrangler/tmp/` are empty
whenever no run is in flight. Deleting them reads a tool working correctly as
evidence of junk. So the ordering in rule 6 applies to **directories exactly
as it does to files**: check the denylist before the empty-directory rule in
SAFE. Emptiness never promotes a denylisted path into SAFE, and never
bypasses the fall-through to ASK for unrecognized gitignored paths.
9. **Never delete a git worktree root** — a worktree is a live checkout that
may hold uncommitted, unpushed work, and it is denylisted on the strength of
`git worktree list --porcelain` (or a `.git` **file** pointing at
`…/.git/worktrees/…`), *not* on where it happens to live. Do not infer
protection from a dot-directory prefix: `.claude/worktrees/`, a sibling
`../repo-wt-fix123`, and a worktree under `/private/tmp/` are all protected
by this rule and none of them are matched by rule 6's prefix list. Report
every root (see Report) even when nothing is deleted — the one thing worse
than deleting a worktree is telling the operator their 94 GB tree is clean.
Sizing those roots is a `du` with no `-prune`, so it is opt-in behind
`--sizes` and bounded per root; the report itself never is.
10. **Prune only through the package manager** — the orphaned-environment
offer runs `pnpm prune` / `npm prune` and nothing else. Never `rm` inside
`node_modules/`, never `git clean` against it, never a hand-rolled walk of
`node_modules/.pnpm`. The tier is defensible *because* the package manager
decides what the lockfile no longer references; deciding that ourselves
forfeits the only guarantee that makes it safe. And the offer is ASK-tier,
so rule 7's `--caches` opt-in does not reach it and no flag makes it
automatic — `node_modules/` full-tree deletion remains denylisted and ASK
exactly as under rule 6.
11. **A skipped check never widens what is deleted** — `--fast` skips step 2's
reference scans, so the candidates they would have cross-referenced are
reported under ASK (`reference scan skipped (--fast)`), never auto-deleted as
SAFE or cleared by `--caches`. A check that was not run is not a check that
passed. The same asymmetry governs any future cost flag: making tidy cheaper
may shrink the auto-delete set, never grow it. Its companion is the
invariant on the other side of the dial — **batching a scan is a traversal
optimization only and must never change what is reported** (step 1 states it
for the `find` prune list, step 2 for the batched reference pass), which is
why `--deep` exists as an audit of the default rather than as a second
answer.