@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 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, worktrees and
6
- remote branches, classifying each as `safe-to-delete`, `safe-to-keep` or
7
- `needs-investigation` — with the evidence behind the verdict and the literal
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 the steps and asks before it runs them.
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`, one listing per repository |
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 without authentication, classification proceeds on git
184
- evidence alone and the report says so.
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: all three) |
303
- | `--branch <name\|HEAD>` | One branch of this repository: its worktree, local and remote branch. Refuses the default branch |
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
- | `--no-gh` | Classify on git evidence alone |
307
- | `--no-fetch` | Skip the fetch; the header then says `not fetched` |
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 prints the steps it would run and asks `Delete N items? [y/N]`.
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', { noGh: true })
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. Authentication is `gh`'s
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 { CommandRunner } from '@osdlc/command-seam';
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 `gh`. */
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
- noGh: repo.no_gh,
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
- for (const { repo } of selected) {
105
- if (!swept.has(repo))
106
- swept.set(repo, resweep(repo, opts));
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 = typeof item.facts['sha'] === 'string' ? item.facts['sha'] : null;
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(String(item.facts['remote']), String(item.facts['branch']), sha ?? ''), sha),
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 = String(row.item.facts['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 remote = remoteKey(git, row.repo, String(row.item.facts['remote']));
314
- const key = remote === null ? null : `${remote}\0${String(row.item.facts['branch'])}`;
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 rows = wanted.filter((row, i) => wanted.findIndex((other) => sameSelection(other, row)) === i);
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 = ordered.flatMap((row) => stepsFor(row).map((step) => [step, row]));
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';
@@ -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;