@osdlc/repo-cleaner 0.17.2 → 0.19.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +691 -305
  3. package/dist/branches.d.ts +37 -36
  4. package/dist/branches.js +199 -196
  5. package/dist/clean.d.ts +179 -0
  6. package/dist/clean.js +824 -0
  7. package/dist/cli.d.ts +12 -16
  8. package/dist/cli.js +367 -478
  9. package/dist/hooks.d.ts +8 -12
  10. package/dist/hooks.js +2 -2
  11. package/dist/index.d.ts +18 -25
  12. package/dist/index.js +16 -23
  13. package/dist/landing.d.ts +3 -3
  14. package/dist/landing.js +7 -7
  15. package/dist/plan.d.ts +76 -70
  16. package/dist/plan.js +82 -77
  17. package/dist/providers/github.d.ts +2 -1
  18. package/dist/providers/github.js +2 -1
  19. package/dist/providers/gitlab.d.ts +3 -2
  20. package/dist/providers/gitlab.js +14 -8
  21. package/dist/providers/index.d.ts +5 -4
  22. package/dist/providers/index.js +8 -5
  23. package/dist/providers/types.d.ts +8 -2
  24. package/dist/prs.d.ts +35 -7
  25. package/dist/prs.js +45 -12
  26. package/dist/receipt.d.ts +46 -57
  27. package/dist/receipt.js +52 -28
  28. package/dist/reference.d.ts +12 -0
  29. package/dist/reference.js +152 -0
  30. package/dist/remote-mirror.d.ts +9 -1
  31. package/dist/remote-mirror.js +19 -1
  32. package/dist/render.d.ts +66 -61
  33. package/dist/render.js +206 -255
  34. package/dist/resolve.d.ts +3 -3
  35. package/dist/resolve.js +4 -12
  36. package/dist/status-line.d.ts +1 -1
  37. package/dist/status-line.js +5 -5
  38. package/dist/step-board.d.ts +1 -1
  39. package/dist/step-board.js +2 -5
  40. package/dist/submodules.d.ts +120 -0
  41. package/dist/submodules.js +292 -0
  42. package/dist/sweep.d.ts +107 -64
  43. package/dist/sweep.js +333 -119
  44. package/dist/types.d.ts +294 -105
  45. package/dist/types.js +106 -54
  46. package/dist/util.d.ts +13 -0
  47. package/dist/util.js +36 -0
  48. package/dist/version.generated.d.ts +1 -1
  49. package/dist/version.generated.js +1 -1
  50. package/dist/worktrees.d.ts +21 -37
  51. package/dist/worktrees.js +198 -165
  52. package/package.json +5 -5
  53. package/dist/apply.d.ts +0 -131
  54. package/dist/apply.js +0 -541
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,285 @@ 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 is the same as naming the current directory: at a checkout's root
117
+ it sweeps that repository, and in a directory of checkouts it sweeps each
118
+ one. In a subdirectory of a checkout it searches below that subdirectory,
119
+ like any other directory, so name the checkout instead.
120
+ What a target names is read off the disk rather than from how it is written:
121
+ a path that is itself a checkout names that repository, and a path that is
122
+ not is searched for the checkouts under it, descending through directories
123
+ that are not checkouts and stopping at the ones that are. A directory holding
124
+ a checkout and three of its worktrees sweeps one repository, not four; the
125
+ worktrees appear as rows of that repository.
126
+
127
+ The report ends with what the search cost — `searched 1 target: 214
128
+ directories read, 12 checkouts found.` — because those numbers are how a
129
+ target pointing at the wrong directory gets caught. A search that passes
130
+ 10,000 directories says so on stderr while it is still running. A target that
131
+ resolves to nothing is reported rather than dropped.
132
+
133
+ ### Sweep options
134
+
135
+ The report, `plan` and `clean` take these:
136
+
137
+ | Option | Meaning |
138
+ | --- | --- |
139
+ | `--remote` | Also judge remote branches. Turns on the network: fetches each remote first and asks GitHub or GitLab about pull requests. Slower. |
140
+ | `--offline` | With `--remote`: do not fetch. Judge against the remote-tracking refs already on disk, remote branches included, so judging needs no access to the remote. Pull requests are still looked up; add `--provider none` for no network at all. The refs can be stale: see [`--offline` reads what is already on disk](#--offline-reads-what-is-already-on-disk). |
141
+ | `--only <kinds>` | Only these kinds of row: `worktree`, `branch`, `remote`, comma-separated. Default: `worktree,branch`, plus `remote` with `--remote`. Naming `remote` implies `--remote`. |
142
+ | `--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. |
143
+ | `--against <remote>` | The remote whose default branch counts as "merged into". Default: `origin`, then the first remote. |
144
+ | `--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. |
145
+ | `--no-progress` | No progress line on stderr. |
146
+
147
+ ### The report
148
+
149
+ `repo-cleaner [target...]` lists each row with its verdict, why, the risks it
150
+ carries, and the command that would delete it.
151
+
152
+ | Option | Meaning |
153
+ | --- | --- |
154
+ | `--all` | Also show `keep` rows. By default only `delete`, `review` and `blocked` rows are listed. Only with `--format text`. |
155
+ | `--format text` | The report. The default. |
156
+ | `--format summary` | Counts per repository: total, merged, delete, prune, review, blocked, keep. |
157
+ | `--format commands` | Only the commands of `delete` rows that carry no risk, ready to copy. |
158
+ | `--format json` | Everything, evidence included, for scripts. |
159
+
160
+ ```console
161
+ $ repo-cleaner --format summary --remote
162
+ /Users/me/work/api (acme/api, default main, origin)
163
+ total merged delete prune review blocked keep
164
+ local branches 12 8 6 0 1 0 5
165
+ worktrees 4 3 1 1 0 1 1
166
+ remote branches 40 31 30 0 6 0 4
167
+
168
+ 37 to delete, 1 to prune, 7 to review, 1 blocked, across 1 repository.
169
+ ```
170
+
171
+ `prune` counts worktree rows whose directory is gone, or is no longer a
172
+ checkout: deleting one drops the stale record, with `git worktree remove
173
+ <path>` for a directory that is gone (that worktree's record alone) and the
174
+ repository-wide `git worktree prune` for one left without its `.git`, and a
175
+ directory that is still there stays. `merged` counts rows whose work is on
176
+ the default branch, by ancestry, by a merged PR at the branch's head, or by a
177
+ patch match; the default branch and the primary worktree are counted in
178
+ `total` but not judged.
179
+
180
+ ### `repo-cleaner plan`
181
+
182
+ Sweeps and saves every row, each with a fingerprint of the evidence behind its
183
+ verdict, so it can be reviewed now and run later with
184
+ `clean --plan`. `-o, --out <file>` sets where it goes; the default, `-`, is
185
+ stdout, and the totals line goes to stderr.
186
+
187
+ ### `repo-cleaner clean`
188
+
189
+ Sweeps (or reads `--plan`), checks every row again, shows the steps, asks,
190
+ then deletes, compare-and-swap, verifying each step and stopping at the first
191
+ failure. Without `--pick` it runs every row judged `delete`.
192
+
193
+ | Option | Meaning |
194
+ | --- | --- |
195
+ | `--pick <row>` | Work on exactly these rows. See [Pick and spare](#pick-and-spare). Repeatable. |
196
+ | `--spare <row>` | Leave this row alone, and make sure it is what you saw. Repeatable. |
197
+ | `--allow <risk>` | Accept this risk for every row that carries it. |
198
+ | `--allow <risk>=<row>` | Accept it for that one row only. |
199
+ | `--plan <file\|->` | Run a saved plan instead of sweeping now (`-` reads stdin). Rows whose evidence changed since the plan are dropped, not deleted. |
200
+ | `-y, --yes` | Do not ask. Without it, and with no terminal to ask on, `clean` refuses. |
201
+ | `--dry-run` | Check and show the steps, then stop. |
202
+ | `--receipt <file>` | Write a record of every step, rewritten after each one. Default: next to a `--plan` file; none otherwise. |
203
+ | `--format text\|json` | How the steps and the outcome are shown. |
204
+
205
+ ```text
206
+ /Users/me/work/api
207
+ delete branch:fix/login-redirect
208
+ git update-ref -d refs/heads/fix/login-redirect 3f9c2e1d…
209
+ prune worktree:/Users/me/work/wt/old
210
+ git -C /Users/me/work/api worktree remove /Users/me/work/wt/old
211
+ skip worktree:/Users/me/work/wt/spike
212
+ needs-pick: review: clean but no clear disposability signal; pick it to run it
213
+
214
+ would delete: 1 local branch, 1 stale worktree pruned
215
+ skipped 1, not deleted: 1 needs-pick (name with --pick)
216
+ Delete 2 items? [y/N]
217
+ ```
218
+
219
+ `clean` throws away anything typed while the sweep ran, so a key pressed
220
+ early cannot answer the question, and says so when it did.
221
+
222
+ ### Pick and spare
223
+
224
+ **`--pick <row>`** means "work on exactly these rows". It does two things:
225
+
226
+ 1. **It narrows the run.** Only the picked rows are considered; everything
227
+ else is left alone.
228
+ 2. **It opts in rows that never run otherwise.** Without `--pick`, `clean`
229
+ deletes only rows judged `delete`. A `review` row, or a `keep` row with an
230
+ action (a branch with an open pull request), is deleted only when it is
231
+ named, so a borderline row never goes as part of a batch.
232
+
233
+ Picking does not approve anything: a picked row that carries a risk still
234
+ needs `--allow` for it. A `blocked` row, or a `keep` row with no action, is
235
+ skipped and shown with the reason.
236
+
237
+ **`--spare <row>`** means "leave this row alone, and make sure it is what I
238
+ saw". The usual case: delete a merged worktree and its local branch, but keep
239
+ the branch on the remote as a backup.
240
+
241
+ - A spared row is not deleted, even if it is judged `delete`.
242
+ - A spared remote branch is still checked. If it is gone or its tip moved
243
+ since the plan, the run stops before deleting anything: you spared it for
244
+ what it held, so if that changed, the rest of the plan rests on a wrong
245
+ assumption. The run's first step checks the tip again with `git ls-remote`,
246
+ so one that moves after the check stops the run too. This holds under
247
+ `--offline` too: `clean`'s re-check asks the remote, so a spared branch the
248
+ remote deleted refuses the run, whatever the tracking refs on disk say.
249
+ - Sparing a worktree also spares its branch, which was only free to delete
250
+ because the worktree was going.
251
+
252
+ ### Rows, verdicts and risks
253
+
254
+ A **row** is one thing that could be deleted, named `<kind>:<name>`. A bare
255
+ name or path works when only one row has it.
256
+
257
+ | Row | What it is | Deleted with |
258
+ | --- | --- | --- |
259
+ | `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` |
260
+ | `branch:<name>` | a local branch | `git update-ref -d refs/heads/<name> <sha>` |
261
+ | `remote:<remote>/<name>` | a branch on a remote | `git push --force-with-lease=…:<sha> <remote> :refs/heads/<name>` |
262
+
263
+ A **verdict** is what the sweep concluded about a row:
264
+
265
+ | Verdict | Meaning | What `clean` does |
266
+ | --- | --- | --- |
267
+ | `delete` | Its work is on the default branch, or it is otherwise safe to lose | Deletes it, once its risks are allowed |
268
+ | `review` | The signals are mixed, or it holds work | Deletes it only when picked and its risks are allowed |
269
+ | `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 |
270
+ | `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 |
271
+
272
+ The report shows `prune` for a `delete` row whose worktree directory is
273
+ already gone: only the stale record goes.
274
+
275
+ A **risk** is what deleting a row would lose. Each row lists its risks, and
276
+ you approve them by name with `--allow`:
277
+
278
+ | Risk | Meaning |
279
+ | --- | --- |
280
+ | `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. |
281
+ | `discard-changes` | Uncommitted or untracked files in the worktree or any of its submodules. |
282
+ | `confirm-open-pr` | The branch has an open pull request, or open pull requests could not be checked (the CLI is missing or signed out, or the listing failed) so one cannot be ruled out; deleting the remote branch closes it. |
283
+
284
+ A worktree's submodules are part of it: one `--allow` on the worktree row
285
+ covers the same risk in every submodule. A stash in the worktree itself is
286
+ shared with the main checkout and survives, so it carries no risk.
287
+
288
+ A branch whose worktree is judged `delete` is not held by it: the sweep judges
289
+ the branch on its own and records the worktree's path in `evidence.worktree`,
290
+ so one run removes the worktree and then deletes the branch. `clean` deletes
291
+ such a branch only when the same run removes its worktree, and drops it as
292
+ `held` otherwise. With `--only branch` no worktree is swept, so the branch
293
+ stays held.
294
+
295
+ ### Refused combinations
296
+
297
+ A combination of options is refused when it contradicts itself, or when one of
298
+ its options would have no effect: a flag that is silently ignored leaves you
299
+ believing something that is not true. repo-cleaner never guesses, and no
300
+ option silently overrides another.
301
+
302
+ Refused before anything is deleted, with exit 2. Most are refused before
303
+ anything is swept; a row named two ways (a bare name and its id), and an
304
+ `--allow` for a row, are checked against the plan, after the sweep:
305
+
306
+ | Combination | Why |
307
+ | --- | --- |
308
+ | The same row in `--pick` and `--spare` | Asks for the row to be deleted and kept |
309
+ | `--allow <risk>=<row>` for a spared row, or for a row outside `--pick` | Approves deleting something that will not be deleted |
310
+ | A picked or spared row of a kind `--only` leaves out | The row cannot be in the run |
311
+ | A picked or spared row that is not one of `--branch`'s rows | The same |
312
+ | `--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 |
313
+ | `--plan` with targets or sweep options | The plan already fixed what is swept |
314
+ | `--plan -` without `--yes` or `--dry-run` | stdin carries the plan, so nothing can ask |
315
+ | `--yes` with `--dry-run` | One deletes without asking, the other deletes nothing |
316
+ | `--receipt` with `--dry-run` | Nothing runs, so there is nothing to record |
317
+ | `--offline` or `--provider` without `--remote` | Nothing is fetched or looked up without it |
318
+ | `--remote` with an `--only` that leaves `remote` out | They ask for opposite things |
319
+ | 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 |
320
+ | `--all` with any `--format` but `text` | `--all` shows keep rows in the text report only |
321
+
322
+ Refused after the sweep, before deleting, with exit 1:
323
+
324
+ - a picked, spared or allowed row the plan does not have;
325
+ - a row name that matches rows in more than one repository: sweep or plan
326
+ only the repository it is in (a bare name can also be given as its full id,
327
+ when the matches are of different kinds);
328
+ - a spared remote branch that moved since the plan, or that the re-check no
329
+ longer finds.
330
+
331
+ Allowed, because nothing contradicts:
332
+
333
+ | Combination | What happens |
334
+ | --- | --- |
335
+ | The same row picked twice | It runs once |
336
+ | `--allow <risk>` with `--allow <risk>=<row>` | The broader one covers it |
337
+ | `--allow` for a risk no row that runs carries | Runs; the preview lists it as `unused` |
338
+ | Picking a `blocked` or `keep` row | It is skipped and shown with the reason; the rest runs |
339
+ | `--only remote` without `--remote` | Asking for remote branches implies the network |
340
+
341
+ ### A remote with no checkout
342
+
343
+ ```bash
344
+ repo-cleaner git@github.com:acme/api.git
345
+ repo-cleaner clean git@github.com:acme/api.git
346
+ ```
347
+
348
+ A remote URL target sweeps a remote's branches when you have no checkout of
349
+ it. It clones the URL once, with `--no-checkout`, into
350
+ `repo-cleaner/remotes/` under the cache directory (`~/Library/Caches` on
351
+ macOS, `$XDG_CACHE_HOME` or `~/.cache` on Linux, `REPO_CLEANER_CACHE_DIR` to
352
+ move it), and sweeps that clone. Later runs reuse the clone and fetch. Only
353
+ remote branches are swept: the clone's own branches and worktree are not
354
+ yours. Deletes are pushed to the URL from the clone, compare-and-swap as
355
+ always. A local bare repository is named as `file:///path/to/repo.git`.
356
+
357
+ The clone is kept, not deleted after the run, so a plan of a remote URL can
358
+ run later from anywhere: the plan names the clone's directory.
359
+ `repo-cleaner cache` lists the clones with their URL and size;
360
+ `repo-cleaner cache remove <url>` drops one, and `cache remove --all` drops
361
+ every one, a clone left by a stopped run included. A plan whose clone was
362
+ removed cannot run; sweep again. The clone is a full one, not
363
+ `--filter=blob:none`: the patch check reads file contents.
364
+
365
+ A clone is reused only while its `origin` fetches from that URL and pushes to
366
+ it alone; `clean --plan` checks the same of a plan's clone before deleting.
367
+ The clone's directory name and every message carry the URL with its
368
+ credentials, query and fragment removed.
369
+
93
370
  ## How it decides
94
371
 
95
372
  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 +381,11 @@ Ancestry is measured against `<remote>/<default>`, so work merged only into an
104
381
  unpushed local default branch does not count. Without a remote, the local
105
382
  default branch is used.
106
383
 
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.
384
+ A merged PR that fails the head or base check is kept in `evidence.pr` with
385
+ `proof` set to `head-moved` or `other-base`. Fork PRs are ignored.
109
386
 
110
387
  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
388
+ is `review` with the reasons spelled out, not a guess. A worktree
112
389
  holding uncommitted or unpushed work is never proposed for removal whatever
113
390
  else is true of it — losing an hour of someone's work costs more than any
114
391
  amount of clutter.
@@ -117,24 +394,73 @@ A clean worktree whose HEAD is merged by ancestry alone, with no merged PR
117
394
  or patch match, needs one more sign, because a worktree that `git worktree
118
395
  add -b` just made from the default branch is merged that way too, and
119
396
  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,
397
+ in `evidence.branchUsed`: `commits` when the branch's reflog records a commit,
121
398
  cherry-pick, `am`, or a merge or pull that made a merge commit (a
122
399
  fast-forward only moves the branch), and `upstream` when the branch
123
400
  tracks a branch on a remote, other than the default branch (a branch made
124
401
  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`.
402
+ is `delete`; with neither (`null`) it stays `review`.
126
403
 
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`:
404
+ Some worktrees are held back whatever their branch says and whatever is
405
+ allowed. Each lands in `row.blockers`, the row's verdict is `blocked`, and it
406
+ carries no command and no action:
129
407
 
130
408
  | Blocker | Why |
131
409
  | --- | --- |
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. |
410
+ | `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). |
411
+ | `live-process` | A process other than an idle shell has its working directory in the worktree (`evidence.liveProcesses`), lock or not. |
412
+ | `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
413
  | `hidden-index-flags` | Files marked skip-worktree or assume-unchanged hide edits from `status`. |
136
414
  | `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`). |
415
+ | `branch-in-other-worktree` | Another worktree has the same branch checked out (`evidence.otherWorktrees`). |
416
+ | `submodule-unreadable` | A submodule could not be read, so nobody can say it is safe to lose (see [Submodules](#submodules)). |
417
+
418
+ ### Submodules
419
+
420
+ A linked worktree's submodules keep their git dirs inside the worktree's own
421
+ git dir (`.git/worktrees/<id>/modules/<name>`), not the main checkout's. Removing
422
+ the worktree, or pruning it once its directory is gone, deletes them: a commit
423
+ made in a submodule and never pushed goes with it, and so does a stash or a
424
+ local branch. Plain `git worktree remove` refuses every worktree with a
425
+ checked-out submodule, or with a submodule git dir a `git submodule deinit`
426
+ left behind, whatever state it is in. It takes `--force`, and `--force` also
427
+ skips git's own check for modified and untracked files.
428
+
429
+ So the sweep reads every submodule itself, nested ones through their parents,
430
+ and every left-over submodule git dir, and records what it found in
431
+ `evidence.submodules`. A submodule is judged as part of its worktree: the same
432
+ risks apply wherever they are, and one `--allow` on a worktree row covers its
433
+ submodules too.
434
+
435
+ | Found in a submodule | Effect on the worktree row |
436
+ | --- | --- |
437
+ | 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 |
438
+ | Modified, staged or untracked files | `review`, carries `discard-changes`; `evidence.submoduleDirtyFiles` counts them |
439
+ | Skip-worktree or assume-unchanged files, nested repositories | `hidden-index-flags`, `nested-repository`, with the submodule's path in front |
440
+ | A git call that failed | `submodule-unreadable` blocker |
441
+
442
+ A worktree whose submodules are all clean and pushed is judged as any other,
443
+ and when it is `delete` its command is `git worktree remove --force`
444
+ (`evidence.forcedForSubmodules` is `true`, and the reason says why). Just before that
445
+ remove, `clean` checks the worktree's own files and every submodule again,
446
+ since git will not, and refuses the step if it would lose anything not allowed. A submodule's
447
+ state is part of the plan fingerprint, so a submodule that moved since the plan
448
+ drops the row first.
449
+
450
+ A worktree whose directory is gone is read through its git dir: unpushed work
451
+ in a left-over submodule git dir makes the prune row a `review` that carries
452
+ `abandon-unmerged`.
453
+ A worktree whose directory is gone is removed by its own record. One whose
454
+ directory is left without its `.git` takes the repository-wide `git worktree
455
+ prune`, which drops every gone worktree, not only the planned one. So such a
456
+ row runs last, and only while every other gone worktree that prune would drop
457
+ goes in the same run; otherwise `clean` drops it (`blocked`, with the reason).
458
+ A run never drops the record of a gone worktree it did not select.
459
+
460
+ Commits count as pushed only when a remote-tracking ref of the submodule holds
461
+ them. A superproject that records a submodule commit reachable from no branch
462
+ on the submodule's remote (fetched by sha, or on a branch since deleted) carries
463
+ `abandon-unmerged` until a fetch brings it under one.
138
464
 
139
465
  ### Patch equivalence
140
466
 
@@ -149,16 +475,16 @@ branch as safe as an ancestor. A second, independent check must agree:
149
475
  `git merge-tree --write-tree <default> <tip>` must come out clean with exactly
150
476
  the default branch's tree, which shows the branch's content is already there. It
151
477
  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:
478
+ agreed. The row is `delete` either way, but only an agreed check drops the
479
+ `abandon-unmerged` risk. Without it the row keeps carrying that risk, and a
480
+ worktree with unpushed commits stays `review`. The row carries three pieces of
481
+ evidence, and its reason says which case applied:
156
482
 
157
- | Fact | Value |
483
+ | Evidence | Value |
158
484
  | --- | --- |
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 |
485
+ | `evidence.landing` | `patch-equivalent` |
486
+ | `evidence.landingCheck` | which check passed, below |
487
+ | `evidence.landingContentConfirmed` | `true` when the merge-tree check agreed, `false` otherwise |
162
488
 
163
489
  | `landing_check` | Meaning |
164
490
  | --- | --- |
@@ -168,7 +494,7 @@ carries three facts, and its reason says which case applied:
168
494
 
169
495
  `squashed` accepts any one commit on the default branch with the same net
170
496
  patch, not necessarily one that came from this branch. That is acceptable only
171
- because the content check, or the grant, stands behind it.
497
+ because the content check, or `--allow abandon-unmerged`, stands behind it.
172
498
 
173
499
  The squashed check builds one commit object with `git commit-tree` and no ref.
174
500
  It leaves the index, the working tree and every ref alone, and needs no git
@@ -179,7 +505,7 @@ The check decides what gets deleted, so a doubt is never evidence. None of
179
505
  these count as landed:
180
506
 
181
507
  - 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
508
+ commits that are missing are listed in `evidence.unmatchedCommits` and in the
183
509
  investigation text when part of the work did land);
184
510
  - a git call that fails, or output that cannot be read;
185
511
  - a shallow clone, a missing merge base, a ref that does not resolve;
@@ -193,7 +519,7 @@ A patch id says two changes are the same, not that they come from the same
193
519
  branch, and it ignores whitespace. A one-line change that the default branch
194
520
  also made independently, an indentation-only difference, or work that landed
195
521
  and was then reverted all match by patch id; the content check does not agree
196
- with them, so they keep needing the grant.
522
+ with them, so they keep needing `--allow abandon-unmerged`.
197
523
 
198
524
  ### Locks and running processes
199
525
 
@@ -202,9 +528,9 @@ token from the lock reason, `pid <N>`, optionally followed by `start <time>`,
202
528
  and asks whether that process is still running. It reads nothing else of the
203
529
  reason, so a free-form reason such as `keep, build running` always blocks.
204
530
 
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
531
+ - The owner is dead: the lock is not a blocker. `evidence.staleLock` is `true`,
532
+ `evidence.lockOwner` holds the pid and start time, and the row's commands begin
533
+ with `git worktree unlock`. `clean` unlocks, removes and verifies it, and the
208
534
  receipt records the `unlock-worktree` step.
209
535
  - The owner is alive, or cannot be ruled out: `locked-worktree`.
210
536
  - A lock writes `pid <N>` and, to guard against pid reuse, `start <time>`. The
@@ -220,7 +546,7 @@ reason, so a free-form reason such as `keep, build running` always blocks.
220
546
  - The `pid` token is trusted as a local pid. A pid from another container or
221
547
  host that shares the filesystem is looked up here, will not be found, and
222
548
  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
549
+ - Before the unlock and again before the remove, `clean` asks again, and reads
224
550
  the process table afresh. If the owner is alive by then, or a process other
225
551
  than an idle shell works in the directory, or the table cannot be read, the
226
552
  step fails and the run stops. A row dropped at the re-sweep touches nothing;
@@ -233,252 +559,272 @@ reason, so a free-form reason such as `keep, build running` always blocks.
233
559
  - Separately, any process whose working directory is inside the worktree blocks
234
560
  it as `live-process`, lock or not. A shell (`sh`, `bash`, `zsh`, `fish`) is
235
561
  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
562
+ `evidence.idleShells` and does not block. A shell with a child, or whose
237
563
  children are unknown, counts as a live process.
238
564
 
239
565
  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
566
+ `holders(path, { fresh? })`), passed as `probe` in the options of `sweep`,
567
+ `check` and `run`. The default, `defaultProcessProbe()`, runs
242
568
  `ps -o lstart=`, `ps -A -o pid=,ppid=` and `lsof -d cwd` through
243
569
  `@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
570
+ process table once per probe and reuses it for every worktree: `sweep` makes
571
+ one probe per repository, and `run` uses one probe for its re-sweep and
246
572
  reads the table afresh for each pre-unlock and pre-remove check. Where the
247
573
  table cannot be read the worktree is held back as `process-probe-failed`. A
248
574
  host or test supplies its own probe.
249
575
  Nothing is ever killed.
250
576
 
251
- Without the provider's CLI (`gh`, or `glab` for GitLab), or without
252
- authentication, classification proceeds on git evidence alone and the report
253
- says so. See [Providers (prototype)](#providers-prototype).
577
+ When the provider's CLI is missing or signed out, or a lookup fails, the
578
+ sweep keeps going on what git alone can tell it: see
579
+ [When a lookup fails](#when-a-lookup-fails).
254
580
 
255
581
  Every branch delete names the full sha the sweep judged. `update-ref -d` and
256
582
  the pinned `--force-with-lease` push both refuse when the branch has moved
257
583
  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
584
 
261
- Every row names, in `requires`, the risks its delete carries, so a person
262
- approves them by name:
585
+ ## When a lookup fails
263
586
 
264
- | Authorization | When a row needs it |
587
+ A sweep asks three things of the outside world: `git fetch` for the remote's
588
+ current state, `git ls-remote` for its branches, and `gh` or `glab` for pull
589
+ requests. Any of them can fail: the network is down, the repository was
590
+ renamed or deleted, the signed-in account cannot see it, a rate limit.
591
+
592
+ The rule is that a failed lookup costs only what it would have told you. The
593
+ rows that did not need it are judged as usual, the rows that did are judged
594
+ without it, and the repository carries a `note` saying what was missing. Only
595
+ a failed fetch costs the whole repository.
596
+
597
+ | What failed | What you get |
265
598
  | --- | --- |
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. |
599
+ | `gh pr list` / `glab api` (the PR listing) | Every row is judged on git evidence. Remote branches with no known PR need `--allow confirm-open-pr` to delete. One note on the repository. |
600
+ | `gh` / `glab` not installed or not signed in | The provider is never asked, so this is treated like a failed listing: every row is judged on git evidence, remote branches with no known PR need `--allow confirm-open-pr` to delete, and a note on the repository says why. Pass `--provider none` if the remote has no forge, so there is nothing to check. |
601
+ | `git ls-remote`, after a fetch that worked | Remote branches are read from the refs that fetch just wrote. A note on the repository and on each remote row. |
602
+ | `git ls-remote`, in `clean`'s re-check of an `--offline` plan | The run is refused before anything runs. The refs on disk are what the re-check is there to replace, and `clean` does not delete on a remote it cannot ask. |
603
+ | `git fetch` | The repository is an error with no rows. Every row would otherwise be judged against an older default branch than the one you asked for. |
269
604
 
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`.
605
+ ### Example: a repository `gh` cannot see
272
606
 
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.
607
+ The remote names a repository that was deleted, or that your account cannot
608
+ read. In 0.18 and earlier the repository came back as an error, and none of
609
+ it was judged:
278
610
 
279
- ## Commands
611
+ ```console
612
+ $ repo-cleaner --remote --offline
613
+ ~/work/app (acme/app, default main, origin, not fetched)
614
+ error: GitHub has no such pull request, issue or repository, or you cannot see it.
280
615
 
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
616
+ 0 to delete, 0 to review, across 1 repository (1 unreadable).
287
617
  ```
288
618
 
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.
619
+ Now the merged branch is still found, and the note says what was missing:
295
620
 
296
621
  ```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.
622
+ $ repo-cleaner --remote --offline
623
+ ~/work/app (acme/app, default main, origin, not fetched)
624
+ note: Listing PRs with `gh pr list` failed (GitHub has no such pull request, issue or repository, or you cannot see it.); judged on git evidence alone.
625
+ branch feat/login delete fully merged into origin/main
626
+ git update-ref -d refs/heads/feat/login 44d2db93…
627
+ remote origin/feat/login delete fully merged into origin/main; needs --allow confirm-open-pr
628
+ git push --force-with-lease=refs/heads/feat/login:44d2db93… origin :refs/heads/feat/login
629
+
630
+ 2 to delete, 0 to review, across 1 repository.
305
631
  ```
306
632
 
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.
633
+ When the remote may not be on a forge at all, the note also suggests
634
+ `--provider none`: for a host `auto` could only guess was GitHub, or a local
635
+ path swept with `--provider github`. Under `auto`, a local path gets no
636
+ provider in the first place.
313
637
 
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`.
638
+ ### Why the remote branch needs `confirm-open-pr`
319
639
 
320
- ## Scope is opt-in
640
+ Deleting the head branch of an open pull request closes that pull request.
641
+ When the listing works, `repo-cleaner` knows which branches have one. When it
642
+ failed, or could not run because `gh` is missing or signed out, it cannot
643
+ tell, so it does not delete a remote branch on its own say:
321
644
 
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
645
+ ```console
646
+ $ repo-cleaner clean --remote --offline --yes
647
+ repo-cleaner clean: ~/work/app: Listing PRs with `gh pr list` failed (GitHub has no such pull request, …); judged on git evidence alone.
648
+ skipped remote:origin/feat/login — not-allowed (needs confirm-open-pr)
649
+ ok branch:feat/login — git update-ref -d refs/heads/feat/login 44d2db93… (ref does not resolve)
650
+
651
+ deleted: 1 local branch
652
+ skipped 1, not deleted: 1 not-allowed (--allow confirm-open-pr)
653
+ status: complete
326
654
  ```
327
655
 
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.
656
+ The local branch went; the remote branch stayed. To go on, pick one:
333
657
 
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.
658
+ - Fix the lookup (install `gh`, sign in, check the remote URL) and run again.
659
+ - Pass `--provider none` if this remote has no forge at all, so there is no
660
+ pull request to lose.
661
+ - Check for open pull requests yourself, then pass `--allow confirm-open-pr`.
338
662
 
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.
663
+ The same happens when `gh` is not installed or not signed in. Nothing was
664
+ asked, so nothing can rule out an open pull request:
344
665
 
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.
666
+ ```console
667
+ $ repo-cleaner --remote --offline
668
+ ~/work/app (acme/app, default main, origin, not fetched)
669
+ note: Could not check for open PRs: `gh` is not installed, so deleting a remote branch needs confirm-open-pr. Install `gh`, or pass `--allow confirm-open-pr` once you have checked.
670
+ branch feat/login delete fully merged into origin/main
671
+ git update-ref -d refs/heads/feat/login 255acd56…
672
+ remote origin/feat/login delete fully merged into origin/main; needs --allow confirm-open-pr
673
+ git push --force-with-lease=refs/heads/feat/login:255acd56… origin :refs/heads/feat/login
674
+
675
+ 2 to delete, 0 to review, across 1 repository.
676
+ ```
348
677
 
349
- ### A remote with no checkout
678
+ `--provider none` is how you say the remote has no forge: then there is no
679
+ lookup, no note and no gate.
350
680
 
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
- ```
681
+ ### `--offline` reads what is already on disk
355
682
 
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.
683
+ `--offline` does not fetch, and reads remote branches from the
684
+ remote-tracking refs (`refs/remotes/<remote>/`) instead of asking the remote
685
+ with `git ls-remote`. So judging works with no network and with a remote that
686
+ no longer exists. In 0.18 and earlier it still called `git ls-remote`, and a
687
+ remote that could not be read failed the whole repository:
372
688
 
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.
689
+ ```console
690
+ $ repo-cleaner --remote --offline --provider none # 0.18 and earlier
691
+ ~/work/api (default main, origin, not fetched)
692
+ error: git ls-remote: The remote '/srv/git/api.git' does not exist or is not a git repository. …
693
+ ```
379
694
 
380
- ## Options
695
+ ```console
696
+ $ repo-cleaner --remote --offline --provider none # now
697
+ ~/work/api (default main, origin, not fetched)
698
+ branch feat/login delete fully merged into origin/main
699
+ git update-ref -d refs/heads/feat/login 255acd56…
700
+ remote origin/feat/login delete fully merged into origin/main
701
+ git push --force-with-lease=refs/heads/feat/login:255acd56… origin :refs/heads/feat/login
702
+
703
+ 2 to delete, 0 to review, across 1 repository.
704
+ ```
381
705
 
382
- Every command that sweeps takes these:
706
+ `--offline` still asks `gh` or `glab` about pull requests. Add
707
+ `--provider none` to judge with no network at all.
383
708
 
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 |
709
+ The refs on disk can be stale. A remote-tracking ref stays until the next
710
+ `git fetch --prune`, so a branch the forge deleted after its pull request
711
+ merged still shows in the report, and a branch someone pushed to since shows
712
+ its old tip. That is fine for a report, but `clean` deletes on the remote, so
713
+ before it does, its re-check asks the remote itself with `git ls-remote`. A
714
+ branch the remote no longer has is skipped as gone, and one whose tip moved
715
+ is skipped too:
393
716
 
394
- `status` also takes:
717
+ ```console
718
+ $ repo-cleaner clean --remote --offline --provider none --only remote --yes
719
+ skipped remote:origin/feat/search — gone (no longer in the sweep)
720
+ ok remote:origin/feat/cart — git push … origin :refs/heads/feat/cart (ls-remote lists nothing)
395
721
 
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 |
722
+ deleted: 1 remote branch
723
+ skipped 1, not deleted: 1 gone
724
+ status: complete
725
+ ```
401
726
 
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).
727
+ The preview does the same, so `--dry-run` shows what the run will do. If the
728
+ re-check's `git ls-remote` fails, nothing runs, `--dry-run` included.
729
+ A remote branch you `--spare` (see [Pick and spare](#pick-and-spare)) that is
730
+ gone from the remote refuses the run instead, because you spared it for what
731
+ it held. `git fetch --prune` drops the stale tracking refs, so the report
732
+ stops showing them.
733
+
734
+ ### In the library
735
+
736
+ The same information is on the result, for a host to show or act on:
737
+
738
+ - `RepoSweep.notes`: one sentence for each lookup the repository was judged
739
+ without. Empty when everything answered.
740
+ - `RepoInfo.provider.listError`: the provider's own reason, when its listing
741
+ failed.
742
+ - `Preview.notes` (`check`) and `RunOptions.onNotes` (`run`): the same notes
743
+ from their re-checks. A re-check that sweeps again can meet a failure the
744
+ first sweep did not. `check` given the sweep (`CheckOptions.sweep`) reports
745
+ that sweep's notes, plus those of each repository it sweeps again because
746
+ the sweep read its remote branches from disk.
747
+ - `Step.gone` on a receipt step, and `RunSummary.gone`: a remote branch
748
+ someone deleted between the re-check and the run's push, counted apart from
749
+ what the run deleted.
406
750
 
407
- ## Plan and apply
751
+ ```ts
752
+ import { sweep } from '@osdlc/repo-cleaner'
753
+
754
+ const result = sweep(['.'], { remote: true, offline: true, provider: 'github' })
755
+ for (const repo of result.repos) {
756
+ if (repo.error !== null) {
757
+ console.log(`${repo.path}: could not be swept: ${repo.error}`)
758
+ continue
759
+ }
760
+ for (const note of repo.notes) console.log(`note: ${note}`)
761
+ console.log(`listError: ${repo.repo?.provider?.listError ?? '(none)'}`)
762
+ for (const row of repo.rows) console.log(row.id, row.verdict, row.risks.join(', ') || '-')
763
+ }
764
+ ```
408
765
 
409
- ```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
766
+ ```text
767
+ note: Listing PRs with `gh pr list` failed (GitHub has no such pull request, …); judged on git evidence alone.
768
+ listError: GitHub has no such pull request, issue or repository, or you cannot see it.
769
+ worktree:/Users/me/work/app keep -
770
+ branch:feat/login delete -
771
+ branch:main keep -
772
+ remote:origin/feat/login delete confirm-open-pr
773
+ remote:origin/main keep -
415
774
  ```
416
775
 
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.
776
+ The local branch needs no grant: deleting it closes no pull request. The
777
+ remote branch carries `confirm-open-pr`, so `check` drops it unless that risk
778
+ is allowed.
422
779
 
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]`:
780
+ A `PrProvider` you write reports a failure by returning `null` or by
781
+ throwing; either becomes `listError` and a note. Return an empty list only
782
+ when the forge answered and there are no pull requests.
429
783
 
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
784
+ The decision behind this is
785
+ [D-UVUH](docs/planning/decisions/D-UVUH-failed-lookups.md).
438
786
 
439
- would delete: 1 local branch
440
- skipped 1, not deleted: 1 needs-review (name with --row)
441
- Delete 2 items? [y/N]
787
+ ## Plan and clean
788
+
789
+ ```bash
790
+ repo-cleaner clean # sweep, show the steps, ask, delete
791
+ repo-cleaner clean --only worktree --yes # no question
792
+ repo-cleaner plan -o plan.json # save the plan to review or edit
793
+ repo-cleaner clean --plan plan.json --pick branch:feat/x
794
+ repo-cleaner plan | repo-cleaner clean --plan - --yes
442
795
  ```
443
796
 
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:
797
+ A plan holds every row with a fingerprint of the evidence behind its verdict,
798
+ and the sweep options it was made with. Without `--pick` only the rows that
799
+ could be deleted run; the blocked and structural rows are there so a pick of
800
+ one is skipped with its reason. `clean`
801
+ either sweeps and plans in memory, or reads a plan with `--plan`. A plan of an
802
+ older version is refused, not translated: sweep again.
803
+
804
+ Before it deletes anything, `clean` sweeps again, with the plan's options, and
805
+ drops a row that is gone (`gone`), that can no longer be deleted (`blocked`,
806
+ `keep`, or `held` for a branch whose worktree this run does not remove), that
807
+ was not picked and is no longer `delete` (`needs-pick`), whose evidence moved
808
+ (`changed`), or that carries a risk you did not `--allow` (`not-allowed`). The
809
+ re-check reads only the kinds the selected rows are in, so running a plan of
810
+ local rows does not compare every remote branch again, and it fetches when the
811
+ plan's sweep did, so it judges against the same, current default branch.
812
+
813
+ On yes it runs only the steps it showed: spared remote tips first, then
814
+ worktrees, local branches and remote branches, in that order. Branch
815
+ deletes are compare-and-swap, every step is verified, and the first failure
816
+ stops the run. Every run ends with what it did:
470
817
 
471
818
  ```text
472
819
  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
820
+ skipped 2, not deleted: 1 needs-pick (name with --pick), 1 duplicate
474
821
  status: complete
475
822
  ```
476
823
 
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.
824
+ Two clones of one remote list the same remote branches; `clean` deletes each
825
+ once and drops the other clone's row as `duplicate`, or as `spared` when the
826
+ branch is spared through the other clone. Remotes are compared by the push URL
827
+ git uses, with `url.*.insteadOf` applied.
482
828
 
483
829
  A receipt records every step and is rewritten after each one. With
484
830
  `--plan plan.json` it goes beside the plan, as
@@ -490,56 +836,89 @@ that swept, or read its plan from stdin, writes a receipt only when
490
836
 
491
837
  | Code | Meaning |
492
838
  | --- | --- |
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 |
839
+ | `0` | Swept; or `clean` ran, was declined, or had nothing to do |
840
+ | `1` | Failed against the state it found: an unreadable plan, a row not in it, a spared branch that moved |
841
+ | `2` | Cannot run as typed: an unknown flag, a malformed row, a refused combination, `clean` with nobody to ask and no `--yes` |
842
+ | `10` | Nothing to sweep: no target named a repository |
843
+ | `11` | `clean` stopped at a failed step; the receipt says which |
498
844
 
499
845
  ## As a library
500
846
 
501
847
  ```ts
502
- import { sweepRepo, sweepRepos } from '@osdlc/repo-cleaner'
848
+ import { check, plan, render, run, sweep } from '@osdlc/repo-cleaner'
503
849
 
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)
850
+ const result = sweep(['/Users/me/work/api'], { remote: true })
851
+ for (const row of result.repos[0]?.rows ?? []) {
852
+ console.log(row.id, row.verdict, row.why, row.risks)
507
853
  }
508
854
 
509
- const result = sweepRepos(['~/Developer'], { cwd: process.cwd() })
855
+ const preview = check(plan(result), {
856
+ pick: ['branch:spike/cache'],
857
+ allow: [{ risk: 'abandon-unmerged', row: 'branch:spike/cache' }],
858
+ })
859
+ process.stdout.write(render.preview(preview))
860
+ const receipt = run(preview)
510
861
  ```
511
862
 
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.
863
+ | Export | What it does |
864
+ | --- | --- |
865
+ | `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` |
866
+ | `plan(sweep)` | Every row, fingerprinted, with the sweep's options |
867
+ | `check(plan, approval, opts?)` | Resolve `{ pick, spare, allow }` against the plan, sweep again, and return a `Preview`: the steps, the rows dropped and why, the allows no row needed, and `notes`, what the re-check judged without. Deletes nothing |
868
+ | `run(preview, opts?)` | Sweep once more and take the preview's steps, writing a `Receipt` (to `receiptPath` after every step, when given). `onNotes` hears what its own re-check judged without |
869
+ | `inspectWorktree(path, opts?)` | One worktree judged as a sweep judges it, submodules included |
870
+ | `readPlan`, `writePlan` | Plans on disk |
871
+ | `render` | The command line's formats: `text`, `json`, `summary`, `commands`, `preview`, `receipt` |
872
+ | `defaultProcessProbe`, `providers` | The defaults a host can wrap or replace |
873
+ | `RepoCleanerError` | Every refusal, with a `code`: `conflict`, `bad-input`, `not-found`, `ambiguous`, `spared-changed`, `bad-plan` |
874
+
875
+ A row is a `WorktreeRow`, `BranchRow` or `RemoteRow`, told apart by `kind`:
876
+
877
+ ```ts
878
+ interface Row {
879
+ kind: 'worktree' | 'branch' | 'remote'
880
+ id: RowId // 'worktree:/p', 'branch:feat/x', 'remote:origin/feat/x'
881
+ name: string // the path, the branch, `<remote>/<branch>`
882
+ verdict: 'delete' | 'review' | 'keep' | 'blocked'
883
+ action: 'remove' | 'prune' | 'delete' | null // null: never deleted
884
+ why: string
885
+ risks: Risk[] // gathered over the row's submodules
886
+ blockers: Blocker[] // non-empty exactly when verdict is 'blocked'
887
+ commands: string[]
888
+ notes: string[] // what is unclear, or what to do about a blocker
889
+ evidence: WorktreeEvidence | BranchEvidence | RemoteEvidence
890
+ }
891
+ ```
514
892
 
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`.
893
+ `evidence` is typed for each kind: a worktree's `path`, `branch`, `head`,
894
+ `dirtyFiles`, `unpushedCommits`, lock and process evidence, and `submodules`;
895
+ a branch's `sha`, `upstream`, `track`; every kind's `merged`, `pr`,
896
+ `uniqueCommits` and landing evidence.
897
+
898
+ `sweep` never throws for a repository it cannot read: that one comes back
899
+ with `error` set, because a fan-out must not end on its first bad entry. A
900
+ lookup that only adds evidence (a pull request listing, or `ls-remote` after
901
+ the fetch) costs that evidence and not the repository: its rows are judged
902
+ without it, and the repository's `notes` say what was missing. It
903
+ throws a `RepoCleanerError` for options that contradict each other, as the
904
+ command line refuses them, before reading anything. `check` throws for an
905
+ approval that contradicts itself, names a row the plan does not have, or
906
+ spares a remote branch that moved; nothing has been touched when it does.
520
907
 
521
908
  ### Adding knowledge the library does not have
522
909
 
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.
910
+ Every row carries the evidence behind its verdict, and `notes` is a list of
911
+ sentences rather than a finished string. That is deliberate: a host that knows
912
+ something git does not — an issue tracker, a task corpus — passes `annotate`
913
+ to `sweep`, `check` and `run`, and re-ranks the rows or appends its own
914
+ sentence, instead of reaching into classification. Because the same hook runs
915
+ in every re-check, the rows a run checks are the rows the host planned from.
529
916
 
530
917
  This works because of how the ladder is ordered. Every verdict the sweep
531
918
  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`.
919
+ host rarely has to veto one. It mostly sharpens the rows left at `keep` or
920
+ `review`. A worktree the host holds back keeps its branch: the sweep applies
921
+ that rule after the hook.
543
922
 
544
923
  ## Providers (prototype)
545
924
 
@@ -552,11 +931,12 @@ branch.
552
931
  | `github` | `gh` | `gh pr list --state all`, up to 300 |
553
932
  | `gitlab` | `glab` | `glab api projects/<path>/merge_requests?state=all`, 100 a page, up to 300 |
554
933
 
555
- `--provider auto` (the default) reads the remote's host: a host with `gitlab`
556
- in its name is GitLab, any other is GitHub, which is what the tool did before
557
- providers existed. Name one with `--provider github|gitlab` for a host the
934
+ With `--remote`, `--provider auto` (the default) reads the remote's host: a
935
+ remote that names no host (a local path or `file://`) gets no provider, since
936
+ it can have no pull requests; a host with `gitlab` in its name is GitLab; any
937
+ other is GitHub, which is what the tool did before providers existed. Name one with `--provider github|gitlab` for a host the
558
938
  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.
939
+ records the choice, so `clean` re-checks with the same provider.
560
940
 
561
941
  GitLab maps onto the same evidence: `opened` and `locked` are `OPEN`, `merged`
562
942
  is `MERGED`, `closed` is `CLOSED`, `sha` is the head commit and `target_branch`
@@ -570,20 +950,26 @@ Not done yet:
570
950
  - Gitea, Forgejo and Bitbucket.
571
951
  - Mapping a self-hosted host to a provider in config; today it is the host
572
952
  name or `--provider`.
573
- - Auth edge cases: tokens without API scope, `glab` signed in to a different
574
- host than the remote, rate limits. Each reads as "no PR found".
953
+ - Auth edge cases. A token without API scope, or a rate limit, is a failed
954
+ listing: the rows are judged on git evidence with a note (see
955
+ [When a lookup fails](#when-a-lookup-fails)). `glab` signed in only to a
956
+ different host than the remote's reads as not signed in for that host,
957
+ which gates remote deletes the same way.
575
958
  - Squash metadata. GitLab reports `squash_commit_sha`; it is not used, so a
576
959
  squashed MR proves landing only by its head commit, as on GitHub.
577
960
  - GitLab under a sub-path (`https://host/gitlab/group/proj`). The URL does not
578
961
  say where the install ends and the project begins, so the whole path is read
579
962
  as the project, `glab` is asked for a project that does not exist, and the
580
- listing fails. That reads as "no PR found": rows stay at `review`, nothing is
581
- deleted on its account.
963
+ listing fails. The rows are judged on git evidence with a note, and a remote
964
+ branch needs `--allow confirm-open-pr` to delete.
582
965
 
583
966
  ## Runtime dependencies
584
967
 
585
- `git` on `PATH`, and `gh` (or `glab` for GitLab) when PR state should count.
586
- Authentication is the CLI's own — this package reads no token.
968
+ `git` on `PATH`, and `gh` (or `glab` for GitLab) for pull request evidence.
969
+ Without it, deleting a remote branch needs `--allow confirm-open-pr`, or
970
+ `--provider none` for a remote with no forge (see
971
+ [When a lookup fails](#when-a-lookup-fails)). Authentication is the CLI's
972
+ own — this package reads no token.
587
973
 
588
974
  ## Running from a checkout
589
975