@osdlc/repo-cleaner 0.11.0 → 0.14.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 +35 -0
- package/README.md +140 -19
- package/dist/apply.d.ts +32 -2
- package/dist/apply.js +121 -18
- package/dist/branches.d.ts +2 -0
- package/dist/branches.js +8 -3
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +138 -24
- package/dist/discover.d.ts +11 -2
- package/dist/discover.js +20 -9
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/plan.d.ts +18 -1
- package/dist/plan.js +23 -1
- package/dist/providers/github.d.ts +10 -0
- package/dist/providers/github.js +56 -0
- package/dist/providers/gitlab.d.ts +32 -0
- package/dist/providers/gitlab.js +117 -0
- package/dist/providers/index.d.ts +30 -0
- package/dist/providers/index.js +63 -0
- package/dist/providers/types.d.ts +56 -0
- package/dist/providers/types.js +10 -0
- package/dist/prs.d.ts +14 -10
- package/dist/prs.js +20 -44
- package/dist/receipt.d.ts +4 -0
- package/dist/receipt.js +2 -0
- package/dist/render.d.ts +47 -4
- package/dist/render.js +132 -19
- package/dist/status-line.d.ts +4 -2
- package/dist/status-line.js +15 -5
- package/dist/step-board.d.ts +34 -0
- package/dist/step-board.js +101 -0
- package/dist/sweep.d.ts +10 -3
- package/dist/sweep.js +11 -5
- package/dist/types.d.ts +9 -2
- 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 +57 -0
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# @osdlc/repo-cleaner
|
|
2
2
|
|
|
3
|
+
## 0.14.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- cde983c: Pull request evidence now comes from a provider, and GitLab is one (prototype). `--provider auto|github|gitlab|none` on every sweeping command picks it; `auto`, the default, reads the remote's host, so a `gitlab` host asks `glab` for merge requests and any other host asks `gh` as before. `--no-gh` is `--provider none`. A plan records the choice for `apply`'s re-check. **Breaking (library):** `RepoInfo.provider` says which provider was asked and what its probe found, and replaces `hasGh` and `ghAuthenticated`. The `noGh` sweep and plan option is now `noProvider`. `fetchPrs` takes a provider (`githubProvider(gh)` for the old `Gh` form), and its third argument is an exact branch, not a prefix. The plan file keeps its `no_gh` field. Not yet done: Gitea, Forgejo, Bitbucket, a host-to-provider config, GitLab squash metadata, and GitLab installed under a sub-path. Without `--remotes` no provider is asked.
|
|
8
|
+
|
|
9
|
+
## 0.13.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 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)
|
|
14
|
+
|
|
15
|
+
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)
|
|
16
|
+
|
|
17
|
+
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)
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- 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.
|
|
22
|
+
- 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.
|
|
23
|
+
- Updated dependencies [9948d90]
|
|
24
|
+
- @osdlc/cli-tool@0.14.0
|
|
25
|
+
|
|
26
|
+
## 0.12.0
|
|
27
|
+
|
|
28
|
+
### Minor Changes
|
|
29
|
+
|
|
30
|
+
- 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`.
|
|
31
|
+
|
|
32
|
+
`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.
|
|
33
|
+
|
|
34
|
+
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.
|
|
35
|
+
|
|
36
|
+
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.
|
|
37
|
+
|
|
3
38
|
## 0.11.0
|
|
4
39
|
|
|
5
40
|
### Minor 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
|
|
@@ -39,7 +74,7 @@ Three signals settle most items. The first two are enough alone; the third needs
|
|
|
39
74
|
|
|
40
75
|
| Signal | Source |
|
|
41
76
|
| --- | --- |
|
|
42
|
-
| A `MERGED` pull request from this repository, into the default branch, whose head commit is the branch tip | `gh pr list
|
|
77
|
+
| A `MERGED` pull request from this repository, into the default branch, whose head commit is the branch tip | `gh pr list` (or `glab` for GitLab), one listing per repository |
|
|
43
78
|
| The branch tip is reachable from the default branch | `git merge-base --is-ancestor` |
|
|
44
79
|
| The branch's changes are on the default branch under other commit ids (rebase merge, cherry-pick, squash merge) | `git cherry` plus `git merge-tree`, see [Patch equivalence](#patch-equivalence) |
|
|
45
80
|
|
|
@@ -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
|
|
|
@@ -180,8 +226,9 @@ table cannot be read the worktree is held back as `process-probe-failed`. A
|
|
|
180
226
|
host or test supplies its own probe.
|
|
181
227
|
Nothing is ever killed.
|
|
182
228
|
|
|
183
|
-
Without `gh`, or
|
|
184
|
-
evidence alone and the report
|
|
229
|
+
Without the provider's CLI (`gh`, or `glab` for GitLab), or without
|
|
230
|
+
authentication, classification proceeds on git evidence alone and the report
|
|
231
|
+
says so. See [Providers (prototype)](#providers-prototype).
|
|
185
232
|
|
|
186
233
|
Every branch delete names the full sha the sweep judged. `update-ref -d` and
|
|
187
234
|
the pinned `--force-with-lease` push both refuse when the branch has moved
|
|
@@ -213,7 +260,7 @@ repo-cleaner apply # re-check, show the steps, ask, delete
|
|
|
213
260
|
All four take the same paths and sweep options.
|
|
214
261
|
|
|
215
262
|
```console
|
|
216
|
-
$ repo-cleaner summary
|
|
263
|
+
$ repo-cleaner summary --remotes
|
|
217
264
|
/Users/me/work/api (acme/api, default main, origin)
|
|
218
265
|
total merged delete prune review keep
|
|
219
266
|
local branches 12 8 6 0 1 5
|
|
@@ -299,12 +346,14 @@ Every command that sweeps takes these:
|
|
|
299
346
|
|
|
300
347
|
| Flag | Effect |
|
|
301
348
|
| --- | --- |
|
|
302
|
-
| `--only <list>` | `worktrees`, `branches`, `remotes` (default:
|
|
303
|
-
| `--
|
|
349
|
+
| `--only <list>` | `worktrees`, `branches`, `remotes` (default: `worktrees,branches`) |
|
|
350
|
+
| `--remotes` | Also sweep remote branches. Slower: each one is compared with the default branch |
|
|
351
|
+
| `--branch <name\|HEAD>` | One branch of this repository: its worktree and local branch, and its remote branch with `--remotes`. Refuses the default branch |
|
|
304
352
|
| `--remote <name>` | Remote to measure against (default `origin`) |
|
|
305
353
|
| `--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 |
|
|
306
|
-
| `--
|
|
307
|
-
| `--no-
|
|
354
|
+
| `--provider <name>` | With `--remotes`: where pull requests come from: `auto` (default, by the remote's host), `github`, `gitlab` or `none` |
|
|
355
|
+
| `--no-gh` | With `--remotes`: classify on git evidence alone; the same as `--provider none`. Without `--remotes`, no provider is asked |
|
|
356
|
+
| `--no-fetch` | With `--remotes`: skip the fetch; the header then says `not fetched`. Without `--remotes`, nothing is fetched |
|
|
308
357
|
|
|
309
358
|
`status` also takes:
|
|
310
359
|
|
|
@@ -338,7 +387,40 @@ with `--row <category>:<id>` to apply it, review rows included.
|
|
|
338
387
|
Before it deletes anything, `apply` sweeps again and drops a row that is gone,
|
|
339
388
|
no longer deletable, whose facts moved, or that carries a risk you did not
|
|
340
389
|
accept with `--allow <risk>` (every row) or `--allow <risk>=<category>:<id>`
|
|
341
|
-
(one row). It
|
|
390
|
+
(one row). It then shows, per repository, each row it will delete or prune
|
|
391
|
+
with the command under it, each row it will skip and why, and the counts, and
|
|
392
|
+
asks `Delete N items? [y/N]`:
|
|
393
|
+
|
|
394
|
+
```text
|
|
395
|
+
/Users/me/work/api
|
|
396
|
+
delete local_branch fix/login-redirect
|
|
397
|
+
git update-ref -d refs/heads/fix/login-redirect 3f9c2e1d…
|
|
398
|
+
prune
|
|
399
|
+
git -C /Users/me/work/api worktree prune
|
|
400
|
+
skip worktree /Users/me/work/api/wt/spike
|
|
401
|
+
needs-review: clean but no clear disposability signal; name it with --row to apply it
|
|
402
|
+
|
|
403
|
+
would delete: 1 local branch
|
|
404
|
+
dropped 1: 1 needs-review
|
|
405
|
+
Delete 2 items? [y/N]
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
The re-check reads only the categories the selected rows are in, so applying
|
|
409
|
+
a plan of local rows does not compare every remote branch again. A plan
|
|
410
|
+
records in each repository's `fetch` whether its sweep fetched; the re-check
|
|
411
|
+
fetches too when it did, so it judges against the same, current default
|
|
412
|
+
branch. A plan written before that field re-checks without fetching.
|
|
413
|
+
|
|
414
|
+
`--keep remote_branch:<remote>/<branch>` keeps a remote branch while the
|
|
415
|
+
rest of its rows go: say, delete the worktree and local branch but leave
|
|
416
|
+
the branch on the remote. A kept row is checked, not skipped. The re-check
|
|
417
|
+
refuses the run if the branch is gone or its tip moved since the plan, and
|
|
418
|
+
the run's first step checks the tip again with `git ls-remote`, so one that
|
|
419
|
+
moves after the re-check stops the run before anything is deleted. Only the
|
|
420
|
+
tip counts: a PR that closed since the plan does not refuse it. Another
|
|
421
|
+
clone of the same remote does not delete a kept branch either; its row is
|
|
422
|
+
dropped as `kept`. The row id is the one the plan lists,
|
|
423
|
+
`<remote>/<branch>`, e.g. `origin/feat/x`.
|
|
342
424
|
`--yes` skips the question. With no terminal to ask on and no `--yes`, it
|
|
343
425
|
refuses with exit 2 rather than deleting. `--plan -` needs `--yes` or
|
|
344
426
|
`--dry-run`, because stdin carries the plan. `--dry-run` prints the steps and
|
|
@@ -383,7 +465,7 @@ that swept, or read its plan from stdin, writes a receipt only when
|
|
|
383
465
|
```ts
|
|
384
466
|
import { sweepRepo, sweepRepos } from '@osdlc/repo-cleaner'
|
|
385
467
|
|
|
386
|
-
const sweep = sweepRepo('/Users/me/work/api', {
|
|
468
|
+
const sweep = sweepRepo('/Users/me/work/api', { noProvider: true })
|
|
387
469
|
for (const item of sweep.items) {
|
|
388
470
|
console.log(item.classification, item.id, item.reason)
|
|
389
471
|
}
|
|
@@ -398,7 +480,7 @@ back as `error`, because a fan-out must not end on its first bad entry.
|
|
|
398
480
|
before classifying, so ancestry is read against fresh remote-tracking refs.
|
|
399
481
|
That fetch is the only write. A repository whose fetch fails comes back with `error` starting
|
|
400
482
|
`fetch failed:` and no items. `repo.fetched` says which case applied. The
|
|
401
|
-
library default is off; the CLI fetches unless given `--no-fetch`.
|
|
483
|
+
library default is off; the CLI fetches with `--remotes`, unless given `--no-fetch`.
|
|
402
484
|
|
|
403
485
|
### Adding knowledge the library does not have
|
|
404
486
|
|
|
@@ -417,10 +499,49 @@ or `needs-investigation`.
|
|
|
417
499
|
An issue tracker's linkage, applied this way, is a host's own layer over the
|
|
418
500
|
sweep and `applyPlan`.
|
|
419
501
|
|
|
502
|
+
## Providers (prototype)
|
|
503
|
+
|
|
504
|
+
Pull request evidence comes from a provider. Classification reads the same
|
|
505
|
+
fields whichever one answered: number, state, head branch, head commit, target
|
|
506
|
+
branch.
|
|
507
|
+
|
|
508
|
+
| Provider | CLI | Listing |
|
|
509
|
+
| --- | --- | --- |
|
|
510
|
+
| `github` | `gh` | `gh pr list --state all`, up to 300 |
|
|
511
|
+
| `gitlab` | `glab` | `glab api projects/<path>/merge_requests?state=all`, 100 a page, up to 300 |
|
|
512
|
+
|
|
513
|
+
`--provider auto` (the default) reads the remote's host: a host with `gitlab`
|
|
514
|
+
in its name is GitLab, any other is GitHub, which is what the tool did before
|
|
515
|
+
providers existed. Name one with `--provider github|gitlab` for a host the
|
|
516
|
+
name does not give away, or `--provider none` to skip pull requests. A plan
|
|
517
|
+
records the choice, so `apply` re-checks with the same provider.
|
|
518
|
+
|
|
519
|
+
GitLab maps onto the same evidence: `opened` and `locked` are `OPEN`, `merged`
|
|
520
|
+
is `MERGED`, `closed` is `CLOSED`, `sha` is the head commit and `target_branch`
|
|
521
|
+
the base. A merge request from a fork (source project is not the target
|
|
522
|
+
project) is dropped, as a cross-repository PR is on GitHub. The project path is
|
|
523
|
+
read off the remote URL with its groups (`group/sub/name`), and `glab` is asked
|
|
524
|
+
for that host with `--hostname`.
|
|
525
|
+
|
|
526
|
+
Not done yet:
|
|
527
|
+
|
|
528
|
+
- Gitea, Forgejo and Bitbucket.
|
|
529
|
+
- Mapping a self-hosted host to a provider in config; today it is the host
|
|
530
|
+
name or `--provider`.
|
|
531
|
+
- Auth edge cases: tokens without API scope, `glab` signed in to a different
|
|
532
|
+
host than the remote, rate limits. Each reads as "no PR found".
|
|
533
|
+
- Squash metadata. GitLab reports `squash_commit_sha`; it is not used, so a
|
|
534
|
+
squashed MR proves landing only by its head commit, as on GitHub.
|
|
535
|
+
- GitLab under a sub-path (`https://host/gitlab/group/proj`). The URL does not
|
|
536
|
+
say where the install ends and the project begins, so the whole path is read
|
|
537
|
+
as the project, `glab` is asked for a project that does not exist, and the
|
|
538
|
+
listing fails. That reads as "no PR found": rows stay at `review`, nothing is
|
|
539
|
+
deleted on its account.
|
|
540
|
+
|
|
420
541
|
## Runtime dependencies
|
|
421
542
|
|
|
422
|
-
`git` on `PATH`, and `gh` when PR state should count.
|
|
423
|
-
own — this package reads no token.
|
|
543
|
+
`git` on `PATH`, and `gh` (or `glab` for GitLab) when PR state should count.
|
|
544
|
+
Authentication is the CLI's own — this package reads no token.
|
|
424
545
|
|
|
425
546
|
## Running from a checkout
|
|
426
547
|
|
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';
|
|
@@ -26,9 +26,10 @@ export interface Grant {
|
|
|
26
26
|
row: RowRef | null;
|
|
27
27
|
}
|
|
28
28
|
export interface ApplyOptions extends HostHooks {
|
|
29
|
-
/** Injected runners. Defaults spawn the real `git` and `
|
|
29
|
+
/** Injected runners. Defaults spawn the real `git`, `gh` and `glab`. */
|
|
30
30
|
git?: CommandRunner | undefined;
|
|
31
31
|
gh?: CommandRunner | undefined;
|
|
32
|
+
glab?: CommandRunner | undefined;
|
|
32
33
|
/**
|
|
33
34
|
* Answers lock-owner and working-directory questions, for the re-sweep and
|
|
34
35
|
* for the checks just before an unlock and a remove. Defaults to `ps` and
|
|
@@ -61,14 +62,43 @@ export interface ApplyOptions extends HostHooks {
|
|
|
61
62
|
swept?: ReadonlyMap<string, readonly SweepItem[]> | undefined;
|
|
62
63
|
/** Rows dropped before this run, such as by a preview; recorded first in the receipt. */
|
|
63
64
|
dropped?: readonly Dropped[] | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* Remote branch rows to keep: not deleted, but checked. Each must be a
|
|
67
|
+
* `remote_branch` row of the plan; its id is `<remote>/<branch>`, as in the
|
|
68
|
+
* plan, e.g. `origin/topic`. The re-check refuses the run with
|
|
69
|
+
* {@link ApplyRefused} when a kept branch is gone or its tip is not the
|
|
70
|
+
* planned one, and the run's first steps check each tip again with
|
|
71
|
+
* `ls-remote`, so a branch that moves after the re-check stops the run
|
|
72
|
+
* before anything is deleted. A kept row cannot also be selected.
|
|
73
|
+
*/
|
|
74
|
+
keep?: readonly RowSelection[] | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* Fetch each repository's remote before the re-sweep. Defaults to what the
|
|
77
|
+
* plan repository records in `fetch`: a plan swept after a fetch is
|
|
78
|
+
* re-checked after one. Remote branch tips are read live either way; the
|
|
79
|
+
* fetch moves the default branch those tips and the local refs are judged
|
|
80
|
+
* against. A failed fetch fails the run before any step.
|
|
81
|
+
*/
|
|
82
|
+
fetch?: boolean | undefined;
|
|
64
83
|
/** Passed to the re-sweep's `onProgress`, for a status line. */
|
|
65
84
|
onSweepProgress?: ((progress: SweepProgress) => void) | undefined;
|
|
85
|
+
/**
|
|
86
|
+
* Told once the steps are planned, before the first runs, with the list
|
|
87
|
+
* itself: each step's `result` is updated in place as the run goes. Not
|
|
88
|
+
* called under `dryRun`.
|
|
89
|
+
*/
|
|
90
|
+
onStart?: ((steps: readonly Step[]) => void) | undefined;
|
|
66
91
|
/** Told before each step runs: its 0-based index, the step count and the step. Not called under `dryRun`. */
|
|
67
92
|
onStep?: ((index: number, total: number, step: Step) => void) | undefined;
|
|
93
|
+
/** Told after each step, with its `result` set (`ok`, or `failed` and the run stops), before the receipt is saved. Not called under `dryRun`. */
|
|
94
|
+
onStepDone?: ((index: number, total: number, step: Step) => void) | undefined;
|
|
68
95
|
}
|
|
69
96
|
/** A selection or grant the plan cannot satisfy. Nothing has been touched. */
|
|
70
97
|
export declare class ApplyArgumentError extends Error {
|
|
71
98
|
}
|
|
99
|
+
/** A kept row is gone or moved since the plan, so the run refuses. Nothing has been touched. */
|
|
100
|
+
export declare class ApplyRefused extends Error {
|
|
101
|
+
}
|
|
72
102
|
/** `<category>:<id>`, split at the first colon. */
|
|
73
103
|
export declare function parseRowRef(text: string): RowRef;
|
|
74
104
|
/** `<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,19 +81,29 @@ 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];
|
|
85
95
|
}
|
|
86
96
|
const sweep = sweepRepo(repo.root, {
|
|
87
97
|
remote: repo.remote,
|
|
88
|
-
|
|
98
|
+
noProvider: repo.no_gh,
|
|
99
|
+
provider: repo.provider,
|
|
89
100
|
branch: repo.branch ?? undefined,
|
|
101
|
+
fetch: opts.fetch ?? repo.fetch,
|
|
102
|
+
scope,
|
|
90
103
|
cwd: opts.callerCwd,
|
|
91
104
|
git: opts.git,
|
|
92
105
|
gh: opts.gh,
|
|
106
|
+
glab: opts.glab,
|
|
93
107
|
probe: opts.probe,
|
|
94
108
|
onProgress: opts.onSweepProgress,
|
|
95
109
|
});
|
|
@@ -99,11 +113,34 @@ function resweep(repo, opts) {
|
|
|
99
113
|
return opts.annotate === undefined ? sweep.items : opts.annotate(sweep.repo, sweep.items);
|
|
100
114
|
}
|
|
101
115
|
/** Re-sweep, then split the selection into rows to run and rows dropped. */
|
|
102
|
-
function recheck(selected, grants, implicit, opts) {
|
|
116
|
+
function recheck(selected, kept, grants, implicit, opts) {
|
|
103
117
|
const swept = new Map();
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
118
|
+
const all = [...selected, ...kept];
|
|
119
|
+
for (const { repo } of all) {
|
|
120
|
+
if (swept.has(repo))
|
|
121
|
+
continue;
|
|
122
|
+
// Only the categories the selection is in: a plan of local rows need not
|
|
123
|
+
// compare every remote branch again.
|
|
124
|
+
const scope = new Set(all.filter((s) => s.repo === repo).map((s) => SCOPE_OF[s.ref.category]));
|
|
125
|
+
swept.set(repo, resweep(repo, [...scope], opts));
|
|
126
|
+
}
|
|
127
|
+
// A kept branch is checked, not dropped: one that moved refuses the run.
|
|
128
|
+
const verified = [];
|
|
129
|
+
for (const { repo, ref } of kept) {
|
|
130
|
+
const planned = repo.rows.find((row) => sameRow(row, ref));
|
|
131
|
+
const item = swept.get(repo).find((it) => sameRow(it, ref));
|
|
132
|
+
if (item === undefined) {
|
|
133
|
+
throw new ApplyRefused(`kept ${label(ref)} is no longer in the sweep; refusing to apply`);
|
|
134
|
+
}
|
|
135
|
+
// The tip alone: a PR that closed since the plan does not move the branch.
|
|
136
|
+
const plannedSha = planned.fingerprint['sha'];
|
|
137
|
+
if (typeof plannedSha !== 'string' || shaOf(item) === null) {
|
|
138
|
+
throw new ApplyRefused(`kept ${label(ref)} has no tip to check; refusing to apply`);
|
|
139
|
+
}
|
|
140
|
+
if (shaOf(item) !== plannedSha) {
|
|
141
|
+
throw new ApplyRefused(`kept ${label(ref)} moved since the plan (planned ${String(plannedSha)}, now ${String(item.facts['sha'])}); refusing to apply`);
|
|
142
|
+
}
|
|
143
|
+
verified.push({ repo: repo.root, item, force: false });
|
|
107
144
|
}
|
|
108
145
|
const accepted = [];
|
|
109
146
|
const dropped = [];
|
|
@@ -137,7 +174,7 @@ function recheck(selected, grants, implicit, opts) {
|
|
|
137
174
|
}
|
|
138
175
|
accepted.push({ repo: repo.root, item, force: granted(grants, ref, 'discard-changes') });
|
|
139
176
|
}
|
|
140
|
-
return { accepted, dropped };
|
|
177
|
+
return { accepted, dropped, verified };
|
|
141
178
|
}
|
|
142
179
|
function pending(repo, row, action, command, expectedSha = null) {
|
|
143
180
|
return {
|
|
@@ -152,9 +189,24 @@ function pending(repo, row, action, command, expectedSha = null) {
|
|
|
152
189
|
};
|
|
153
190
|
}
|
|
154
191
|
const pruneStep = (repo, row) => pending(repo, row, 'prune-worktrees', worktreePruneCommand(repo));
|
|
192
|
+
/** A row's sha fact, or null when it has none. */
|
|
193
|
+
function shaOf(item) {
|
|
194
|
+
return typeof item.facts['sha'] === 'string' ? item.facts['sha'] : null;
|
|
195
|
+
}
|
|
196
|
+
/** A remote branch row's remote, its full ref on that remote, and its branch name. */
|
|
197
|
+
function remoteRefOf(item) {
|
|
198
|
+
const branch = String(item.facts['branch']);
|
|
199
|
+
return { remote: String(item.facts['remote']), ref: `refs/heads/${branch}`, branch };
|
|
200
|
+
}
|
|
201
|
+
/** The step that checks a kept remote branch's tip, just before anything is deleted. */
|
|
202
|
+
function verifyStep({ repo, item }) {
|
|
203
|
+
const sha = shaOf(item);
|
|
204
|
+
const { remote, ref } = remoteRefOf(item);
|
|
205
|
+
return pending(repo, { category: item.category, id: item.id }, 'verify-remote', shellJoin(['git', 'ls-remote', remote, ref]), sha);
|
|
206
|
+
}
|
|
155
207
|
function stepsFor({ repo, item, force }) {
|
|
156
208
|
const ref = { category: item.category, id: item.id };
|
|
157
|
-
const sha =
|
|
209
|
+
const sha = shaOf(item);
|
|
158
210
|
switch (item.category) {
|
|
159
211
|
case 'worktree': {
|
|
160
212
|
// Remove and prune both skip a locked worktree, so a stale lock goes first.
|
|
@@ -172,7 +224,7 @@ function stepsFor({ repo, item, force }) {
|
|
|
172
224
|
return [pending(repo, ref, 'delete-local', localDeleteCommand(item.id, sha ?? ''), sha)];
|
|
173
225
|
case 'remote_branch':
|
|
174
226
|
return [
|
|
175
|
-
pending(repo, ref, 'delete-remote', remoteDeleteCommand(
|
|
227
|
+
pending(repo, ref, 'delete-remote', remoteDeleteCommand(remoteRefOf(item).remote, remoteRefOf(item).branch, sha ?? ''), sha),
|
|
176
228
|
];
|
|
177
229
|
}
|
|
178
230
|
}
|
|
@@ -244,9 +296,18 @@ function execute(step, row, git, probe) {
|
|
|
244
296
|
throw new Error('ref still resolves');
|
|
245
297
|
return 'ref does not resolve';
|
|
246
298
|
}
|
|
299
|
+
case 'verify-remote': {
|
|
300
|
+
const { remote, ref } = remoteRefOf(row.item);
|
|
301
|
+
const tip = at.lsRemote(remote, ref).find((r) => r.ref === ref);
|
|
302
|
+
if (tip === undefined)
|
|
303
|
+
throw new Error(`refused: kept ${ref} is gone from ${remote}`);
|
|
304
|
+
if (tip.sha !== step.expected_sha) {
|
|
305
|
+
throw new Error(`refused: kept ${ref} moved to ${tip.sha}, planned ${step.expected_sha}`);
|
|
306
|
+
}
|
|
307
|
+
return 'tip unchanged; kept';
|
|
308
|
+
}
|
|
247
309
|
case 'delete-remote': {
|
|
248
|
-
const remote =
|
|
249
|
-
const ref = `refs/heads/${String(row.item.facts['branch'])}`;
|
|
310
|
+
const { remote, ref } = remoteRefOf(row.item);
|
|
250
311
|
const report = at.push(remote, `:${ref}`, {
|
|
251
312
|
forceWithLease: { ref, expect: step.expected_sha },
|
|
252
313
|
});
|
|
@@ -301,7 +362,18 @@ function remoteKey(git, repo, remote) {
|
|
|
301
362
|
* list its branches; deleting one twice fails the second push and stops the
|
|
302
363
|
* run, so the later row is dropped as `duplicate`.
|
|
303
364
|
*/
|
|
304
|
-
function onePerRemoteBranch({ accepted, dropped }, git) {
|
|
365
|
+
function onePerRemoteBranch({ accepted, dropped, verified, }, git) {
|
|
366
|
+
const keyOf = (row) => {
|
|
367
|
+
const remote = remoteKey(git, row.repo, String(row.item.facts['remote']));
|
|
368
|
+
return remote === null ? null : `${remote}\0${remoteRefOf(row.item).branch}`;
|
|
369
|
+
};
|
|
370
|
+
// A branch kept through one clone stays, whichever clone lists it.
|
|
371
|
+
const keptAt = new Map();
|
|
372
|
+
for (const row of verified) {
|
|
373
|
+
const key = keyOf(row);
|
|
374
|
+
if (key !== null)
|
|
375
|
+
keptAt.set(key, row.repo);
|
|
376
|
+
}
|
|
305
377
|
const seen = new Map();
|
|
306
378
|
const kept = [];
|
|
307
379
|
const extra = [];
|
|
@@ -310,8 +382,18 @@ function onePerRemoteBranch({ accepted, dropped }, git) {
|
|
|
310
382
|
kept.push(row);
|
|
311
383
|
continue;
|
|
312
384
|
}
|
|
313
|
-
const
|
|
314
|
-
const
|
|
385
|
+
const key = keyOf(row);
|
|
386
|
+
const keeper = key === null ? undefined : keptAt.get(key);
|
|
387
|
+
if (keeper !== undefined) {
|
|
388
|
+
extra.push({
|
|
389
|
+
repo: row.repo,
|
|
390
|
+
category: row.item.category,
|
|
391
|
+
id: row.item.id,
|
|
392
|
+
reason: 'kept',
|
|
393
|
+
detail: `the same remote branch is kept through ${keeper}`,
|
|
394
|
+
});
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
315
397
|
const first = key === null ? undefined : seen.get(key);
|
|
316
398
|
if (first !== undefined) {
|
|
317
399
|
extra.push({
|
|
@@ -348,7 +430,19 @@ export function applyPlan(plan, requested, grants, opts) {
|
|
|
348
430
|
: plan.repos
|
|
349
431
|
.filter((r) => opts.repoRoot === undefined || resolve(r.root) === resolve(opts.repoRoot))
|
|
350
432
|
.flatMap((r) => r.rows.map((row) => ({ category: row.category, id: row.id, repo: r.root })));
|
|
351
|
-
const
|
|
433
|
+
const kept = locate(plan, opts.keep ?? [], opts.repoRoot);
|
|
434
|
+
for (const k of kept) {
|
|
435
|
+
if (k.ref.category !== 'remote_branch') {
|
|
436
|
+
throw new ApplyArgumentError(`--keep takes a remote_branch row, not ${label(k.ref)}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const isKept = (row) => kept.some((k) => sameRow(k.ref, row) &&
|
|
440
|
+
(row.repo === undefined || resolve(row.repo) === resolve(k.repo.root)));
|
|
441
|
+
for (const row of requested) {
|
|
442
|
+
if (isKept(row))
|
|
443
|
+
throw new ApplyArgumentError(`row ${label(row)} is both selected and kept`);
|
|
444
|
+
}
|
|
445
|
+
const rows = wanted.filter((row, i) => wanted.findIndex((other) => sameSelection(other, row)) === i && !isKept(row));
|
|
352
446
|
if (rows.length === 0)
|
|
353
447
|
throw new ApplyArgumentError('the plan has no rows to apply');
|
|
354
448
|
const selected = locate(plan, rows, opts.repoRoot);
|
|
@@ -366,11 +460,15 @@ export function applyPlan(plan, requested, grants, opts) {
|
|
|
366
460
|
dropped: [],
|
|
367
461
|
steps: [],
|
|
368
462
|
};
|
|
369
|
-
const rechecked = recheck(selected, grants, opts.safeOnly ?? requested.length === 0, opts);
|
|
463
|
+
const rechecked = recheck(selected, kept, grants, opts.safeOnly ?? requested.length === 0, opts);
|
|
370
464
|
const { accepted, dropped } = onePerRemoteBranch(rechecked, new Git(opts.callerCwd, { runner: opts.git }));
|
|
371
465
|
receipt.dropped = [...(opts.dropped ?? []), ...dropped];
|
|
372
466
|
const ordered = PLAN_CATEGORIES.flatMap((category) => accepted.filter((row) => row.item.category === category));
|
|
373
|
-
const plannedSteps =
|
|
467
|
+
const plannedSteps = [
|
|
468
|
+
// Kept tips first: one that moved stops the run before anything is deleted.
|
|
469
|
+
...rechecked.verified.map((row) => [verifyStep(row), row]),
|
|
470
|
+
...ordered.flatMap((row) => stepsFor(row).map((step) => [step, row])),
|
|
471
|
+
];
|
|
374
472
|
for (const repo of new Set(ordered.map((row) => row.repo))) {
|
|
375
473
|
plannedSteps.push([pruneStep(repo, null), null]);
|
|
376
474
|
}
|
|
@@ -387,6 +485,7 @@ export function applyPlan(plan, requested, grants, opts) {
|
|
|
387
485
|
writeJsonAtomic(receiptPath, receipt);
|
|
388
486
|
};
|
|
389
487
|
save();
|
|
488
|
+
opts.onStart?.(receipt.steps);
|
|
390
489
|
const git = new Git(opts.callerCwd, { runner: opts.git });
|
|
391
490
|
const probe = opts.probe ?? defaultProcessProbe();
|
|
392
491
|
// Worktrees whose lock this run cleared. typed-git has no `worktree lock`
|
|
@@ -408,9 +507,13 @@ export function applyPlan(plan, requested, grants, opts) {
|
|
|
408
507
|
}
|
|
409
508
|
receipt.status = 'failed';
|
|
410
509
|
receipt.finished_at = now().toISOString();
|
|
510
|
+
// Told before the save, so a receipt that cannot be written still
|
|
511
|
+
// leaves the step's outcome on screen.
|
|
512
|
+
opts.onStepDone?.(i, plannedSteps.length, step);
|
|
411
513
|
save();
|
|
412
514
|
return receipt;
|
|
413
515
|
}
|
|
516
|
+
opts.onStepDone?.(i, plannedSteps.length, step);
|
|
414
517
|
save();
|
|
415
518
|
}
|
|
416
519
|
receipt.status = 'complete';
|
package/dist/branches.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export declare function recordPr(facts: Record<string, unknown>, pr: PRInfo | un
|
|
|
25
25
|
landed: boolean;
|
|
26
26
|
gap: PrEvidenceGap | null;
|
|
27
27
|
};
|
|
28
|
+
/** Why a branch has no PR, naming where the sweep looked. */
|
|
29
|
+
export declare function noPrSentence(repo: RepoInfo): string;
|
|
28
30
|
/** One sentence on why a merged PR did not count, for `investigationParts`. */
|
|
29
31
|
export declare function gapSentence(pr: PRInfo, gap: PrEvidenceGap, repo: RepoInfo): string;
|
|
30
32
|
/**
|
package/dist/branches.js
CHANGED
|
@@ -53,6 +53,13 @@ export function recordPr(facts, pr, tip, repo) {
|
|
|
53
53
|
out['evidence'] = verdict === true ? 'exact-head' : verdict;
|
|
54
54
|
return verdict === true ? { landed: true, gap: null } : { landed: false, gap: verdict };
|
|
55
55
|
}
|
|
56
|
+
/** Why a branch has no PR, naming where the sweep looked. */
|
|
57
|
+
export function noPrSentence(repo) {
|
|
58
|
+
const provider = repo.provider;
|
|
59
|
+
if (provider === null)
|
|
60
|
+
return 'No PR lookup (no provider).';
|
|
61
|
+
return `No ${provider.noun} found via \`${provider.listing}\`.`;
|
|
62
|
+
}
|
|
56
63
|
/** One sentence on why a merged PR did not count, for `investigationParts`. */
|
|
57
64
|
export function gapSentence(pr, gap, repo) {
|
|
58
65
|
return gap === 'other-base'
|
|
@@ -233,9 +240,7 @@ export function classifyLocalBranch(opts) {
|
|
|
233
240
|
: `Branch \`${name}\` is not an ancestor of \`${compareRef}\`.`;
|
|
234
241
|
const parts = [lead];
|
|
235
242
|
parts.push(upstream ? `Tracks \`${upstream}\` (${track || 'in sync'}).` : 'Has no upstream.');
|
|
236
|
-
parts.push(pr
|
|
237
|
-
? `Associated PR #${pr.number} is ${pr.state} (${pr.url}).`
|
|
238
|
-
: 'No PR found via `gh pr list`.');
|
|
243
|
+
parts.push(pr ? `Associated PR #${pr.number} is ${pr.state} (${pr.url}).` : noPrSentence(repo));
|
|
239
244
|
if (pr && gap)
|
|
240
245
|
parts.push(gapSentence(pr, gap, repo));
|
|
241
246
|
const unmatched = patch === null ? null : unmatchedSentence(patch, repo);
|
package/dist/cli.d.ts
CHANGED
|
@@ -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;
|