@osdlc/repo-cleaner 0.17.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,64 +2,62 @@
2
2
 
3
3
  What has been merged into the default branch, and what can go away.
4
4
 
5
- Point it at a repository and it inventories the local branches and
6
- worktrees, and with `--remotes` the remote branches too, classifying each
7
- as `safe-to-delete`, `safe-to-keep` or `needs-investigation` — with the
8
- evidence behind the verdict and the literal command that would act on it.
5
+ Point it at a repository and it lists the worktrees and local branches, and
6
+ with `--remote` the remote branches too, and judges each one: `delete`,
7
+ `review`, `keep` or `blocked`, with the evidence behind the verdict, the risks
8
+ deleting it carries, and the literal command that would delete it.
9
9
 
10
- `repo-cleaner` (or `repo-cleaner status`) never deletes anything. It prints
11
- the commands that would. `repo-cleaner apply` deletes: it checks each row
12
- again, shows you what it will delete and skip, and asks before it runs.
10
+ `repo-cleaner` never deletes anything; it prints the commands that would.
11
+ `repo-cleaner clean` deletes: it checks each row again, shows you what it
12
+ will delete and skip, and asks before it runs.
13
13
 
14
14
  By default it works on your own machine only: worktrees and local branches,
15
- nothing fetched and no `gh` call, compared with the `origin/<default>` you
16
- last fetched (the header says `not fetched`). `--remotes` (or `--only
17
- remotes`, or `--remote-url`) turns on all three things that need the
18
- network: it fetches each remote first, asks `gh` about pull requests, and
19
- sweeps remote branches. Remote branches are shared, often number in the
20
- hundreds, and each one is compared with the default branch, so they make a
21
- sweep much slower. A local report ends with a line saying what it left out.
22
-
23
- Without `gh`, a squash-merged branch is still found by comparing patches,
24
- and a clean worktree merged by ancestry is `delete` once its branch shows
25
- it was worked on (see [How it decides](#how-it-decides)); one that looks
26
- freshly made stays `review`.
27
-
28
- On a terminal, a long sweep step draws a progress bar on stderr, and
29
- `apply` draws its steps as a list that updates in place: each row has its
30
- own bar, empty while it waits, half full while it runs and full with a `✓`
31
- (or `✗`) when it is done, with a bar for the whole run underneath:
15
+ nothing fetched and no pull-request lookup, compared with the
16
+ `origin/<default>` you last fetched (the header says `not fetched`).
17
+ `--remote` turns on all three things that need the network: it fetches each
18
+ remote first, asks GitHub or GitLab about pull requests, and judges remote
19
+ branches. Remote branches are shared, often number in the hundreds, and each
20
+ one is compared with the default branch, so they make a sweep much slower. A
21
+ local report ends with a line saying what it left out.
22
+
23
+ Without a pull-request lookup, a squash-merged branch is still found by
24
+ comparing patches, and a clean worktree merged by ancestry is `delete` once
25
+ its branch shows it was worked on (see [How it decides](#how-it-decides)); one
26
+ that looks freshly made stays `review`.
32
27
 
33
- ```text
34
- ██████████ ✓ delete local_branch feat/a
35
- █████░░░░░ › delete local_branch feat/b
36
- ░░░░░░░░░░ delete remote_branch origin/feat/b
37
- ░░░░░░░░░░ prune worktree records in /Users/me/work/api
38
- ████████░░░░░░░░░░░░░░░░░░░░░░ 1/4
28
+ ```console
29
+ $ repo-cleaner --remote
30
+ /Users/me/work/api (acme/api, default main, origin)
31
+ branch fix/login-redirect delete PR #412 merged
32
+ git update-ref -d refs/heads/fix/login-redirect 3f9c2e1d8b7a6c5d4e3f2a1b0c9d8e7f6a5b4c3d
33
+ branch spike/cache review mixed or absent signals; needs --allow abandon-unmerged (drops 2 commits)
34
+ git update-ref -d refs/heads/spike/cache 9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b
35
+ worktree /Users/me/work/wt/412 delete clean; PR #412 merged
36
+ git -C /Users/me/work/api worktree remove /Users/me/work/wt/412
37
+ remote origin/fix/login-… delete PR #412 merged (auto-delete didn't fire)
38
+ git push --force-with-lease=refs/heads/fix/login-redirect:3f9c2e1d8b7a6c5d4e3f2a1b0c9d8e7f6a5b4c3d origin :refs/heads/fix/login-redirect
39
+
40
+ 3 to delete, 1 to review, across 1 repository.
39
41
  ```
40
42
 
41
- A list taller than the terminal scrolls to keep the running step in view.
42
- A failed step's error is printed in full under the list.
43
-
44
- On a terminal, the report and the apply preview are colored: red for a
45
- delete, blue for a prune, yellow for a review or a skipped row, green for a
46
- keep. `NO_COLOR` turns this off. A pipe or a file gets no color unless
47
- `FORCE_COLOR` asks for it.
43
+ On a terminal, the report and the preview are colored: red for a delete,
44
+ blue for a prune, yellow for a review, a blocked or a skipped row, green for a
45
+ keep. `NO_COLOR` turns this off; a pipe or a file gets no color unless
46
+ `FORCE_COLOR` asks for it. A long sweep step draws a progress bar on stderr,
47
+ and `clean` draws its steps as a list that updates in place, with a bar for the
48
+ whole run underneath:
48
49
 
49
- ```console
50
- $ repo-cleaner --remotes
51
- /Users/me/work/api (acme/api, default main, origin)
52
- local_branch fix/login-redirect delete PR #412 merged
53
- git update-ref -d refs/heads/fix/login-redirect 3f9c2e1d8b7a6c5d4e3f2a1b0c9d8e7f6a5b4c3d
54
- local_branch spike/cache review mixed or absent signals
55
- worktree wt/412 delete clean; PR #412 merged
56
- git -C /Users/me/work/api worktree remove wt/412
57
- remote_branch origin/fix/login-… delete PR #412 merged (auto-delete didn't fire)
58
- git push --force-with-lease=refs/heads/fix/login-redirect:3f9c2e1d8b7a6c5d4e3f2a1b0c9d8e7f6a5b4c3d origin :refs/heads/fix/login-redirect
59
-
60
- 1 to delete, 1 to review, across 1 repository.
50
+ ```text
51
+ ██████████ ✓ delete branch:feat/a
52
+ █████░░░░░ › delete branch:feat/b
53
+ ░░░░░░░░░░ delete remote:origin/feat/b
54
+ ░░░░░░░░░░ prune worktree:/Users/me/work/wt/old
55
+ ████████░░░░░░░░░░░░░░░░░░░░░░ 1/4
61
56
  ```
62
57
 
58
+ A list taller than the terminal scrolls to keep the running step in view, and
59
+ a failed step's error is printed in full under the list.
60
+
63
61
  ## Install
64
62
 
65
63
  ```sh
@@ -90,6 +88,279 @@ nvm users in fish can use a fish port such as `nvm.fish`; fnm has
90
88
  `fnm env --use-on-cd --shell fish | source`. Then open a new shell and check
91
89
  with `type repo-cleaner`.
92
90
 
91
+
92
+ ## Commands
93
+
94
+ ```bash
95
+ repo-cleaner [target...] # the report: what is merged, what can go; deletes nothing
96
+ repo-cleaner plan [target...] # save the sweep's rows and their evidence, to run later (-o <file>)
97
+ repo-cleaner clean [target...] # check again, show the steps, ask, delete
98
+ repo-cleaner cache # list or remove the clones kept for remote URL targets
99
+ repo-cleaner dev # run repo-cleaner from a development checkout
100
+ ```
101
+
102
+ Every command takes `--help`, and `--help --extended` for the full reference:
103
+ what each option means, the words the report uses, and which combinations are
104
+ refused. A checkout directory named like a command is swept as `./<name>`.
105
+
106
+ ### Targets
107
+
108
+ A target is what to sweep:
109
+
110
+ | Target | Swept |
111
+ | --- | --- |
112
+ | a checkout | that repository; a linked worktree sweeps the repository it belongs to |
113
+ | a directory | every checkout found under it, three levels deep at most |
114
+ | a remote URL (`https://`, `ssh://`, `file://`, `user@host:path`) | its remote branches only, through a clone kept in the cache directory (see [A remote with no checkout](#a-remote-with-no-checkout)) |
115
+
116
+ No target sweeps the checkout you are in. A bare invocation never fans out.
117
+ What a target names is read off the disk rather than from how it is written:
118
+ a path that is itself a checkout names that repository, and a path that is
119
+ not is searched for the checkouts under it, descending through directories
120
+ that are not checkouts and stopping at the ones that are. A directory holding
121
+ a checkout and three of its worktrees sweeps one repository, not four; the
122
+ worktrees appear as rows of that repository.
123
+
124
+ The report ends with what the search cost — `searched 1 target: 214
125
+ directories read, 12 checkouts found.` — because those numbers are how a
126
+ target pointing at the wrong directory gets caught. A search that passes
127
+ 10,000 directories says so on stderr while it is still running. A target that
128
+ resolves to nothing is reported rather than dropped.
129
+
130
+ ### Sweep options
131
+
132
+ The report, `plan` and `clean` take these:
133
+
134
+ | Option | Meaning |
135
+ | --- | --- |
136
+ | `--remote` | Also judge remote branches. Turns on the network: fetches each remote first and asks GitHub or GitLab about pull requests. Slower. |
137
+ | `--offline` | With `--remote`: do not fetch; judge against the remote-tracking refs already on disk. |
138
+ | `--only <kinds>` | Only these kinds of row: `worktree`, `branch`, `remote`, comma-separated. Default: `worktree,branch`, plus `remote` with `--remote`. Naming `remote` implies `--remote`. |
139
+ | `--branch <name\|HEAD>` | Only the rows of one branch: its worktree, its local branch, and its remote branch with `--remote`. `HEAD` is the branch checked out at the target. Never the default branch. |
140
+ | `--against <remote>` | The remote whose default branch counts as "merged into". Default: `origin`, then the first remote. |
141
+ | `--provider <name>` | With `--remote`: where pull requests come from. `auto` (by the remote's host), `github`, `gitlab`, or `none` to judge on git evidence alone. |
142
+ | `--no-progress` | No progress line on stderr. |
143
+
144
+ ### The report
145
+
146
+ `repo-cleaner [target...]` lists each row with its verdict, why, the risks it
147
+ carries, and the command that would delete it.
148
+
149
+ | Option | Meaning |
150
+ | --- | --- |
151
+ | `--all` | Also show `keep` rows. By default only `delete`, `review` and `blocked` rows are listed. Only with `--format text`. |
152
+ | `--format text` | The report. The default. |
153
+ | `--format summary` | Counts per repository: total, merged, delete, prune, review, blocked, keep. |
154
+ | `--format commands` | Only the commands of `delete` rows that carry no risk, ready to copy. |
155
+ | `--format json` | Everything, evidence included, for scripts. |
156
+
157
+ ```console
158
+ $ repo-cleaner --format summary --remote
159
+ /Users/me/work/api (acme/api, default main, origin)
160
+ total merged delete prune review blocked keep
161
+ local branches 12 8 6 0 1 0 5
162
+ worktrees 4 3 1 1 0 1 1
163
+ remote branches 40 31 30 0 6 0 4
164
+
165
+ 37 to delete, 1 to prune, 7 to review, 1 blocked, across 1 repository.
166
+ ```
167
+
168
+ `prune` counts worktree rows whose directory is gone, or is no longer a
169
+ checkout: deleting one drops the stale record, with `git worktree remove
170
+ <path>` for a directory that is gone (that worktree's record alone) and the
171
+ repository-wide `git worktree prune` for one left without its `.git`, and a
172
+ directory that is still there stays. `merged` counts rows whose work is on
173
+ the default branch, by ancestry, by a merged PR at the branch's head, or by a
174
+ patch match; the default branch and the primary worktree are counted in
175
+ `total` but not judged.
176
+
177
+ ### `repo-cleaner plan`
178
+
179
+ Sweeps and saves every row, each with a fingerprint of the evidence behind its
180
+ verdict, so it can be reviewed now and run later with
181
+ `clean --plan`. `-o, --out <file>` sets where it goes; the default, `-`, is
182
+ stdout, and the totals line goes to stderr.
183
+
184
+ ### `repo-cleaner clean`
185
+
186
+ Sweeps (or reads `--plan`), checks every row again, shows the steps, asks,
187
+ then deletes, compare-and-swap, verifying each step and stopping at the first
188
+ failure. Without `--pick` it runs every row judged `delete`.
189
+
190
+ | Option | Meaning |
191
+ | --- | --- |
192
+ | `--pick <row>` | Work on exactly these rows. See [Pick and spare](#pick-and-spare). Repeatable. |
193
+ | `--spare <row>` | Leave this row alone, and make sure it is what you saw. Repeatable. |
194
+ | `--allow <risk>` | Accept this risk for every row that carries it. |
195
+ | `--allow <risk>=<row>` | Accept it for that one row only. |
196
+ | `--plan <file\|->` | Run a saved plan instead of sweeping now (`-` reads stdin). Rows whose evidence changed since the plan are dropped, not deleted. |
197
+ | `-y, --yes` | Do not ask. Without it, and with no terminal to ask on, `clean` refuses. |
198
+ | `--dry-run` | Check and show the steps, then stop. |
199
+ | `--receipt <file>` | Write a record of every step, rewritten after each one. Default: next to a `--plan` file; none otherwise. |
200
+ | `--format text\|json` | How the steps and the outcome are shown. |
201
+
202
+ ```text
203
+ /Users/me/work/api
204
+ delete branch:fix/login-redirect
205
+ git update-ref -d refs/heads/fix/login-redirect 3f9c2e1d…
206
+ prune worktree:/Users/me/work/wt/old
207
+ git -C /Users/me/work/api worktree remove /Users/me/work/wt/old
208
+ skip worktree:/Users/me/work/wt/spike
209
+ needs-pick: review: clean but no clear disposability signal; pick it to run it
210
+
211
+ would delete: 1 local branch, 1 stale worktree pruned
212
+ skipped 1, not deleted: 1 needs-pick (name with --pick)
213
+ Delete 2 items? [y/N]
214
+ ```
215
+
216
+ `clean` throws away anything typed while the sweep ran, so a key pressed
217
+ early cannot answer the question, and says so when it did.
218
+
219
+ ### Pick and spare
220
+
221
+ **`--pick <row>`** means "work on exactly these rows". It does two things:
222
+
223
+ 1. **It narrows the run.** Only the picked rows are considered; everything
224
+ else is left alone.
225
+ 2. **It opts in rows that never run otherwise.** Without `--pick`, `clean`
226
+ deletes only rows judged `delete`. A `review` row, or a `keep` row with an
227
+ action (a branch with an open pull request), is deleted only when it is
228
+ named, so a borderline row never goes as part of a batch.
229
+
230
+ Picking does not approve anything: a picked row that carries a risk still
231
+ needs `--allow` for it. A `blocked` row, or a `keep` row with no action, is
232
+ skipped and shown with the reason.
233
+
234
+ **`--spare <row>`** means "leave this row alone, and make sure it is what I
235
+ saw". The usual case: delete a merged worktree and its local branch, but keep
236
+ the branch on the remote as a backup.
237
+
238
+ - A spared row is not deleted, even if it is judged `delete`.
239
+ - A spared remote branch is still checked. If it is gone or its tip moved
240
+ since the plan, the run stops before deleting anything: you spared it for
241
+ what it held, so if that changed, the rest of the plan rests on a wrong
242
+ assumption. The run's first step checks the tip again with `git ls-remote`,
243
+ so one that moves after the check stops the run too.
244
+ - Sparing a worktree also spares its branch, which was only free to delete
245
+ because the worktree was going.
246
+
247
+ ### Rows, verdicts and risks
248
+
249
+ A **row** is one thing that could be deleted, named `<kind>:<name>`. A bare
250
+ name or path works when only one row has it.
251
+
252
+ | Row | What it is | Deleted with |
253
+ | --- | --- | --- |
254
+ | `worktree:<path>` | a linked worktree | `git worktree remove`; when its directory is already gone, the same on its record alone, or `git worktree prune` when the directory is left without its `.git` |
255
+ | `branch:<name>` | a local branch | `git update-ref -d refs/heads/<name> <sha>` |
256
+ | `remote:<remote>/<name>` | a branch on a remote | `git push --force-with-lease=…:<sha> <remote> :refs/heads/<name>` |
257
+
258
+ A **verdict** is what the sweep concluded about a row:
259
+
260
+ | Verdict | Meaning | What `clean` does |
261
+ | --- | --- | --- |
262
+ | `delete` | Its work is on the default branch, or it is otherwise safe to lose | Deletes it, once its risks are allowed |
263
+ | `review` | The signals are mixed, or it holds work | Deletes it only when picked and its risks are allowed |
264
+ | `blocked` | Something rules removal out, such as a lock, a running process, or a submodule that cannot be read | Never deletes it; fix the cause and sweep again |
265
+ | `keep` | Structural (the default branch, the current checkout, the primary worktree), or in use (an open pull request) | Never deletes a structural row; deletes one in use only when picked and `confirm-open-pr` is allowed |
266
+
267
+ The report shows `prune` for a `delete` row whose worktree directory is
268
+ already gone: only the stale record goes.
269
+
270
+ A **risk** is what deleting a row would lose. Each row lists its risks, and
271
+ you approve them by name with `--allow`:
272
+
273
+ | Risk | Meaning |
274
+ | --- | --- |
275
+ | `abandon-unmerged` | Commits not on the default branch and not captured by an exact-head merged PR, or a worktree's (or its submodules') commits and stashes that no remote has. `evidence.uniqueCommits` counts the commits. |
276
+ | `discard-changes` | Uncommitted or untracked files in the worktree or any of its submodules. |
277
+ | `confirm-open-pr` | The branch has an open pull request; deleting the remote branch closes it. |
278
+
279
+ A worktree's submodules are part of it: one `--allow` on the worktree row
280
+ covers the same risk in every submodule. A stash in the worktree itself is
281
+ shared with the main checkout and survives, so it carries no risk.
282
+
283
+ A branch whose worktree is judged `delete` is not held by it: the sweep judges
284
+ the branch on its own and records the worktree's path in `evidence.worktree`,
285
+ so one run removes the worktree and then deletes the branch. `clean` deletes
286
+ such a branch only when the same run removes its worktree, and drops it as
287
+ `held` otherwise. With `--only branch` no worktree is swept, so the branch
288
+ stays held.
289
+
290
+ ### Refused combinations
291
+
292
+ A combination of options is refused when it contradicts itself, or when one of
293
+ its options would have no effect: a flag that is silently ignored leaves you
294
+ believing something that is not true. repo-cleaner never guesses, and no
295
+ option silently overrides another.
296
+
297
+ Refused before anything is deleted, with exit 2. Most are refused before
298
+ anything is swept; a row named two ways (a bare name and its id), and an
299
+ `--allow` for a row, are checked against the plan, after the sweep:
300
+
301
+ | Combination | Why |
302
+ | --- | --- |
303
+ | The same row in `--pick` and `--spare` | Asks for the row to be deleted and kept |
304
+ | `--allow <risk>=<row>` for a spared row, or for a row outside `--pick` | Approves deleting something that will not be deleted |
305
+ | A picked or spared row of a kind `--only` leaves out | The row cannot be in the run |
306
+ | A picked or spared row that is not one of `--branch`'s rows | The same |
307
+ | `--branch` with more than one target, with a remote URL, or naming the default branch | `--branch` means one branch of one checkout, and the default branch is never a candidate |
308
+ | `--plan` with targets or sweep options | The plan already fixed what is swept |
309
+ | `--plan -` without `--yes` or `--dry-run` | stdin carries the plan, so nothing can ask |
310
+ | `--yes` with `--dry-run` | One deletes without asking, the other deletes nothing |
311
+ | `--receipt` with `--dry-run` | Nothing runs, so there is nothing to record |
312
+ | `--offline` or `--provider` without `--remote` | Nothing is fetched or looked up without it |
313
+ | `--remote` with an `--only` that leaves `remote` out | They ask for opposite things |
314
+ | A remote URL target with `--only worktree` or `--only branch`, with `--offline`, or mixed with checkout targets | A remote URL has only remote branches, and it is fetched to be swept |
315
+ | `--all` with any `--format` but `text` | `--all` shows keep rows in the text report only |
316
+
317
+ Refused after the sweep, before deleting, with exit 1:
318
+
319
+ - a picked, spared or allowed row the plan does not have;
320
+ - a row name that matches rows in more than one repository: sweep or plan
321
+ only the repository it is in (a bare name can also be given as its full id,
322
+ when the matches are of different kinds);
323
+ - a spared remote branch that is gone or moved since the plan.
324
+
325
+ Allowed, because nothing contradicts:
326
+
327
+ | Combination | What happens |
328
+ | --- | --- |
329
+ | The same row picked twice | It runs once |
330
+ | `--allow <risk>` with `--allow <risk>=<row>` | The broader one covers it |
331
+ | `--allow` for a risk no row that runs carries | Runs; the preview lists it as `unused` |
332
+ | Picking a `blocked` or `keep` row | It is skipped and shown with the reason; the rest runs |
333
+ | `--only remote` without `--remote` | Asking for remote branches implies the network |
334
+
335
+ ### A remote with no checkout
336
+
337
+ ```bash
338
+ repo-cleaner git@github.com:acme/api.git
339
+ repo-cleaner clean git@github.com:acme/api.git
340
+ ```
341
+
342
+ A remote URL target sweeps a remote's branches when you have no checkout of
343
+ it. It clones the URL once, with `--no-checkout`, into
344
+ `repo-cleaner/remotes/` under the cache directory (`~/Library/Caches` on
345
+ macOS, `$XDG_CACHE_HOME` or `~/.cache` on Linux, `REPO_CLEANER_CACHE_DIR` to
346
+ move it), and sweeps that clone. Later runs reuse the clone and fetch. Only
347
+ remote branches are swept: the clone's own branches and worktree are not
348
+ yours. Deletes are pushed to the URL from the clone, compare-and-swap as
349
+ always. A local bare repository is named as `file:///path/to/repo.git`.
350
+
351
+ The clone is kept, not deleted after the run, so a plan of a remote URL can
352
+ run later from anywhere: the plan names the clone's directory.
353
+ `repo-cleaner cache` lists the clones with their URL and size;
354
+ `repo-cleaner cache remove <url>` drops one, and `cache remove --all` drops
355
+ every one, a clone left by a stopped run included. A plan whose clone was
356
+ removed cannot run; sweep again. The clone is a full one, not
357
+ `--filter=blob:none`: the patch check reads file contents.
358
+
359
+ A clone is reused only while its `origin` fetches from that URL and pushes to
360
+ it alone; `clean --plan` checks the same of a plan's clone before deleting.
361
+ The clone's directory name and every message carry the URL with its
362
+ credentials, query and fragment removed.
363
+
93
364
  ## How it decides
94
365
 
95
366
  Three signals settle most items. The first two are enough alone; the third needs a content check too (see [Patch equivalence](#patch-equivalence)):
@@ -104,11 +375,11 @@ Ancestry is measured against `<remote>/<default>`, so work merged only into an
104
375
  unpushed local default branch does not count. Without a remote, the local
105
376
  default branch is used.
106
377
 
107
- A merged PR that fails the head or base check is kept in `facts.pr` with
108
- `evidence` set to `head-moved` or `other-base`. Fork PRs are ignored.
378
+ A merged PR that fails the head or base check is kept in `evidence.pr` with
379
+ `proof` set to `head-moved` or `other-base`. Fork PRs are ignored.
109
380
 
110
381
  Everything else is held back. A branch that is neither merged nor an ancestor
111
- is `needs-investigation` with the reasons spelled out, not a guess. A worktree
382
+ is `review` with the reasons spelled out, not a guess. A worktree
112
383
  holding uncommitted or unpushed work is never proposed for removal whatever
113
384
  else is true of it — losing an hour of someone's work costs more than any
114
385
  amount of clutter.
@@ -117,24 +388,73 @@ A clean worktree whose HEAD is merged by ancestry alone, with no merged PR
117
388
  or patch match, needs one more sign, because a worktree that `git worktree
118
389
  add -b` just made from the default branch is merged that way too, and
119
390
  removing it would lose the ignored files of work about to start. The sign is
120
- in `facts.branch_used`: `commits` when the branch's reflog records a commit,
391
+ in `evidence.branchUsed`: `commits` when the branch's reflog records a commit,
121
392
  cherry-pick, `am`, or a merge or pull that made a merge commit (a
122
393
  fast-forward only moves the branch), and `upstream` when the branch
123
394
  tracks a branch on a remote, other than the default branch (a branch made
124
395
  with `--track` from `main` or `origin/main` does not count). With either, the worktree
125
- is `safe-to-delete`; with neither (`null`) it stays `needs-investigation`.
396
+ is `delete`; with neither (`null`) it stays `review`.
126
397
 
127
- Some worktrees are held back whatever their branch says. Each lands in
128
- `facts.blockers`, and the row carries no command and has `deletable: false`:
398
+ Some worktrees are held back whatever their branch says and whatever is
399
+ allowed. Each lands in `row.blockers`, the row's verdict is `blocked`, and it
400
+ carries no command and no action:
129
401
 
130
402
  | Blocker | Why |
131
403
  | --- | --- |
132
- | `locked-worktree` | Someone ran `git worktree lock` and the lock's owner is alive or unknown; the reason is in `facts.lock_reason`. A lock whose owner is dead is not a blocker (see below). |
133
- | `live-process` | A process other than an idle shell has its working directory in the worktree (`facts.live_processes`), lock or not. |
134
- | `process-probe-failed` | The process table could not be read (no `lsof`, a timeout, an `lsof` that failed outright), so nobody can say the directory is idle. `facts.process_probe_error` has the message. |
404
+ | `locked-worktree` | Someone ran `git worktree lock` and the lock's owner is alive or unknown; the reason is in `evidence.lockReason`. A lock whose owner is dead is not a blocker (see below). |
405
+ | `live-process` | A process other than an idle shell has its working directory in the worktree (`evidence.liveProcesses`), lock or not. |
406
+ | `process-probe-failed` | The process table could not be read (no `lsof`, a timeout, an `lsof` that failed outright), so nobody can say the directory is idle. `evidence.processProbeError` has the message. |
135
407
  | `hidden-index-flags` | Files marked skip-worktree or assume-unchanged hide edits from `status`. |
136
408
  | `nested-repository` | An untracked or ignored directory holds a repository with its own history. |
137
- | `branch-in-other-worktree` | Another worktree has the same branch checked out (`facts.other_worktrees`). |
409
+ | `branch-in-other-worktree` | Another worktree has the same branch checked out (`evidence.otherWorktrees`). |
410
+ | `submodule-unreadable` | A submodule could not be read, so nobody can say it is safe to lose (see [Submodules](#submodules)). |
411
+
412
+ ### Submodules
413
+
414
+ A linked worktree's submodules keep their git dirs inside the worktree's own
415
+ git dir (`.git/worktrees/<id>/modules/<name>`), not the main checkout's. Removing
416
+ the worktree, or pruning it once its directory is gone, deletes them: a commit
417
+ made in a submodule and never pushed goes with it, and so does a stash or a
418
+ local branch. Plain `git worktree remove` refuses every worktree with a
419
+ checked-out submodule, or with a submodule git dir a `git submodule deinit`
420
+ left behind, whatever state it is in. It takes `--force`, and `--force` also
421
+ skips git's own check for modified and untracked files.
422
+
423
+ So the sweep reads every submodule itself, nested ones through their parents,
424
+ and every left-over submodule git dir, and records what it found in
425
+ `evidence.submodules`. A submodule is judged as part of its worktree: the same
426
+ risks apply wherever they are, and one `--allow` on a worktree row covers its
427
+ submodules too.
428
+
429
+ | Found in a submodule | Effect on the worktree row |
430
+ | --- | --- |
431
+ | Commits on HEAD or a local branch that no remote-tracking ref holds, or a stash | `review`, carries `abandon-unmerged`; the reason names each submodule and what it holds |
432
+ | Modified, staged or untracked files | `review`, carries `discard-changes`; `evidence.submoduleDirtyFiles` counts them |
433
+ | Skip-worktree or assume-unchanged files, nested repositories | `hidden-index-flags`, `nested-repository`, with the submodule's path in front |
434
+ | A git call that failed | `submodule-unreadable` blocker |
435
+
436
+ A worktree whose submodules are all clean and pushed is judged as any other,
437
+ and when it is `delete` its command is `git worktree remove --force`
438
+ (`evidence.forcedForSubmodules` is `true`, and the reason says why). Just before that
439
+ remove, `clean` checks the worktree's own files and every submodule again,
440
+ since git will not, and refuses the step if it would lose anything not allowed. A submodule's
441
+ state is part of the plan fingerprint, so a submodule that moved since the plan
442
+ drops the row first.
443
+
444
+ A worktree whose directory is gone is read through its git dir: unpushed work
445
+ in a left-over submodule git dir makes the prune row a `review` that carries
446
+ `abandon-unmerged`.
447
+ A worktree whose directory is gone is removed by its own record. One whose
448
+ directory is left without its `.git` takes the repository-wide `git worktree
449
+ prune`, which drops every gone worktree, not only the planned one. So such a
450
+ row runs last, and only while every other gone worktree that prune would drop
451
+ goes in the same run; otherwise `clean` drops it (`blocked`, with the reason).
452
+ A run never drops the record of a gone worktree it did not select.
453
+
454
+ Commits count as pushed only when a remote-tracking ref of the submodule holds
455
+ them. A superproject that records a submodule commit reachable from no branch
456
+ on the submodule's remote (fetched by sha, or on a branch since deleted) carries
457
+ `abandon-unmerged` until a fetch brings it under one.
138
458
 
139
459
  ### Patch equivalence
140
460
 
@@ -149,16 +469,16 @@ branch as safe as an ancestor. A second, independent check must agree:
149
469
  `git merge-tree --write-tree <default> <tip>` must come out clean with exactly
150
470
  the default branch's tree, which shows the branch's content is already there. It
151
471
  needs git 2.38 or later; an older git, a conflict or any failure counts as not
152
- agreed. The row is `safe-to-delete` either way, but only an agreed check drops
153
- the `abandon-unmerged` authorization. Without it the row keeps requiring that
154
- grant, and a worktree with unpushed commits stays `needs-investigation`. The row
155
- carries three facts, and its reason says which case applied:
472
+ agreed. The row is `delete` either way, but only an agreed check drops the
473
+ `abandon-unmerged` risk. Without it the row keeps carrying that risk, and a
474
+ worktree with unpushed commits stays `review`. The row carries three pieces of
475
+ evidence, and its reason says which case applied:
156
476
 
157
- | Fact | Value |
477
+ | Evidence | Value |
158
478
  | --- | --- |
159
- | `facts.landing` | `patch-equivalent` |
160
- | `facts.landing_check` | which check passed, below |
161
- | `facts.landing_content_confirmed` | `true` when the merge-tree check agreed, `false` otherwise |
479
+ | `evidence.landing` | `patch-equivalent` |
480
+ | `evidence.landingCheck` | which check passed, below |
481
+ | `evidence.landingContentConfirmed` | `true` when the merge-tree check agreed, `false` otherwise |
162
482
 
163
483
  | `landing_check` | Meaning |
164
484
  | --- | --- |
@@ -168,7 +488,7 @@ carries three facts, and its reason says which case applied:
168
488
 
169
489
  `squashed` accepts any one commit on the default branch with the same net
170
490
  patch, not necessarily one that came from this branch. That is acceptable only
171
- because the content check, or the grant, stands behind it.
491
+ because the content check, or `--allow abandon-unmerged`, stands behind it.
172
492
 
173
493
  The squashed check builds one commit object with `git commit-tree` and no ref.
174
494
  It leaves the index, the working tree and every ref alone, and needs no git
@@ -179,7 +499,7 @@ The check decides what gets deleted, so a doubt is never evidence. None of
179
499
  these count as landed:
180
500
 
181
501
  - a commit that is not on the default branch, however many others are (the
182
- commits that are missing are listed in `facts.unmatched_commits` and in the
502
+ commits that are missing are listed in `evidence.unmatchedCommits` and in the
183
503
  investigation text when part of the work did land);
184
504
  - a git call that fails, or output that cannot be read;
185
505
  - a shallow clone, a missing merge base, a ref that does not resolve;
@@ -193,7 +513,7 @@ A patch id says two changes are the same, not that they come from the same
193
513
  branch, and it ignores whitespace. A one-line change that the default branch
194
514
  also made independently, an indentation-only difference, or work that landed
195
515
  and was then reverted all match by patch id; the content check does not agree
196
- with them, so they keep needing the grant.
516
+ with them, so they keep needing `--allow abandon-unmerged`.
197
517
 
198
518
  ### Locks and running processes
199
519
 
@@ -202,9 +522,9 @@ token from the lock reason, `pid <N>`, optionally followed by `start <time>`,
202
522
  and asks whether that process is still running. It reads nothing else of the
203
523
  reason, so a free-form reason such as `keep, build running` always blocks.
204
524
 
205
- - The owner is dead: the lock is not a blocker. `facts.stale_lock` is `true`,
206
- `facts.lock_owner` holds the pid and start time, and the row's commands begin
207
- with `git worktree unlock`. `apply` unlocks, removes and verifies it, and the
525
+ - The owner is dead: the lock is not a blocker. `evidence.staleLock` is `true`,
526
+ `evidence.lockOwner` holds the pid and start time, and the row's commands begin
527
+ with `git worktree unlock`. `clean` unlocks, removes and verifies it, and the
208
528
  receipt records the `unlock-worktree` step.
209
529
  - The owner is alive, or cannot be ruled out: `locked-worktree`.
210
530
  - A lock writes `pid <N>` and, to guard against pid reuse, `start <time>`. The
@@ -220,7 +540,7 @@ reason, so a free-form reason such as `keep, build running` always blocks.
220
540
  - The `pid` token is trusted as a local pid. A pid from another container or
221
541
  host that shares the filesystem is looked up here, will not be found, and
222
542
  reads as dead; do not sweep a repository whose locks were written elsewhere.
223
- - Before the unlock and again before the remove, `apply` asks again, and reads
543
+ - Before the unlock and again before the remove, `clean` asks again, and reads
224
544
  the process table afresh. If the owner is alive by then, or a process other
225
545
  than an idle shell works in the directory, or the table cannot be read, the
226
546
  step fails and the run stops. A row dropped at the re-sweep touches nothing;
@@ -233,16 +553,16 @@ reason, so a free-form reason such as `keep, build running` always blocks.
233
553
  - Separately, any process whose working directory is inside the worktree blocks
234
554
  it as `live-process`, lock or not. A shell (`sh`, `bash`, `zsh`, `fish`) is
235
555
  idle only when it has no child processes; it is then listed in
236
- `facts.idle_shells` and does not block. A shell with a child, or whose
556
+ `evidence.idleShells` and does not block. A shell with a child, or whose
237
557
  children are unknown, counts as a live process.
238
558
 
239
559
  Both questions go to a `ProcessProbe` (`alive(pid, startedAt?)` and
240
- `holders(path, { fresh? })`), passed as `probe` in the options of `sweepRepo`,
241
- `sweepRepos` and `applyPlan`. The default, `defaultProcessProbe()`, runs
560
+ `holders(path, { fresh? })`), passed as `probe` in the options of `sweep`,
561
+ `check` and `run`. The default, `defaultProcessProbe()`, runs
242
562
  `ps -o lstart=`, `ps -A -o pid=,ppid=` and `lsof -d cwd` through
243
563
  `@osdlc/command-seam`, and needs `ps` and `lsof` on `PATH`. It reads the
244
- process table once per probe and reuses it for every worktree: `sweepRepo`
245
- makes one probe per call, and `applyPlan` uses one probe for its re-sweep and
564
+ process table once per probe and reuses it for every worktree: `sweep` makes
565
+ one probe per repository, and `run` uses one probe for its re-sweep and
246
566
  reads the table afresh for each pre-unlock and pre-remove check. Where the
247
567
  table cannot be read the worktree is held back as `process-probe-failed`. A
248
568
  host or test supplies its own probe.
@@ -255,230 +575,48 @@ says so. See [Providers (prototype)](#providers-prototype).
255
575
  Every branch delete names the full sha the sweep judged. `update-ref -d` and
256
576
  the pinned `--force-with-lease` push both refuse when the branch has moved
257
577
  since, so a commit pushed while you read the plan survives it.
258
- `localDeleteCommand` and `remoteDeleteCommand` build them for hosts that add
259
- their own rows.
260
-
261
- Every row names, in `requires`, the risks its delete carries, so a person
262
- approves them by name:
263
-
264
- | Authorization | When a row needs it |
265
- | --- | --- |
266
- | `abandon-unmerged` | Commits not on the default branch and not captured by an exact-head merged PR; a worktree's unpushed commits. `facts.unique_commits` counts them. |
267
- | `discard-changes` | A worktree has modified, staged or untracked files. |
268
- | `confirm-open-pr` | The branch has an open PR. |
269
-
270
- A row kept for structure (default branch, current HEAD, a branch held by a
271
- worktree, the primary or caller's worktree) has `deletable: false`.
272
-
273
- A branch whose worktree is `safe-to-delete` is not held by it: the sweep judges
274
- the branch on its own and records the worktree's path in `facts.worktree`, so
275
- one run removes the worktree and then deletes the branch. `apply` deletes such
276
- a branch only when the same run removes its worktree, and skips it otherwise.
277
- With `--only branches` no worktree is swept, so the branch stays held.
278
-
279
- ## Commands
280
-
281
- ```bash
282
- repo-cleaner [status] # the report: what is merged, what can go (the default)
283
- repo-cleaner summary # counts per repository: total, merged, delete, prune, review, keep
284
- repo-cleaner plan # the deletable rows as a plan, to stdout or -o <file>
285
- repo-cleaner apply # re-check, show the steps, ask, delete
286
- repo-cleaner cache # list or remove the clones --remote-url keeps
287
- ```
288
-
289
- The first four take the same paths and sweep options. A checkout directory
290
- named like a command is swept as `./<name>`.
291
-
292
- `apply` asks before deleting, and throws away anything typed while the sweep
293
- ran, so a key pressed early cannot answer the question. It says so when it
294
- did.
295
-
296
- ```console
297
- $ repo-cleaner summary --remotes
298
- /Users/me/work/api (acme/api, default main, origin)
299
- total merged delete prune review keep
300
- local branches 12 8 6 0 1 5
301
- worktrees 4 3 1 1 1 1
302
- remote branches 40 31 30 0 6 4
303
-
304
- 37 to delete, 1 to prune, 8 to review, across 1 repository.
305
- ```
306
-
307
- `prune` counts worktree rows whose directory is gone, or is no longer a
308
- checkout: deleting one runs `git worktree prune` to drop the stale link, and
309
- a directory that is still there stays. The report labels those rows `prune`
310
- rather than `delete`. In `--output json` they are `safe-to-delete` with
311
- `action: "prune"`, and each summary bucket has a `prune` count, a part of its
312
- `safe-to-delete` count.
313
-
314
- `merged` counts rows whose work is on the default branch: by ancestry, by a
315
- merged PR at the branch's head, or by a patch match. A branch checked out in a
316
- worktree counts as merged by ancestry or PR alone. The default branch and the
317
- primary worktree are counted in `total` but not judged. Each row's verdict is
318
- in its `facts.merged`.
319
-
320
- ## Scope is opt-in
321
-
322
- ```bash
323
- repo-cleaner # the repository you are standing in
324
- repo-cleaner ~/work/api ~/work/web # named repositories
325
- repo-cleaner ~/Developer # every checkout under it
326
- ```
327
-
328
- A bare invocation never fans out. What an argument names is read off the disk
329
- rather than from how it is written: a path that is itself a checkout names
330
- that repository, and a path that is not is searched for the checkouts under
331
- it, descending through directories that are not checkouts — an owner
332
- directory, a folder per client — and stopping at the ones that are.
333
-
334
- A linked worktree collapses into the repository it belongs to, so a directory
335
- holding a checkout and three of its worktrees sweeps one repository, not four.
336
- Those worktrees still appear — as worktree rows in that repository's own
337
- inventory.
338
578
 
339
- The report ends with what the search cost — `searched 1 entry: 214
340
- directories read, 12 checkouts found.` — because those numbers are how an
341
- entry pointing at the wrong directory gets caught. A search that passes
342
- 10,000 directories says so on stderr while it is still running, and the walk
343
- stops descending three levels below the entry.
344
-
345
- An entry that resolves to nothing is reported rather than dropped. A worklist
346
- that silently sweeps four of the five repositories it was given is worse than
347
- one that says which it could not find.
348
-
349
- ### A remote with no checkout
350
-
351
- ```bash
352
- repo-cleaner --remote-url git@github.com:acme/api.git
353
- repo-cleaner apply --remote-url git@github.com:acme/api.git
354
- ```
355
-
356
- `--remote-url <url>` sweeps a remote's branches when you have no checkout of
357
- it. It clones the URL once, with `--no-checkout`, into
358
- `repo-cleaner/remotes/` under the cache directory (`~/Library/Caches` on
359
- macOS, `$XDG_CACHE_HOME` or `~/.cache` on Linux, `REPO_CLEANER_CACHE_DIR`
360
- to move it), and sweeps that clone. Later runs reuse the clone and fetch. Only remote branches
361
- are swept: the clone's own branches and worktree are not yours. Deletes are
362
- pushed to the URL from the clone, compare-and-swap as always.
363
-
364
- The clone is kept, not deleted after the run, so a plan written with
365
- `--remote-url` can be applied later from anywhere: the plan names the clone's
366
- directory. `repo-cleaner cache` lists the clones with their URL and size;
367
- `repo-cleaner cache remove <url>` drops one, and `cache remove --all` drops
368
- every one, a clone left by a stopped run included. A plan whose clone was
369
- removed cannot be applied; sweep again. The clone is a full one, not
370
- `--filter=blob:none`: the patch check reads file contents, and a partial
371
- clone fetches them one branch at a time.
372
-
373
- The flag repeats for several remotes. It does not combine with paths,
374
- `--branch` or `--plan`, `--only` may only name `remotes`, and an empty URL is
375
- refused. A clone is reused only while its `origin` fetches from that URL and
376
- pushes to it alone; `apply --plan` checks the same of a plan's clone before
377
- deleting. The clone's directory name and every message carry the URL with
378
- its credentials, query and fragment removed.
379
-
380
- ## Options
381
-
382
- Every command that sweeps takes these:
383
-
384
- | Flag | Effect |
385
- | --- | --- |
386
- | `--only <list>` | `worktrees`, `branches`, `remotes` (default: `worktrees,branches`) |
387
- | `--remotes` | Also sweep remote branches. Slower: each one is compared with the default branch |
388
- | `--branch <name\|HEAD>` | One branch of this repository: its worktree and local branch, and its remote branch with `--remotes`. Refuses the default branch |
389
- | `--measure-remote <name>` | The remote whose default branch work is measured against (default `origin`) |
390
- | `--remote-url <url>` | Sweep this remote's branches with no checkout of your own (see [A remote with no checkout](#a-remote-with-no-checkout)); repeatable |
391
- | `--provider <name>` | With `--remotes`: where pull requests come from: `auto` (default, by the remote's host), `github`, `gitlab`, or `none` to classify on git evidence alone |
392
- | `--no-fetch` | With `--remotes`: skip the fetch; the header then says `not fetched`. Without `--remotes`, nothing is fetched |
393
-
394
- `status` also takes:
395
-
396
- | Flag | Effect |
397
- | --- | --- |
398
- | `--keep` | Include `safe-to-keep` rows in the report |
399
- | `--commands` | Print the proposed commands of rows that need no authorization, and nothing else |
400
- | `--output json` | The full inventory, facts included |
401
-
402
- `--version` prints `repo-cleaner <version>` and exits; it must be the first
403
- argument. A checkout directory named like a command (`status`, `summary`,
404
- `plan`, `apply`, `dev`) is swept as `./status` and so on: `dev` runs the tool from a
405
- checkout (see Running from a checkout).
406
-
407
- ## Plan and apply
579
+ ## Plan and clean
408
580
 
409
581
  ```bash
410
- repo-cleaner apply # sweep, show the steps, ask, delete
411
- repo-cleaner apply --only worktrees --yes # no question
412
- repo-cleaner plan -o plan.json # save the plan to review or edit
413
- repo-cleaner apply --plan plan.json --row local_branch:feat/x
414
- repo-cleaner plan | repo-cleaner apply --plan - --yes
582
+ repo-cleaner clean # sweep, show the steps, ask, delete
583
+ repo-cleaner clean --only worktree --yes # no question
584
+ repo-cleaner plan -o plan.json # save the plan to review or edit
585
+ repo-cleaner clean --plan plan.json --pick branch:feat/x
586
+ repo-cleaner plan | repo-cleaner clean --plan - --yes
415
587
  ```
416
588
 
417
- A plan holds each deletable row with a fingerprint of the facts behind its
418
- verdict. `apply` either sweeps and plans in memory, or reads a plan with
419
- `--plan <file>` (`-` reads stdin). With no `--row` it applies every row judged
420
- `safe-to-delete` and drops each row to review as `needs-review`. Name a row
421
- with `--row <category>:<id>` to apply it, review rows included.
422
-
423
- Before it deletes anything, `apply` sweeps again and drops a row that is gone,
424
- no longer deletable, whose facts moved, or that carries a risk you did not
425
- accept with `--allow <risk>` (every row) or `--allow <risk>=<category>:<id>`
426
- (one row). It then shows, per repository, each row it will delete or prune
427
- with the command under it, each row it will skip and why, and the counts, and
428
- asks `Delete N items? [y/N]`:
429
-
430
- ```text
431
- /Users/me/work/api
432
- delete local_branch fix/login-redirect
433
- git update-ref -d refs/heads/fix/login-redirect 3f9c2e1d…
434
- prune worktree records
435
- git -C /Users/me/work/api worktree prune
436
- skip worktree /Users/me/work/api/wt/spike
437
- needs-review: clean but no clear disposability signal; name it with --row to apply it
438
-
439
- would delete: 1 local branch
440
- skipped 1, not deleted: 1 needs-review (name with --row)
441
- Delete 2 items? [y/N]
442
- ```
443
-
444
- The re-check reads only the categories the selected rows are in, so applying
445
- a plan of local rows does not compare every remote branch again. A plan
446
- records in each repository's `fetch` whether its sweep fetched; the re-check
447
- fetches too when it did, so it judges against the same, current default
448
- branch. A plan written before that field re-checks without fetching.
449
-
450
- `--keep remote_branch:<remote>/<branch>` keeps a remote branch while the
451
- rest of its rows go: say, delete the worktree and local branch but leave
452
- the branch on the remote. A kept row is checked, not skipped. The re-check
453
- refuses the run if the branch is gone or its tip moved since the plan, and
454
- the run's first step checks the tip again with `git ls-remote`, so one that
455
- moves after the re-check stops the run before anything is deleted. Only the
456
- tip counts: a PR that closed since the plan does not refuse it. Another
457
- clone of the same remote does not delete a kept branch either; its row is
458
- dropped as `kept`. The row id is the one the plan lists,
459
- `<remote>/<branch>`, e.g. `origin/feat/x`.
460
- `--yes` skips the question. With no terminal to ask on and no `--yes`, it
461
- refuses with exit 2 rather than deleting. `--plan -` needs `--yes` or
462
- `--dry-run`, because stdin carries the plan. `--dry-run` prints the steps and
463
- stops.
464
-
465
- On yes it runs only the steps it showed: worktrees first, then local branches,
466
- remote branches and a prune, in that order. Branch deletes are
467
- compare-and-swap, every step is verified, and the first failure stops the run.
468
-
469
- Every run ends with what it did:
589
+ A plan holds every row with a fingerprint of the evidence behind its verdict,
590
+ and the sweep options it was made with. Without `--pick` only the rows that
591
+ could be deleted run; the blocked and structural rows are there so a pick of
592
+ one is skipped with its reason. `clean`
593
+ either sweeps and plans in memory, or reads a plan with `--plan`. A plan of an
594
+ older version is refused, not translated: sweep again.
595
+
596
+ Before it deletes anything, `clean` sweeps again, with the plan's options, and
597
+ drops a row that is gone (`gone`), that can no longer be deleted (`blocked`,
598
+ `keep`, or `held` for a branch whose worktree this run does not remove), that
599
+ was not picked and is no longer `delete` (`needs-pick`), whose evidence moved
600
+ (`changed`), or that carries a risk you did not `--allow` (`not-allowed`). The
601
+ re-check reads only the kinds the selected rows are in, so running a plan of
602
+ local rows does not compare every remote branch again, and it fetches when the
603
+ plan's sweep did, so it judges against the same, current default branch.
604
+
605
+ On yes it runs only the steps it showed: spared remote tips first, then
606
+ worktrees, local branches and remote branches, in that order. Branch
607
+ deletes are compare-and-swap, every step is verified, and the first failure
608
+ stops the run. Every run ends with what it did:
470
609
 
471
610
  ```text
472
611
  deleted: 1 worktree, 3 local branches, 2 remote branches, 1 stale worktree pruned
473
- skipped 2, not deleted: 1 needs-review (name with --row), 1 duplicate
612
+ skipped 2, not deleted: 1 needs-pick (name with --pick), 1 duplicate
474
613
  status: complete
475
614
  ```
476
615
 
477
- The preview says `would delete:`, and a run that stops at a failed step says
478
- how many rows it did not reach. `--output json` carries the same counts as
479
- `summary`. Two clones of one remote list the same remote branches; `apply`
480
- deletes each once and drops the other clone's row as `duplicate`. Remotes are
481
- compared by the push URL git uses, with `url.*.insteadOf` applied.
616
+ Two clones of one remote list the same remote branches; `clean` deletes each
617
+ once and drops the other clone's row as `duplicate`, or as `spared` when the
618
+ branch is spared through the other clone. Remotes are compared by the push URL
619
+ git uses, with `url.*.insteadOf` applied.
482
620
 
483
621
  A receipt records every step and is rewritten after each one. With
484
622
  `--plan plan.json` it goes beside the plan, as
@@ -490,56 +628,86 @@ that swept, or read its plan from stdin, writes a receipt only when
490
628
 
491
629
  | Code | Meaning |
492
630
  | --- | --- |
493
- | `0` | Swept; or `apply` ran, was declined, or had nothing to run |
494
- | `1` | Failed against the state it found: an unreadable plan, a row not in the plan |
495
- | `2` | Cannot run as typed: an unknown flag, a malformed `--row`, `--branch` with a path, `apply` with nobody to ask and no `--yes` |
496
- | `10` | Nothing to sweep: no entry named a repository |
497
- | `11` | `apply` stopped at a failed step; the receipt says which |
631
+ | `0` | Swept; or `clean` ran, was declined, or had nothing to do |
632
+ | `1` | Failed against the state it found: an unreadable plan, a row not in it, a spared branch that moved |
633
+ | `2` | Cannot run as typed: an unknown flag, a malformed row, a refused combination, `clean` with nobody to ask and no `--yes` |
634
+ | `10` | Nothing to sweep: no target named a repository |
635
+ | `11` | `clean` stopped at a failed step; the receipt says which |
498
636
 
499
637
  ## As a library
500
638
 
501
639
  ```ts
502
- import { sweepRepo, sweepRepos } from '@osdlc/repo-cleaner'
640
+ import { check, plan, render, run, sweep } from '@osdlc/repo-cleaner'
503
641
 
504
- const sweep = sweepRepo('/Users/me/work/api', { noProvider: true })
505
- for (const item of sweep.items) {
506
- console.log(item.classification, item.id, item.reason)
642
+ const result = sweep(['/Users/me/work/api'], { remote: true })
643
+ for (const row of result.repos[0]?.rows ?? []) {
644
+ console.log(row.id, row.verdict, row.why, row.risks)
507
645
  }
508
646
 
509
- const result = sweepRepos(['~/Developer'], { cwd: process.cwd() })
647
+ const preview = check(plan(result), {
648
+ pick: ['branch:spike/cache'],
649
+ allow: [{ risk: 'abandon-unmerged', row: 'branch:spike/cache' }],
650
+ })
651
+ process.stdout.write(render.preview(preview))
652
+ const receipt = run(preview)
510
653
  ```
511
654
 
512
- `sweepRepo` never throws for a repository it cannot read: the failure comes
513
- back as `error`, because a fan-out must not end on its first bad entry.
655
+ | Export | What it does |
656
+ | --- | --- |
657
+ | `sweep(targets, opts?)` | Resolve the targets and judge every row. Options as on the command line: `only`, `remote`, `offline`, `against`, `provider`, `branch`, plus `cwd`, `cacheDir`, `annotate`, `onProgress`, `onWarn`, and the injectable `git`, `gh`, `glab` runners and `probe` |
658
+ | `plan(sweep)` | Every row, fingerprinted, with the sweep's options |
659
+ | `check(plan, approval, opts?)` | Resolve `{ pick, spare, allow }` against the plan, sweep again, and return a `Preview`: the steps, the rows dropped and why, and the allows no row needed. Deletes nothing |
660
+ | `run(preview, opts?)` | Sweep once more and take the preview's steps, writing a `Receipt` (to `receiptPath` after every step, when given) |
661
+ | `inspectWorktree(path, opts?)` | One worktree judged as a sweep judges it, submodules included |
662
+ | `readPlan`, `writePlan` | Plans on disk |
663
+ | `render` | The command line's formats: `text`, `json`, `summary`, `commands`, `preview`, `receipt` |
664
+ | `defaultProcessProbe`, `providers` | The defaults a host can wrap or replace |
665
+ | `RepoCleanerError` | Every refusal, with a `code`: `conflict`, `bad-input`, `not-found`, `ambiguous`, `spared-changed`, `bad-plan` |
666
+
667
+ A row is a `WorktreeRow`, `BranchRow` or `RemoteRow`, told apart by `kind`:
514
668
 
515
- `fetch: true` runs `git fetch --prune --quiet --no-write-fetch-head <remote>`
516
- before classifying, so ancestry is read against fresh remote-tracking refs.
517
- That fetch is the only write. A repository whose fetch fails comes back with `error` starting
518
- `fetch failed:` and no items. `repo.fetched` says which case applied. The
519
- library default is off; the CLI fetches with `--remotes`, unless given `--no-fetch`.
669
+ ```ts
670
+ interface Row {
671
+ kind: 'worktree' | 'branch' | 'remote'
672
+ id: RowId // 'worktree:/p', 'branch:feat/x', 'remote:origin/feat/x'
673
+ name: string // the path, the branch, `<remote>/<branch>`
674
+ verdict: 'delete' | 'review' | 'keep' | 'blocked'
675
+ action: 'remove' | 'prune' | 'delete' | null // null: never deleted
676
+ why: string
677
+ risks: Risk[] // gathered over the row's submodules
678
+ blockers: Blocker[] // non-empty exactly when verdict is 'blocked'
679
+ commands: string[]
680
+ notes: string[] // what is unclear, or what to do about a blocker
681
+ evidence: WorktreeEvidence | BranchEvidence | RemoteEvidence
682
+ }
683
+ ```
684
+
685
+ `evidence` is typed for each kind: a worktree's `path`, `branch`, `head`,
686
+ `dirtyFiles`, `unpushedCommits`, lock and process evidence, and `submodules`;
687
+ a branch's `sha`, `upstream`, `track`; every kind's `merged`, `pr`,
688
+ `uniqueCommits` and landing evidence.
689
+
690
+ `sweep` never throws for a repository it cannot read: that one comes back
691
+ with `error` set, because a fan-out must not end on its first bad entry. It
692
+ throws a `RepoCleanerError` for options that contradict each other, as the
693
+ command line refuses them, before reading anything. `check` throws for an
694
+ approval that contradicts itself, names a row the plan does not have, or
695
+ spares a remote branch that moved; nothing has been touched when it does.
520
696
 
521
697
  ### Adding knowledge the library does not have
522
698
 
523
- Every row carries the facts behind its verdict, and `investigationParts` is a
524
- list of sentences rather than a finished string. That is deliberate: a host
525
- that knows something git does not — an issue tracker, a task corpus — re-ranks
526
- the returned rows and appends its own sentence, instead of reaching into
527
- classification. `applyPlan` takes the same re-ranking as its `annotate` hook,
528
- so the rows it checks again are the rows the host planned from.
699
+ Every row carries the evidence behind its verdict, and `notes` is a list of
700
+ sentences rather than a finished string. That is deliberate: a host that knows
701
+ something git does not — an issue tracker, a task corpus — passes `annotate`
702
+ to `sweep`, `check` and `run`, and re-ranks the rows or appends its own
703
+ sentence, instead of reaching into classification. Because the same hook runs
704
+ in every re-check, the rows a run checks are the rows the host planned from.
529
705
 
530
706
  This works because of how the ladder is ordered. Every verdict the sweep
531
707
  reaches on its own evidence outranks anything a host could contribute, so a
532
- host rarely has to veto one. It mostly sharpens the rows left at
533
- `safe-to-keep` or `needs-investigation`.
534
-
535
- A host that does hold back or demote a worktree row must also keep that
536
- worktree's branch, which the sweep freed because the worktree was going. Pass
537
- the annotated rows through `holdFreedBranches(items, { branchScoped })`, with
538
- `branchScoped` set for a `branch` sweep. `applyPlan` does the same after its
539
- `annotate` hook, so the plan and the run agree.
540
-
541
- An issue tracker's linkage, applied this way, is a host's own layer over the
542
- sweep and `applyPlan`.
708
+ host rarely has to veto one. It mostly sharpens the rows left at `keep` or
709
+ `review`. A worktree the host holds back keeps its branch: the sweep applies
710
+ that rule after the hook.
543
711
 
544
712
  ## Providers (prototype)
545
713
 
@@ -552,11 +720,11 @@ branch.
552
720
  | `github` | `gh` | `gh pr list --state all`, up to 300 |
553
721
  | `gitlab` | `glab` | `glab api projects/<path>/merge_requests?state=all`, 100 a page, up to 300 |
554
722
 
555
- `--provider auto` (the default) reads the remote's host: a host with `gitlab`
723
+ With `--remote`, `--provider auto` (the default) reads the remote's host: a host with `gitlab`
556
724
  in its name is GitLab, any other is GitHub, which is what the tool did before
557
725
  providers existed. Name one with `--provider github|gitlab` for a host the
558
726
  name does not give away, or `--provider none` to skip pull requests. A plan
559
- records the choice, so `apply` re-checks with the same provider.
727
+ records the choice, so `clean` re-checks with the same provider.
560
728
 
561
729
  GitLab maps onto the same evidence: `opened` and `locked` are `OPEN`, `merged`
562
730
  is `MERGED`, `closed` is `CLOSED`, `sha` is the head commit and `target_branch`