@osdlc/repo-cleaner 0.10.1 → 0.13.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/CHANGELOG.md +43 -0
- package/README.md +135 -19
- package/dist/apply.d.ts +30 -1
- package/dist/apply.js +118 -17
- package/dist/cli.d.ts +6 -1
- package/dist/cli.js +192 -30
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/plan.d.ts +3 -0
- package/dist/plan.js +14 -1
- package/dist/receipt.d.ts +4 -0
- package/dist/receipt.js +2 -0
- package/dist/remote-mirror.d.ts +55 -0
- package/dist/remote-mirror.js +123 -0
- package/dist/render.d.ts +48 -5
- package/dist/render.js +151 -29
- package/dist/status-line.d.ts +4 -2
- package/dist/status-line.js +11 -4
- package/dist/step-board.d.ts +34 -0
- package/dist/step-board.js +101 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.js +14 -1
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worktrees.d.ts +12 -0
- package/dist/worktrees.js +58 -1
- package/package.json +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# @osdlc/repo-cleaner
|
|
2
2
|
|
|
3
|
+
## 0.13.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3005587: A clean worktree merged into the default branch by ancestry alone is now `safe-to-delete` when its branch shows it was worked on: its reflog records a commit, cherry-pick, `am`, or a merge or pull that made a merge commit, or it tracks a branch on a remote other than the default branch. The sign is in the new `facts.branch_used` (`commits`, `upstream` or `null`). A worktree that looks freshly made from the default branch stays `needs-investigation`, as before. (#582)
|
|
8
|
+
|
|
9
|
+
A plan now records, per repository, whether its sweep fetched (`fetch`, default `false` for older plans), and `applyPlan` fetches before its re-check when it did, so the re-check judges against the same current default branch. `ApplyOptions.fetch` overrides it. A failed fetch fails the run before any step. (#584)
|
|
10
|
+
|
|
11
|
+
New `ApplyOptions.keep` and `apply --keep remote_branch:<remote>/<branch>`: keep a remote branch while its other rows are applied, and refuse the run if its tip moved. The re-check throws the new `ApplyRefused` when a kept branch is gone or moved since the plan, and a `verify-remote` step, run before any delete, checks the tip again with `ls-remote`. Only the tip is compared, so PR facts that changed do not refuse it, and another clone of the same remote drops its row for the kept branch with the new reason `kept`. The receipt's step `action` gains `verify-remote`, and `dropped[].reason` gains `kept`. (#583)
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 9948d90: In a project that has this tool in its `node_modules`, the global command now runs the project's copy, unless a `dev link` or `<NAME>_HOME` checkout is set. Set `<NAME>_PROJECT=0` to keep the global copy.
|
|
16
|
+
- 79407f5: Depend on `zod` `^4.6.6` instead of exactly `4.4.3`. 4.4.3 is covered by GHSA-mrhx-c7xc-m3w5 (memory exhaustion validating an oversized constrained collection; fixed in 4.6.6), and the exact pin made a consumer that already had a fixed `zod` install a second, vulnerable copy.
|
|
17
|
+
- Updated dependencies [9948d90]
|
|
18
|
+
- @osdlc/cli-tool@0.14.0
|
|
19
|
+
|
|
20
|
+
## 0.12.0
|
|
21
|
+
|
|
22
|
+
### Minor Changes
|
|
23
|
+
|
|
24
|
+
- 8c832df: **Breaking:** a sweep is local and offline by default. Every sweeping command now reads worktrees and local branches only, fetches nothing and never calls `gh`, which is much faster. `--remotes` (or `--only remotes`, or `--remote-url`) turns on all three: the fetch, the `gh` pull-request lookup and remote branches. `--no-fetch` and `--no-gh` still turn one off again. A local text report ends with a line saying what it left out. A plan written from a local sweep records `no_gh`, so `apply` does not call `gh` either. `--branch` follows the same rule: its remote branch is included only with `--remotes`.
|
|
25
|
+
|
|
26
|
+
`apply` re-checks only the categories its selected rows are in, so applying local rows from a plan no longer compares every remote branch again.
|
|
27
|
+
|
|
28
|
+
On a terminal, the report, the counts and the apply preview are colored: red for delete, blue for prune, yellow for review and skipped rows, green for keep. `NO_COLOR` turns it off; a pipe or file gets none unless `FORCE_COLOR` asks for it. The preview shown before `Delete N items?` now lists, per repository, each row to delete or prune with its command beneath, each skipped row with its reason, and the counts, with terminal controls stripped from paths, names and commands.
|
|
29
|
+
|
|
30
|
+
On a terminal, a long sweep step draws a progress bar on the status line. `apply` draws its steps as a vertical list redrawn in place: each row has its own bar, empty while waiting, half full while running, full with `✓` or `✗` when done, plus a bar for the whole run; a list taller than the terminal keeps the running step in view, and a failed step's error follows in full. It does not list the steps again under the counts. `ApplyOptions.onStart` (the planned steps, updated in place as they run) and `ApplyOptions.onStepDone` (each step after it finishes) are new.
|
|
31
|
+
|
|
32
|
+
## 0.11.0
|
|
33
|
+
|
|
34
|
+
### Minor Changes
|
|
35
|
+
|
|
36
|
+
- 14d4351: A worktree row whose directory is gone, or is no longer a checkout, now reads `prune` instead of `delete` in the report, and `summary` counts those rows in a `prune` column (and a `prune` count in each JSON bucket, a part of `safe-to-delete`). The row's `action` is `prune`.
|
|
37
|
+
|
|
38
|
+
New `--remote-url <url>` on every sweeping command: sweep a remote's branches with no checkout of your own. The URL is cloned once (`--no-checkout`) under the cache directory and reused; deletes are pushed from that clone, so a plan written with `--remote-url` can be applied later from anywhere.
|
|
39
|
+
|
|
40
|
+
### Patch Changes
|
|
41
|
+
|
|
42
|
+
- Updated dependencies [14d4351]
|
|
43
|
+
- Updated dependencies [dd2265e]
|
|
44
|
+
- @osdlc/cli-tool@0.13.0
|
|
45
|
+
|
|
3
46
|
## 0.10.1
|
|
4
47
|
|
|
5
48
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -2,17 +2,52 @@
|
|
|
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
|
|
6
|
-
remote branches, classifying each
|
|
7
|
-
`needs-investigation` — with the
|
|
8
|
-
command that would act on it.
|
|
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.
|
|
9
9
|
|
|
10
10
|
`repo-cleaner` (or `repo-cleaner status`) never deletes anything. It prints
|
|
11
11
|
the commands that would. `repo-cleaner apply` deletes: it checks each row
|
|
12
|
-
again, shows you
|
|
12
|
+
again, shows you what it will delete and skip, and asks before it runs.
|
|
13
|
+
|
|
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:
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
██████████ ✓ delete local_branch feat/a
|
|
35
|
+
█████░░░░░ › delete local_branch feat/b
|
|
36
|
+
░░░░░░░░░░ delete remote_branch origin/feat/b
|
|
37
|
+
░░░░░░░░░░ prune
|
|
38
|
+
████████░░░░░░░░░░░░░░░░░░░░░░ 1/4
|
|
39
|
+
```
|
|
40
|
+
|
|
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.
|
|
13
48
|
|
|
14
49
|
```console
|
|
15
|
-
$ repo-cleaner
|
|
50
|
+
$ repo-cleaner --remotes
|
|
16
51
|
/Users/me/work/api (acme/api, default main, origin)
|
|
17
52
|
local_branch fix/login-redirect delete PR #412 merged
|
|
18
53
|
git update-ref -d refs/heads/fix/login-redirect 3f9c2e1d8b7a6c5d4e3f2a1b0c9d8e7f6a5b4c3d
|
|
@@ -56,6 +91,17 @@ holding uncommitted or unpushed work is never proposed for removal whatever
|
|
|
56
91
|
else is true of it — losing an hour of someone's work costs more than any
|
|
57
92
|
amount of clutter.
|
|
58
93
|
|
|
94
|
+
A clean worktree whose HEAD is merged by ancestry alone, with no merged PR
|
|
95
|
+
or patch match, needs one more sign, because a worktree that `git worktree
|
|
96
|
+
add -b` just made from the default branch is merged that way too, and
|
|
97
|
+
removing it would lose the ignored files of work about to start. The sign is
|
|
98
|
+
in `facts.branch_used`: `commits` when the branch's reflog records a commit,
|
|
99
|
+
cherry-pick, `am`, or a merge or pull that made a merge commit (a
|
|
100
|
+
fast-forward only moves the branch), and `upstream` when the branch
|
|
101
|
+
tracks a branch on a remote, other than the default branch (a branch made
|
|
102
|
+
with `--track` from `main` or `origin/main` does not count). With either, the worktree
|
|
103
|
+
is `safe-to-delete`; with neither (`null`) it stays `needs-investigation`.
|
|
104
|
+
|
|
59
105
|
Some worktrees are held back whatever their branch says. Each lands in
|
|
60
106
|
`facts.blockers`, and the row carries no command and has `deletable: false`:
|
|
61
107
|
|
|
@@ -205,7 +251,7 @@ worktree, the primary or caller's worktree) has `deletable: false`.
|
|
|
205
251
|
|
|
206
252
|
```bash
|
|
207
253
|
repo-cleaner [status] # the report: what is merged, what can go (the default)
|
|
208
|
-
repo-cleaner summary # counts per repository: total, merged, delete, review, keep
|
|
254
|
+
repo-cleaner summary # counts per repository: total, merged, delete, prune, review, keep
|
|
209
255
|
repo-cleaner plan # the deletable rows as a plan, to stdout or -o <file>
|
|
210
256
|
repo-cleaner apply # re-check, show the steps, ask, delete
|
|
211
257
|
```
|
|
@@ -213,16 +259,23 @@ repo-cleaner apply # re-check, show the steps, ask, delete
|
|
|
213
259
|
All four take the same paths and sweep options.
|
|
214
260
|
|
|
215
261
|
```console
|
|
216
|
-
$ repo-cleaner summary
|
|
262
|
+
$ repo-cleaner summary --remotes
|
|
217
263
|
/Users/me/work/api (acme/api, default main, origin)
|
|
218
|
-
total merged delete review keep
|
|
219
|
-
local branches 12 8 6 1 5
|
|
220
|
-
worktrees 4 3
|
|
221
|
-
remote branches 40 31 30 6 4
|
|
264
|
+
total merged delete prune review keep
|
|
265
|
+
local branches 12 8 6 0 1 5
|
|
266
|
+
worktrees 4 3 1 1 1 1
|
|
267
|
+
remote branches 40 31 30 0 6 4
|
|
222
268
|
|
|
223
|
-
|
|
269
|
+
37 to delete, 1 to prune, 8 to review, across 1 repository.
|
|
224
270
|
```
|
|
225
271
|
|
|
272
|
+
`prune` counts worktree rows whose directory is gone, or is no longer a
|
|
273
|
+
checkout: deleting one runs `git worktree prune` to drop the stale link, and
|
|
274
|
+
a directory that is still there stays. The report labels those rows `prune`
|
|
275
|
+
rather than `delete`. In `--output json` they are `safe-to-delete` with
|
|
276
|
+
`action: "prune"`, and each summary bucket has a `prune` count, a part of its
|
|
277
|
+
`safe-to-delete` count.
|
|
278
|
+
|
|
226
279
|
`merged` counts rows whose work is on the default branch: by ancestry, by a
|
|
227
280
|
merged PR at the branch's head, or by a patch match. A branch checked out in a
|
|
228
281
|
worktree counts as merged by ancestry or PR alone. The default branch and the
|
|
@@ -258,17 +311,47 @@ An entry that resolves to nothing is reported rather than dropped. A worklist
|
|
|
258
311
|
that silently sweeps four of the five repositories it was given is worse than
|
|
259
312
|
one that says which it could not find.
|
|
260
313
|
|
|
314
|
+
### A remote with no checkout
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
repo-cleaner --remote-url git@github.com:acme/api.git
|
|
318
|
+
repo-cleaner apply --remote-url git@github.com:acme/api.git
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
`--remote-url <url>` sweeps a remote's branches when you have no checkout of
|
|
322
|
+
it. It clones the URL once, with `--no-checkout`, into
|
|
323
|
+
`repo-cleaner/remotes/` under the cache directory (`~/Library/Caches` on
|
|
324
|
+
macOS, `$XDG_CACHE_HOME` or `~/.cache` on Linux, `REPO_CLEANER_CACHE_DIR`
|
|
325
|
+
to move it), and sweeps that clone. Later runs reuse the clone and fetch. Only remote branches
|
|
326
|
+
are swept: the clone's own branches and worktree are not yours. Deletes are
|
|
327
|
+
pushed to the URL from the clone, compare-and-swap as always.
|
|
328
|
+
|
|
329
|
+
The clone is kept, not deleted after the run, so a plan written with
|
|
330
|
+
`--remote-url` can be applied later from anywhere: the plan names the clone's
|
|
331
|
+
directory. Remove the directory to drop it. The clone is a full one, not
|
|
332
|
+
`--filter=blob:none`: the patch check reads file contents, and a partial
|
|
333
|
+
clone fetches them one branch at a time.
|
|
334
|
+
|
|
335
|
+
The flag repeats for several remotes. It does not combine with paths,
|
|
336
|
+
`--branch` or `--plan`, `--only` may only name `remotes`, and an empty URL is
|
|
337
|
+
refused. A clone is reused only while its `origin` fetches from that URL and
|
|
338
|
+
pushes to it alone; `apply --plan` checks the same of a plan's clone before
|
|
339
|
+
deleting. The clone's directory name and every message carry the URL with
|
|
340
|
+
its credentials, query and fragment removed.
|
|
341
|
+
|
|
261
342
|
## Options
|
|
262
343
|
|
|
263
344
|
Every command that sweeps takes these:
|
|
264
345
|
|
|
265
346
|
| Flag | Effect |
|
|
266
347
|
| --- | --- |
|
|
267
|
-
| `--only <list>` | `worktrees`, `branches`, `remotes` (default:
|
|
268
|
-
| `--
|
|
348
|
+
| `--only <list>` | `worktrees`, `branches`, `remotes` (default: `worktrees,branches`) |
|
|
349
|
+
| `--remotes` | Also sweep remote branches. Slower: each one is compared with the default branch |
|
|
350
|
+
| `--branch <name\|HEAD>` | One branch of this repository: its worktree and local branch, and its remote branch with `--remotes`. Refuses the default branch |
|
|
269
351
|
| `--remote <name>` | Remote to measure against (default `origin`) |
|
|
270
|
-
| `--
|
|
271
|
-
| `--no-
|
|
352
|
+
| `--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 |
|
|
353
|
+
| `--no-gh` | With `--remotes`: classify on git evidence alone. Without `--remotes`, `gh` is never called |
|
|
354
|
+
| `--no-fetch` | With `--remotes`: skip the fetch; the header then says `not fetched`. Without `--remotes`, nothing is fetched |
|
|
272
355
|
|
|
273
356
|
`status` also takes:
|
|
274
357
|
|
|
@@ -302,7 +385,40 @@ with `--row <category>:<id>` to apply it, review rows included.
|
|
|
302
385
|
Before it deletes anything, `apply` sweeps again and drops a row that is gone,
|
|
303
386
|
no longer deletable, whose facts moved, or that carries a risk you did not
|
|
304
387
|
accept with `--allow <risk>` (every row) or `--allow <risk>=<category>:<id>`
|
|
305
|
-
(one row). It
|
|
388
|
+
(one row). It then shows, per repository, each row it will delete or prune
|
|
389
|
+
with the command under it, each row it will skip and why, and the counts, and
|
|
390
|
+
asks `Delete N items? [y/N]`:
|
|
391
|
+
|
|
392
|
+
```text
|
|
393
|
+
/Users/me/work/api
|
|
394
|
+
delete local_branch fix/login-redirect
|
|
395
|
+
git update-ref -d refs/heads/fix/login-redirect 3f9c2e1d…
|
|
396
|
+
prune
|
|
397
|
+
git -C /Users/me/work/api worktree prune
|
|
398
|
+
skip worktree /Users/me/work/api/wt/spike
|
|
399
|
+
needs-review: clean but no clear disposability signal; name it with --row to apply it
|
|
400
|
+
|
|
401
|
+
would delete: 1 local branch
|
|
402
|
+
dropped 1: 1 needs-review
|
|
403
|
+
Delete 2 items? [y/N]
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
The re-check reads only the categories the selected rows are in, so applying
|
|
407
|
+
a plan of local rows does not compare every remote branch again. A plan
|
|
408
|
+
records in each repository's `fetch` whether its sweep fetched; the re-check
|
|
409
|
+
fetches too when it did, so it judges against the same, current default
|
|
410
|
+
branch. A plan written before that field re-checks without fetching.
|
|
411
|
+
|
|
412
|
+
`--keep remote_branch:<remote>/<branch>` keeps a remote branch while the
|
|
413
|
+
rest of its rows go: say, delete the worktree and local branch but leave
|
|
414
|
+
the branch on the remote. A kept row is checked, not skipped. The re-check
|
|
415
|
+
refuses the run if the branch is gone or its tip moved since the plan, and
|
|
416
|
+
the run's first step checks the tip again with `git ls-remote`, so one that
|
|
417
|
+
moves after the re-check stops the run before anything is deleted. Only the
|
|
418
|
+
tip counts: a PR that closed since the plan does not refuse it. Another
|
|
419
|
+
clone of the same remote does not delete a kept branch either; its row is
|
|
420
|
+
dropped as `kept`. The row id is the one the plan lists,
|
|
421
|
+
`<remote>/<branch>`, e.g. `origin/feat/x`.
|
|
306
422
|
`--yes` skips the question. With no terminal to ask on and no `--yes`, it
|
|
307
423
|
refuses with exit 2 rather than deleting. `--plan -` needs `--yes` or
|
|
308
424
|
`--dry-run`, because stdin carries the plan. `--dry-run` prints the steps and
|
|
@@ -362,7 +478,7 @@ back as `error`, because a fan-out must not end on its first bad entry.
|
|
|
362
478
|
before classifying, so ancestry is read against fresh remote-tracking refs.
|
|
363
479
|
That fetch is the only write. A repository whose fetch fails comes back with `error` starting
|
|
364
480
|
`fetch failed:` and no items. `repo.fetched` says which case applied. The
|
|
365
|
-
library default is off; the CLI fetches unless given `--no-fetch`.
|
|
481
|
+
library default is off; the CLI fetches with `--remotes`, unless given `--no-fetch`.
|
|
366
482
|
|
|
367
483
|
### Adding knowledge the library does not have
|
|
368
484
|
|
package/dist/apply.d.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Branch deletes are compare-and-swap on the planned sha. Every step is
|
|
15
15
|
* verified, and the receipt is rewritten after each.
|
|
16
16
|
*/
|
|
17
|
-
import type
|
|
17
|
+
import { type CommandRunner } from '@osdlc/command-seam';
|
|
18
18
|
import type { HostHooks } from './hooks.ts';
|
|
19
19
|
import { type ProcessProbe } from './processes.ts';
|
|
20
20
|
import { type Plan } from './plan.ts';
|
|
@@ -61,14 +61,43 @@ export interface ApplyOptions extends HostHooks {
|
|
|
61
61
|
swept?: ReadonlyMap<string, readonly SweepItem[]> | undefined;
|
|
62
62
|
/** Rows dropped before this run, such as by a preview; recorded first in the receipt. */
|
|
63
63
|
dropped?: readonly Dropped[] | undefined;
|
|
64
|
+
/**
|
|
65
|
+
* Remote branch rows to keep: not deleted, but checked. Each must be a
|
|
66
|
+
* `remote_branch` row of the plan; its id is `<remote>/<branch>`, as in the
|
|
67
|
+
* plan, e.g. `origin/topic`. The re-check refuses the run with
|
|
68
|
+
* {@link ApplyRefused} when a kept branch is gone or its tip is not the
|
|
69
|
+
* planned one, and the run's first steps check each tip again with
|
|
70
|
+
* `ls-remote`, so a branch that moves after the re-check stops the run
|
|
71
|
+
* before anything is deleted. A kept row cannot also be selected.
|
|
72
|
+
*/
|
|
73
|
+
keep?: readonly RowSelection[] | undefined;
|
|
74
|
+
/**
|
|
75
|
+
* Fetch each repository's remote before the re-sweep. Defaults to what the
|
|
76
|
+
* plan repository records in `fetch`: a plan swept after a fetch is
|
|
77
|
+
* re-checked after one. Remote branch tips are read live either way; the
|
|
78
|
+
* fetch moves the default branch those tips and the local refs are judged
|
|
79
|
+
* against. A failed fetch fails the run before any step.
|
|
80
|
+
*/
|
|
81
|
+
fetch?: boolean | undefined;
|
|
64
82
|
/** Passed to the re-sweep's `onProgress`, for a status line. */
|
|
65
83
|
onSweepProgress?: ((progress: SweepProgress) => void) | undefined;
|
|
84
|
+
/**
|
|
85
|
+
* Told once the steps are planned, before the first runs, with the list
|
|
86
|
+
* itself: each step's `result` is updated in place as the run goes. Not
|
|
87
|
+
* called under `dryRun`.
|
|
88
|
+
*/
|
|
89
|
+
onStart?: ((steps: readonly Step[]) => void) | undefined;
|
|
66
90
|
/** Told before each step runs: its 0-based index, the step count and the step. Not called under `dryRun`. */
|
|
67
91
|
onStep?: ((index: number, total: number, step: Step) => void) | undefined;
|
|
92
|
+
/** Told after each step, with its `result` set (`ok`, or `failed` and the run stops), before the receipt is saved. Not called under `dryRun`. */
|
|
93
|
+
onStepDone?: ((index: number, total: number, step: Step) => void) | undefined;
|
|
68
94
|
}
|
|
69
95
|
/** A selection or grant the plan cannot satisfy. Nothing has been touched. */
|
|
70
96
|
export declare class ApplyArgumentError extends Error {
|
|
71
97
|
}
|
|
98
|
+
/** A kept row is gone or moved since the plan, so the run refuses. Nothing has been touched. */
|
|
99
|
+
export declare class ApplyRefused extends Error {
|
|
100
|
+
}
|
|
72
101
|
/** `<category>:<id>`, split at the first colon. */
|
|
73
102
|
export declare function parseRowRef(text: string): RowRef;
|
|
74
103
|
/** `<name>` for every row, or `<name>=<category>:<id>` for one. */
|
package/dist/apply.js
CHANGED
|
@@ -16,18 +16,22 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { existsSync } from 'node:fs';
|
|
18
18
|
import { resolve } from 'node:path';
|
|
19
|
+
import { shellJoin } from '@osdlc/command-seam';
|
|
19
20
|
import { CommandFailed, Git, PUSH_REJECTIONS } from '@osdlc/typed-git';
|
|
20
21
|
import { localDeleteCommand, remoteDeleteCommand } from "./branches.js";
|
|
21
22
|
import { busyHolders, defaultProcessProbe, parseLockOwner } from "./processes.js";
|
|
22
23
|
import { PLAN_CATEGORIES, fingerprint, isPlanCategory, sameFingerprint, } from "./plan.js";
|
|
23
24
|
import { RECEIPT_VERSION } from "./receipt.js";
|
|
24
25
|
import { sweepRepo } from "./sweep.js";
|
|
25
|
-
import { AUTHORIZATIONS } from "./types.js";
|
|
26
|
+
import { AUTHORIZATIONS, } from "./types.js";
|
|
26
27
|
import { worktreePruneCommand, worktreeRemoveCommand, worktreeUnlockCommand } from "./worktrees.js";
|
|
27
28
|
import { writeJsonAtomic } from "./write-json.js";
|
|
28
29
|
/** A selection or grant the plan cannot satisfy. Nothing has been touched. */
|
|
29
30
|
export class ApplyArgumentError extends Error {
|
|
30
31
|
}
|
|
32
|
+
/** A kept row is gone or moved since the plan, so the run refuses. Nothing has been touched. */
|
|
33
|
+
export class ApplyRefused extends Error {
|
|
34
|
+
}
|
|
31
35
|
/** `<category>:<id>`, split at the first colon. */
|
|
32
36
|
export function parseRowRef(text) {
|
|
33
37
|
const colon = text.indexOf(':');
|
|
@@ -77,8 +81,14 @@ function locate(plan, rows, repoRoot) {
|
|
|
77
81
|
function granted(grants, ref, name) {
|
|
78
82
|
return grants.some((g) => g.name === name && (g.row === null || sameRow(g.row, ref)));
|
|
79
83
|
}
|
|
84
|
+
/** The sweep scope that gathers each row category. */
|
|
85
|
+
const SCOPE_OF = {
|
|
86
|
+
worktree: 'worktrees',
|
|
87
|
+
local_branch: 'local-branches',
|
|
88
|
+
remote_branch: 'remote-branches',
|
|
89
|
+
};
|
|
80
90
|
/** The plan repository's rows as they stand now, with the host's overlay applied. */
|
|
81
|
-
function resweep(repo, opts) {
|
|
91
|
+
function resweep(repo, scope, opts) {
|
|
82
92
|
const given = opts.swept?.get(repo.root);
|
|
83
93
|
if (given !== undefined) {
|
|
84
94
|
return [...given];
|
|
@@ -87,6 +97,8 @@ function resweep(repo, opts) {
|
|
|
87
97
|
remote: repo.remote,
|
|
88
98
|
noGh: repo.no_gh,
|
|
89
99
|
branch: repo.branch ?? undefined,
|
|
100
|
+
fetch: opts.fetch ?? repo.fetch,
|
|
101
|
+
scope,
|
|
90
102
|
cwd: opts.callerCwd,
|
|
91
103
|
git: opts.git,
|
|
92
104
|
gh: opts.gh,
|
|
@@ -99,11 +111,34 @@ function resweep(repo, opts) {
|
|
|
99
111
|
return opts.annotate === undefined ? sweep.items : opts.annotate(sweep.repo, sweep.items);
|
|
100
112
|
}
|
|
101
113
|
/** Re-sweep, then split the selection into rows to run and rows dropped. */
|
|
102
|
-
function recheck(selected, grants, implicit, opts) {
|
|
114
|
+
function recheck(selected, kept, grants, implicit, opts) {
|
|
103
115
|
const swept = new Map();
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
116
|
+
const all = [...selected, ...kept];
|
|
117
|
+
for (const { repo } of all) {
|
|
118
|
+
if (swept.has(repo))
|
|
119
|
+
continue;
|
|
120
|
+
// Only the categories the selection is in: a plan of local rows need not
|
|
121
|
+
// compare every remote branch again.
|
|
122
|
+
const scope = new Set(all.filter((s) => s.repo === repo).map((s) => SCOPE_OF[s.ref.category]));
|
|
123
|
+
swept.set(repo, resweep(repo, [...scope], opts));
|
|
124
|
+
}
|
|
125
|
+
// A kept branch is checked, not dropped: one that moved refuses the run.
|
|
126
|
+
const verified = [];
|
|
127
|
+
for (const { repo, ref } of kept) {
|
|
128
|
+
const planned = repo.rows.find((row) => sameRow(row, ref));
|
|
129
|
+
const item = swept.get(repo).find((it) => sameRow(it, ref));
|
|
130
|
+
if (item === undefined) {
|
|
131
|
+
throw new ApplyRefused(`kept ${label(ref)} is no longer in the sweep; refusing to apply`);
|
|
132
|
+
}
|
|
133
|
+
// The tip alone: a PR that closed since the plan does not move the branch.
|
|
134
|
+
const plannedSha = planned.fingerprint['sha'];
|
|
135
|
+
if (typeof plannedSha !== 'string' || shaOf(item) === null) {
|
|
136
|
+
throw new ApplyRefused(`kept ${label(ref)} has no tip to check; refusing to apply`);
|
|
137
|
+
}
|
|
138
|
+
if (shaOf(item) !== plannedSha) {
|
|
139
|
+
throw new ApplyRefused(`kept ${label(ref)} moved since the plan (planned ${String(plannedSha)}, now ${String(item.facts['sha'])}); refusing to apply`);
|
|
140
|
+
}
|
|
141
|
+
verified.push({ repo: repo.root, item, force: false });
|
|
107
142
|
}
|
|
108
143
|
const accepted = [];
|
|
109
144
|
const dropped = [];
|
|
@@ -137,7 +172,7 @@ function recheck(selected, grants, implicit, opts) {
|
|
|
137
172
|
}
|
|
138
173
|
accepted.push({ repo: repo.root, item, force: granted(grants, ref, 'discard-changes') });
|
|
139
174
|
}
|
|
140
|
-
return { accepted, dropped };
|
|
175
|
+
return { accepted, dropped, verified };
|
|
141
176
|
}
|
|
142
177
|
function pending(repo, row, action, command, expectedSha = null) {
|
|
143
178
|
return {
|
|
@@ -152,9 +187,24 @@ function pending(repo, row, action, command, expectedSha = null) {
|
|
|
152
187
|
};
|
|
153
188
|
}
|
|
154
189
|
const pruneStep = (repo, row) => pending(repo, row, 'prune-worktrees', worktreePruneCommand(repo));
|
|
190
|
+
/** A row's sha fact, or null when it has none. */
|
|
191
|
+
function shaOf(item) {
|
|
192
|
+
return typeof item.facts['sha'] === 'string' ? item.facts['sha'] : null;
|
|
193
|
+
}
|
|
194
|
+
/** A remote branch row's remote, its full ref on that remote, and its branch name. */
|
|
195
|
+
function remoteRefOf(item) {
|
|
196
|
+
const branch = String(item.facts['branch']);
|
|
197
|
+
return { remote: String(item.facts['remote']), ref: `refs/heads/${branch}`, branch };
|
|
198
|
+
}
|
|
199
|
+
/** The step that checks a kept remote branch's tip, just before anything is deleted. */
|
|
200
|
+
function verifyStep({ repo, item }) {
|
|
201
|
+
const sha = shaOf(item);
|
|
202
|
+
const { remote, ref } = remoteRefOf(item);
|
|
203
|
+
return pending(repo, { category: item.category, id: item.id }, 'verify-remote', shellJoin(['git', 'ls-remote', remote, ref]), sha);
|
|
204
|
+
}
|
|
155
205
|
function stepsFor({ repo, item, force }) {
|
|
156
206
|
const ref = { category: item.category, id: item.id };
|
|
157
|
-
const sha =
|
|
207
|
+
const sha = shaOf(item);
|
|
158
208
|
switch (item.category) {
|
|
159
209
|
case 'worktree': {
|
|
160
210
|
// Remove and prune both skip a locked worktree, so a stale lock goes first.
|
|
@@ -172,7 +222,7 @@ function stepsFor({ repo, item, force }) {
|
|
|
172
222
|
return [pending(repo, ref, 'delete-local', localDeleteCommand(item.id, sha ?? ''), sha)];
|
|
173
223
|
case 'remote_branch':
|
|
174
224
|
return [
|
|
175
|
-
pending(repo, ref, 'delete-remote', remoteDeleteCommand(
|
|
225
|
+
pending(repo, ref, 'delete-remote', remoteDeleteCommand(remoteRefOf(item).remote, remoteRefOf(item).branch, sha ?? ''), sha),
|
|
176
226
|
];
|
|
177
227
|
}
|
|
178
228
|
}
|
|
@@ -244,9 +294,18 @@ function execute(step, row, git, probe) {
|
|
|
244
294
|
throw new Error('ref still resolves');
|
|
245
295
|
return 'ref does not resolve';
|
|
246
296
|
}
|
|
297
|
+
case 'verify-remote': {
|
|
298
|
+
const { remote, ref } = remoteRefOf(row.item);
|
|
299
|
+
const tip = at.lsRemote(remote, ref).find((r) => r.ref === ref);
|
|
300
|
+
if (tip === undefined)
|
|
301
|
+
throw new Error(`refused: kept ${ref} is gone from ${remote}`);
|
|
302
|
+
if (tip.sha !== step.expected_sha) {
|
|
303
|
+
throw new Error(`refused: kept ${ref} moved to ${tip.sha}, planned ${step.expected_sha}`);
|
|
304
|
+
}
|
|
305
|
+
return 'tip unchanged; kept';
|
|
306
|
+
}
|
|
247
307
|
case 'delete-remote': {
|
|
248
|
-
const remote =
|
|
249
|
-
const ref = `refs/heads/${String(row.item.facts['branch'])}`;
|
|
308
|
+
const { remote, ref } = remoteRefOf(row.item);
|
|
250
309
|
const report = at.push(remote, `:${ref}`, {
|
|
251
310
|
forceWithLease: { ref, expect: step.expected_sha },
|
|
252
311
|
});
|
|
@@ -301,7 +360,18 @@ function remoteKey(git, repo, remote) {
|
|
|
301
360
|
* list its branches; deleting one twice fails the second push and stops the
|
|
302
361
|
* run, so the later row is dropped as `duplicate`.
|
|
303
362
|
*/
|
|
304
|
-
function onePerRemoteBranch({ accepted, dropped }, git) {
|
|
363
|
+
function onePerRemoteBranch({ accepted, dropped, verified, }, git) {
|
|
364
|
+
const keyOf = (row) => {
|
|
365
|
+
const remote = remoteKey(git, row.repo, String(row.item.facts['remote']));
|
|
366
|
+
return remote === null ? null : `${remote}\0${remoteRefOf(row.item).branch}`;
|
|
367
|
+
};
|
|
368
|
+
// A branch kept through one clone stays, whichever clone lists it.
|
|
369
|
+
const keptAt = new Map();
|
|
370
|
+
for (const row of verified) {
|
|
371
|
+
const key = keyOf(row);
|
|
372
|
+
if (key !== null)
|
|
373
|
+
keptAt.set(key, row.repo);
|
|
374
|
+
}
|
|
305
375
|
const seen = new Map();
|
|
306
376
|
const kept = [];
|
|
307
377
|
const extra = [];
|
|
@@ -310,8 +380,18 @@ function onePerRemoteBranch({ accepted, dropped }, git) {
|
|
|
310
380
|
kept.push(row);
|
|
311
381
|
continue;
|
|
312
382
|
}
|
|
313
|
-
const
|
|
314
|
-
const
|
|
383
|
+
const key = keyOf(row);
|
|
384
|
+
const keeper = key === null ? undefined : keptAt.get(key);
|
|
385
|
+
if (keeper !== undefined) {
|
|
386
|
+
extra.push({
|
|
387
|
+
repo: row.repo,
|
|
388
|
+
category: row.item.category,
|
|
389
|
+
id: row.item.id,
|
|
390
|
+
reason: 'kept',
|
|
391
|
+
detail: `the same remote branch is kept through ${keeper}`,
|
|
392
|
+
});
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
315
395
|
const first = key === null ? undefined : seen.get(key);
|
|
316
396
|
if (first !== undefined) {
|
|
317
397
|
extra.push({
|
|
@@ -348,7 +428,19 @@ export function applyPlan(plan, requested, grants, opts) {
|
|
|
348
428
|
: plan.repos
|
|
349
429
|
.filter((r) => opts.repoRoot === undefined || resolve(r.root) === resolve(opts.repoRoot))
|
|
350
430
|
.flatMap((r) => r.rows.map((row) => ({ category: row.category, id: row.id, repo: r.root })));
|
|
351
|
-
const
|
|
431
|
+
const kept = locate(plan, opts.keep ?? [], opts.repoRoot);
|
|
432
|
+
for (const k of kept) {
|
|
433
|
+
if (k.ref.category !== 'remote_branch') {
|
|
434
|
+
throw new ApplyArgumentError(`--keep takes a remote_branch row, not ${label(k.ref)}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const isKept = (row) => kept.some((k) => sameRow(k.ref, row) &&
|
|
438
|
+
(row.repo === undefined || resolve(row.repo) === resolve(k.repo.root)));
|
|
439
|
+
for (const row of requested) {
|
|
440
|
+
if (isKept(row))
|
|
441
|
+
throw new ApplyArgumentError(`row ${label(row)} is both selected and kept`);
|
|
442
|
+
}
|
|
443
|
+
const rows = wanted.filter((row, i) => wanted.findIndex((other) => sameSelection(other, row)) === i && !isKept(row));
|
|
352
444
|
if (rows.length === 0)
|
|
353
445
|
throw new ApplyArgumentError('the plan has no rows to apply');
|
|
354
446
|
const selected = locate(plan, rows, opts.repoRoot);
|
|
@@ -366,11 +458,15 @@ export function applyPlan(plan, requested, grants, opts) {
|
|
|
366
458
|
dropped: [],
|
|
367
459
|
steps: [],
|
|
368
460
|
};
|
|
369
|
-
const rechecked = recheck(selected, grants, opts.safeOnly ?? requested.length === 0, opts);
|
|
461
|
+
const rechecked = recheck(selected, kept, grants, opts.safeOnly ?? requested.length === 0, opts);
|
|
370
462
|
const { accepted, dropped } = onePerRemoteBranch(rechecked, new Git(opts.callerCwd, { runner: opts.git }));
|
|
371
463
|
receipt.dropped = [...(opts.dropped ?? []), ...dropped];
|
|
372
464
|
const ordered = PLAN_CATEGORIES.flatMap((category) => accepted.filter((row) => row.item.category === category));
|
|
373
|
-
const plannedSteps =
|
|
465
|
+
const plannedSteps = [
|
|
466
|
+
// Kept tips first: one that moved stops the run before anything is deleted.
|
|
467
|
+
...rechecked.verified.map((row) => [verifyStep(row), row]),
|
|
468
|
+
...ordered.flatMap((row) => stepsFor(row).map((step) => [step, row])),
|
|
469
|
+
];
|
|
374
470
|
for (const repo of new Set(ordered.map((row) => row.repo))) {
|
|
375
471
|
plannedSteps.push([pruneStep(repo, null), null]);
|
|
376
472
|
}
|
|
@@ -387,6 +483,7 @@ export function applyPlan(plan, requested, grants, opts) {
|
|
|
387
483
|
writeJsonAtomic(receiptPath, receipt);
|
|
388
484
|
};
|
|
389
485
|
save();
|
|
486
|
+
opts.onStart?.(receipt.steps);
|
|
390
487
|
const git = new Git(opts.callerCwd, { runner: opts.git });
|
|
391
488
|
const probe = opts.probe ?? defaultProcessProbe();
|
|
392
489
|
// Worktrees whose lock this run cleared. typed-git has no `worktree lock`
|
|
@@ -408,9 +505,13 @@ export function applyPlan(plan, requested, grants, opts) {
|
|
|
408
505
|
}
|
|
409
506
|
receipt.status = 'failed';
|
|
410
507
|
receipt.finished_at = now().toISOString();
|
|
508
|
+
// Told before the save, so a receipt that cannot be written still
|
|
509
|
+
// leaves the step's outcome on screen.
|
|
510
|
+
opts.onStepDone?.(i, plannedSteps.length, step);
|
|
411
511
|
save();
|
|
412
512
|
return receipt;
|
|
413
513
|
}
|
|
514
|
+
opts.onStepDone?.(i, plannedSteps.length, step);
|
|
414
515
|
save();
|
|
415
516
|
}
|
|
416
517
|
receipt.status = 'complete';
|
package/dist/cli.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Four verbs over one set of sweep filters:
|
|
5
5
|
*
|
|
6
6
|
* status (the default) inventory and classify; read-only but for the fetch
|
|
7
|
-
* summary the same sweep as counts: totals, merged, delete, review, keep
|
|
7
|
+
* summary the same sweep as counts: totals, merged, delete, prune, review, keep
|
|
8
8
|
* plan write the deletable rows as a plan, to stdout or a file
|
|
9
9
|
* apply re-check, show the steps, ask, then delete
|
|
10
10
|
*
|
|
@@ -33,6 +33,11 @@ export interface MainDeps {
|
|
|
33
33
|
readStdin?: () => string;
|
|
34
34
|
/** Whether stderr is a terminal, so a status line may be drawn. Defaults to `isatty(2)`. */
|
|
35
35
|
stderrIsTerminal?: boolean;
|
|
36
|
+
/** The terminal's size, for the live step board. Defaults to stdout's columns and rows. */
|
|
37
|
+
terminalSize?: () => {
|
|
38
|
+
width: number;
|
|
39
|
+
height: number;
|
|
40
|
+
};
|
|
36
41
|
}
|
|
37
42
|
/** `main(argv, ctx)`: returns an exit code, never exits. */
|
|
38
43
|
export declare function main(argv: readonly string[], ctx: CliContext, deps?: MainDeps): number;
|