@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 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, 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
@@ -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 2 1 1
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
- 38 to delete, 8 to review, across 1 repository.
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: all three) |
268
- | `--branch <name\|HEAD>` | One branch of this repository: its worktree, local and remote branch. Refuses the default branch |
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
- | `--no-gh` | Classify on git evidence alone |
271
- | `--no-fetch` | Skip the fetch; the header then says `not fetched` |
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 prints the steps it would run and asks `Delete N items? [y/N]`.
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 { 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';
@@ -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
- for (const { repo } of selected) {
105
- if (!swept.has(repo))
106
- swept.set(repo, resweep(repo, opts));
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 = typeof item.facts['sha'] === 'string' ? item.facts['sha'] : null;
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(String(item.facts['remote']), String(item.facts['branch']), sha ?? ''), sha),
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 = String(row.item.facts['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 remote = remoteKey(git, row.repo, String(row.item.facts['remote']));
314
- const key = remote === null ? null : `${remote}\0${String(row.item.facts['branch'])}`;
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 rows = wanted.filter((row, i) => wanted.findIndex((other) => sameSelection(other, row)) === i);
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 = ordered.flatMap((row) => stepsFor(row).map((step) => [step, row]));
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;