@osdlc/repo-cleaner 0.9.2 → 0.10.1

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,60 @@
1
1
  # @osdlc/repo-cleaner
2
2
 
3
+ ## 0.10.1
4
+
5
+ ### Patch Changes
6
+
7
+ - cde11b5: `apply` fixes:
8
+
9
+ - The run a confirmation approves keeps the safe-to-delete guard: a row that turned into a review row between the preview and the yes is dropped as `needs-review`, not run.
10
+ - Two clones of one remote no longer delete its branches twice. The second row is dropped as the new reason `duplicate`, naming the clone that deletes it; before, the second push was rejected and the run stopped with exit 11. Remotes are compared by the push URLs git uses (`url.*.insteadOf` applied), a network URL in any form (`git@host:o/r`, `ssh://`, `https://`) as `host/path`, and a local path as written.
11
+ - A swept `apply` re-checks once before asking, from the sweep it just ran, instead of sweeping again; the run that deletes still sweeps afresh.
12
+ - A run that deletes without a receipt file says so on stderr.
13
+ - Every run ends with a summary: `deleted: 1 worktree, 3 local branches, 2 remote branches`, the dropped rows by reason, and how many rows a failed run did not reach. A preview says `would delete:`. `--output json` adds a `summary` object.
14
+ - The prompt's `Delete N items?` counts rows, not steps: a stale-locked worktree is one item, not two.
15
+ - `--repo-root` naming no repository in the plan exits 1 instead of reporting nothing to delete.
16
+ - An empty plan's JSON receipt says `complete`, not `dry-run`, unless the run was `--dry-run`.
17
+
18
+ Library: `ApplyOptions.safeOnly` and `ApplyOptions.swept`, the `duplicate` drop reason, `RECEIPT_VERSION`, and `summarizeReceipt`, `renderApplySummary` and `ApplySummary`.
19
+
20
+ ## 0.10.0
21
+
22
+ ### Minor Changes
23
+
24
+ - 6e4284b: **Breaking:** repo-cleaner no longer reads a config file. It sweeps the repository you are in, or the paths you pass, which can be directories holding checkouts. `--all` and `--config` are removed: pass the directories instead (`repo-cleaner ~/Developer ~/work`), or keep them in a shell alias. Removed from the library: `CONFIG_ENV`, `DEFAULT_CONFIG`, `RepoCleanerConfigSchema`, `TOOL`, `clearConfigCache`, `configPath`, `configuredRepos`, `loadConfig` and `RepoCleanerConfig`. `REPO_CLEANER_MACHINE_CONFIG` is no longer read. The package no longer depends on `@osdlc/config-layers`.
25
+ - 6e4284b: A status line on stderr while a run works: which repository (`[2/5]` in a run of several), and which step — reading the repository, fetching, listing pull requests (gh), checking worktrees, local or remote branches with a count (`37/427`) — and, in `apply`, the re-check and each delete step. It is drawn only when stderr is a terminal, erased before any other output, and turned off with `--no-progress`.
26
+
27
+ Library: `SweepOptions.onProgress` receives a `SweepProgress` before each step and each item; `ApplyOptions.onSweepProgress` and `onStep` do the same for `applyPlan`.
28
+ - 6e4284b: New `repo-cleaner summary`: a table per repository counting local branches, worktrees and remote branches, with how many are merged into the default branch and how many the sweep would delete, review or keep. `--output json` gives the counts without the rows.
29
+
30
+ Each row now records `facts.merged`: true when its work is on the default branch by ancestry, an exact-head merged PR or a patch match. A branch checked out in a worktree, or at HEAD, is judged by ancestry or PR alone. The default branch and the primary or current worktree carry none. `SummaryBucket` gains `merged`, so the JSON report's `summary` counts it too.
31
+ - 6e4284b: **Breaking:** the CLI is three commands over one set of sweep options, and `apply` asks before it deletes.
32
+
33
+ - `repo-cleaner [status]` is the report, as before.
34
+ - `repo-cleaner plan` writes the plan to stdout, or to a file with `-o <file>`. It replaces `--write-plan` and `--plan-out`. Plans no longer go to a hidden directory by default.
35
+ - `repo-cleaner apply` sweeps and plans in memory, or reads `--plan <file>` (`-` reads stdin). With no `--row` it applies every row the re-check judges `safe-to-delete`, and drops a row to review with the new reason `needs-review`; name a review row with `--row` to apply it. It re-checks each row, prints the steps and asks `Delete N items? [y/N]`. `--yes` (`-y`) skips the question. With no terminal and no `--yes` it refuses with exit 2. `--plan -` needs `--yes` or `--dry-run`. On yes it runs only the steps it showed.
36
+ - `--only worktrees,branches,remotes` replaces `--scope local-branches,worktrees,remote-branches`, and works on every command.
37
+ - `--allow <risk>[=<category>:<id>]` replaces `--authorize`.
38
+ - Receipts go beside a `--plan` file as `<plan>.receipt-<timestamp>.json`, or to `--receipt <file>`. `--receipt-dir` and the default receipts directory are removed. A run that swept, or read stdin, writes a receipt only with `--receipt`.
39
+
40
+ Library: `applyPlan` with an empty row list selects every row of the plan and drops each one not judged `safe-to-delete` (`needs-review`), and `receiptPath` may be `null` to write no receipt. New `parsePlan(text, source)`. `stampedPath` is removed; use `fileStamp`.
41
+
42
+ ### Patch Changes
43
+
44
+ - 6e4284b: A registered worktree whose directory still exists but whose `.git` is gone (git lists it as prunable) no longer makes the whole repository unreadable with `git status: This is not a git repository`. It is a `safe-to-delete` row whose command is `git worktree prune`, which drops the registration and leaves the directory; `facts.directory_left` is true.
45
+ - 6e4284b: `apply` fixes: a branch name two repositories share applies in both instead of failing as ambiguous; a repository the sweep could not read (such as a failed fetch) is named on stderr and exits 1 instead of reading as "nothing to delete", and `plan` does the same; the receipt file records the rows the preview dropped; a worktree's fingerprint includes `on_disk`, so a prune row whose directory came back is dropped rather than removed. The status line strips terminal controls from paths and fits wide characters to the terminal's width. A worktree's `facts.merged` counts a patch match the same way a branch's does.
46
+
47
+ Library: `applyPlan` takes `RowSelection` rows (a row with an optional `repo`) and `ApplyOptions.dropped`.
48
+
49
+ ## 0.9.3
50
+
51
+ ### Patch Changes
52
+
53
+ - e0aaba5: A remote branch delete the remote refuses now fails its step with git's reason and what it means, such as `[remote rejected] (pre-receive hook declined): A hook on the remote refused the push.`, read from typed-git's per-ref push report instead of stderr.
54
+ - Updated dependencies [e0aaba5]
55
+ - @osdlc/typed-git@0.12.0
56
+ - @osdlc/typed-gh@0.14.1
57
+
3
58
  ## 0.9.2
4
59
 
5
60
  ### Patch Changes
package/README.md CHANGED
@@ -7,9 +7,9 @@ remote branches, classifying each as `safe-to-delete`, `safe-to-keep` or
7
7
  `needs-investigation` — with the evidence behind the verdict and the literal
8
8
  command that would act on it.
9
9
 
10
- The sweep never deletes anything. It emits commands, and can write the
11
- deletable rows as a plan. `repo-cleaner apply` deletes the plan rows you
12
- select, after checking each one again.
10
+ `repo-cleaner` (or `repo-cleaner status`) never deletes anything. It prints
11
+ the commands that would. `repo-cleaner apply` deletes: it checks each row
12
+ again, shows you the steps and asks before it runs them.
13
13
 
14
14
  ```console
15
15
  $ repo-cleaner
@@ -201,13 +201,40 @@ approves them by name:
201
201
  A row kept for structure (default branch, current HEAD, a branch held by a
202
202
  worktree, the primary or caller's worktree) has `deletable: false`.
203
203
 
204
+ ## Commands
205
+
206
+ ```bash
207
+ 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
209
+ repo-cleaner plan # the deletable rows as a plan, to stdout or -o <file>
210
+ repo-cleaner apply # re-check, show the steps, ask, delete
211
+ ```
212
+
213
+ All four take the same paths and sweep options.
214
+
215
+ ```console
216
+ $ repo-cleaner summary
217
+ /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
222
+
223
+ 38 to delete, 8 to review, across 1 repository.
224
+ ```
225
+
226
+ `merged` counts rows whose work is on the default branch: by ancestry, by a
227
+ merged PR at the branch's head, or by a patch match. A branch checked out in a
228
+ worktree counts as merged by ancestry or PR alone. The default branch and the
229
+ primary worktree are counted in `total` but not judged. Each row's verdict is
230
+ in its `facts.merged`.
231
+
204
232
  ## Scope is opt-in
205
233
 
206
234
  ```bash
207
235
  repo-cleaner # the repository you are standing in
208
236
  repo-cleaner ~/work/api ~/work/web # named repositories
209
237
  repo-cleaner ~/Developer # every checkout under it
210
- repo-cleaner --all # every repository in the config file
211
238
  ```
212
239
 
213
240
  A bare invocation never fans out. What an argument names is read off the disk
@@ -233,88 +260,88 @@ one that says which it could not find.
233
260
 
234
261
  ## Options
235
262
 
263
+ Every command that sweeps takes these:
264
+
236
265
  | Flag | Effect |
237
266
  | --- | --- |
238
- | `--all` | Sweep the repositories named in the config file |
239
- | `--config <path>` | Config file to read |
240
- | `--scope <list>` | `local-branches`, `worktrees`, `remote-branches` |
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 |
241
269
  | `--remote <name>` | Remote to measure against (default `origin`) |
242
270
  | `--no-gh` | Classify on git evidence alone |
243
271
  | `--no-fetch` | Skip the fetch; the header then says `not fetched` |
272
+
273
+ `status` also takes:
274
+
275
+ | Flag | Effect |
276
+ | --- | --- |
244
277
  | `--keep` | Include `safe-to-keep` rows in the report |
245
278
  | `--commands` | Print the proposed commands of rows that need no authorization, and nothing else |
246
279
  | `--output json` | The full inventory, facts included |
247
- | `--branch <name\|HEAD>` | Sweep one branch of this repository: its worktree, local and remote branch. Refuses the default branch |
248
- | `--write-plan` | Write the deletable rows as a plan for `apply` |
249
- | `--plan-out <path>` | Write the plan here; implies `--write-plan` |
250
- | `--version` | Print `repo-cleaner <version>` and exit; it must be the first argument |
251
280
 
252
- A checkout directory named `apply` is swept as `./apply`, and one named `dev` as
253
- `./dev`: `dev` runs the tool from a checkout (see Running from a checkout).
281
+ `--version` prints `repo-cleaner <version>` and exits; it must be the first
282
+ argument. A checkout directory named like a command (`status`, `summary`,
283
+ `plan`, `apply`, `dev`) is swept as `./status` and so on: `dev` runs the tool from a
284
+ checkout (see Running from a checkout).
254
285
 
255
286
  ## Plan and apply
256
287
 
257
288
  ```bash
258
- repo-cleaner --branch HEAD --plan-out plan.json
259
- repo-cleaner apply --plan plan.json --row worktree:/path/to/wt --row local_branch:feat/x
289
+ repo-cleaner apply # sweep, show the steps, ask, delete
290
+ repo-cleaner apply --only worktrees --yes # no question
291
+ repo-cleaner plan -o plan.json # save the plan to review or edit
292
+ repo-cleaner apply --plan plan.json --row local_branch:feat/x
293
+ repo-cleaner plan | repo-cleaner apply --plan - --yes
260
294
  ```
261
295
 
262
296
  A plan holds each deletable row with a fingerprint of the facts behind its
263
- verdict. `apply` sweeps again and drops a selected row that is gone, no longer
264
- deletable, whose facts moved, or whose `requires` was not granted with
265
- `--authorize <name>` (every row) or `--authorize <name>=<category>:<id>` (one
266
- row). It then removes worktrees, deletes local branches, deletes remote
267
- branches and prunes, in that order. Branch deletes are compare-and-swap, every
268
- step is verified, and the first failure stops the run.
269
-
270
- A receipt records every step and is rewritten after each one. Plans and receipts go under
271
- `repo-cleaner/plans/` and `repo-cleaner/receipts/` in
272
- [`@osdlc/tiered-dirs`](https://www.npmjs.com/package/@osdlc/tiered-dirs)' standalone durable home
273
- (`repo-cleaner` directory, `REPO_CLEANER_*` override variables), unless `--plan-out` or `--receipt-dir` says
274
- otherwise. `--dry-run` checks again and prints the steps without writing anything.
297
+ verdict. `apply` either sweeps and plans in memory, or reads a plan with
298
+ `--plan <file>` (`-` reads stdin). With no `--row` it applies every row judged
299
+ `safe-to-delete` and drops each row to review as `needs-review`. Name a row
300
+ with `--row <category>:<id>` to apply it, review rows included.
301
+
302
+ Before it deletes anything, `apply` sweeps again and drops a row that is gone,
303
+ no longer deletable, whose facts moved, or that carries a risk you did not
304
+ 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]`.
306
+ `--yes` skips the question. With no terminal to ask on and no `--yes`, it
307
+ refuses with exit 2 rather than deleting. `--plan -` needs `--yes` or
308
+ `--dry-run`, because stdin carries the plan. `--dry-run` prints the steps and
309
+ stops.
310
+
311
+ On yes it runs only the steps it showed: worktrees first, then local branches,
312
+ remote branches and a prune, in that order. Branch deletes are
313
+ compare-and-swap, every step is verified, and the first failure stops the run.
314
+
315
+ Every run ends with what it did:
316
+
317
+ ```text
318
+ deleted: 1 worktree, 3 local branches, 2 remote branches, 1 stale worktree pruned
319
+ dropped 2: 1 needs-review, 1 duplicate
320
+ status: complete
321
+ ```
322
+
323
+ The preview says `would delete:`, and a run that stops at a failed step says
324
+ how many rows it did not reach. `--output json` carries the same counts as
325
+ `summary`. Two clones of one remote list the same remote branches; `apply`
326
+ deletes each once and drops the other clone's row as `duplicate`. Remotes are
327
+ compared by the push URL git uses, with `url.*.insteadOf` applied.
328
+
329
+ A receipt records every step and is rewritten after each one. With
330
+ `--plan plan.json` it goes beside the plan, as
331
+ `plan.receipt-<timestamp>.json`. `--receipt <file>` puts it anywhere. A run
332
+ that swept, or read its plan from stdin, writes a receipt only when
333
+ `--receipt` names one; the printed steps are its record.
275
334
 
276
335
  ## Exit codes
277
336
 
278
337
  | Code | Meaning |
279
338
  | --- | --- |
280
- | `0` | Swept; or every selected row was applied or dropped with a reason |
281
- | `1` | Failed against the state it found: a `{name}` token nothing defines, an unreadable plan, a row not in the plan |
282
- | `2` | Cannot run as typed: an unknown flag, a malformed `--row`, `--branch` with `--all` or a path |
339
+ | `0` | Swept; or `apply` ran, was declined, or had nothing to run |
340
+ | `1` | Failed against the state it found: an unreadable plan, a row not in the plan |
341
+ | `2` | Cannot run as typed: an unknown flag, a malformed `--row`, `--branch` with a path, `apply` with nobody to ask and no `--yes` |
283
342
  | `10` | Nothing to sweep: no entry named a repository |
284
343
  | `11` | `apply` stopped at a failed step; the receipt says which |
285
344
 
286
- ## Configuration
287
-
288
- Only the CLI reads a config file. The library takes repositories as an
289
- argument, so a host that already knows where its repositories are passes them
290
- in.
291
-
292
- ```yaml
293
- paths:
294
- dev: ~/Developer
295
- repos:
296
- - '{dev}'
297
- - ~/work/api
298
- ```
299
-
300
- `paths:` names a directory once; any `repos:` entry then refers to it as
301
- `{name}`. A token nothing defines is an error, not an empty path.
302
-
303
- The file is [`@osdlc/config-layers`](https://www.npmjs.com/package/@osdlc/config-layers)'s machine config — `config.yaml` in the host
304
- OS's config home, resolved through
305
- [`@osdlc/tiered-dirs`](https://www.npmjs.com/package/@osdlc/tiered-dirs)' standalone tier (`repo-cleaner`
306
- directory, `REPO_CLEANER_*` override variables):
307
-
308
- | OS | Path |
309
- | --- | --- |
310
- | macOS | `~/Library/Application Support/repo-cleaner/config.yaml` |
311
- | Linux and other | `$XDG_CONFIG_HOME/repo-cleaner/config.yaml`, else `~/.config/repo-cleaner/config.yaml` |
312
- | Windows | `%APPDATA%\repo-cleaner\config.yaml` |
313
-
314
- `REPO_CLEANER_MACHINE_CONFIG`, or `--config <path>`, relocates it. A missing,
315
- unreadable or malformed file degrades to no repositories rather than failing
316
- the run.
317
-
318
345
  ## As a library
319
346
 
320
347
  ```ts
package/dist/apply.d.ts CHANGED
@@ -18,8 +18,8 @@ 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';
21
- import type { Receipt, RowRef } from './receipt.ts';
22
- import { type Authorization } from './types.ts';
21
+ import { type Dropped, type Receipt, type RowRef, type Step } from './receipt.ts';
22
+ import { type Authorization, type SweepItem, type SweepProgress } from './types.ts';
23
23
  /** An authorization the caller gave, for one row or (`row: null`) for all. */
24
24
  export interface Grant {
25
25
  name: Authorization;
@@ -38,13 +38,33 @@ export interface ApplyOptions extends HostHooks {
38
38
  /** The caller's directory; the sweep never offers the worktree holding it. */
39
39
  callerCwd: string;
40
40
  dryRun: boolean;
41
- /** Where the receipt goes. Unused under `dryRun`. */
42
- receiptPath: string;
43
- /** Shown in the receipt. */
41
+ /** Where the receipt is written after each step; null writes none. Unused under `dryRun`. */
42
+ receiptPath: string | null;
43
+ /** Where the plan came from, shown in the receipt. */
44
44
  planPath: string;
45
45
  /** Limit the run to the plan repository rooted here. */
46
46
  repoRoot?: string | undefined;
47
47
  now?: () => Date;
48
+ /**
49
+ * Drop each selected row the re-check does not judge `safe-to-delete`.
50
+ * Defaults to true when no rows are requested, so a row to review runs only
51
+ * when it is named. Pass it to keep that guard on a run whose rows came
52
+ * from such a selection, such as the run a preview confirmed.
53
+ */
54
+ safeOnly?: boolean | undefined;
55
+ /**
56
+ * Items to re-check against instead of sweeping again, keyed by repository
57
+ * root, already annotated: the hook is not applied to them again. For a
58
+ * preview straight after a sweep; the run that deletes should always sweep
59
+ * again.
60
+ */
61
+ swept?: ReadonlyMap<string, readonly SweepItem[]> | undefined;
62
+ /** Rows dropped before this run, such as by a preview; recorded first in the receipt. */
63
+ dropped?: readonly Dropped[] | undefined;
64
+ /** Passed to the re-sweep's `onProgress`, for a status line. */
65
+ onSweepProgress?: ((progress: SweepProgress) => void) | undefined;
66
+ /** Told before each step runs: its 0-based index, the step count and the step. Not called under `dryRun`. */
67
+ onStep?: ((index: number, total: number, step: Step) => void) | undefined;
48
68
  }
49
69
  /** A selection or grant the plan cannot satisfy. Nothing has been touched. */
50
70
  export declare class ApplyArgumentError extends Error {
@@ -54,9 +74,27 @@ export declare function parseRowRef(text: string): RowRef;
54
74
  /** `<name>` for every row, or `<name>=<category>:<id>` for one. */
55
75
  export declare function parseGrant(text: string): Grant;
56
76
  /**
57
- * Apply the `requested` rows of `plan` under `grants`. A row named twice runs once.
77
+ * A row to apply, optionally pinned to the plan repository rooted at `repo`.
78
+ * Without `repo`, a row id that two plan repositories share is ambiguous.
79
+ */
80
+ export type RowSelection = RowRef & {
81
+ repo?: string;
82
+ };
83
+ /**
84
+ * A network URL as `host[:port]/path`, the same for `git@host:o/r.git`,
85
+ * `ssh://git@host/o/r` and `https://host/o/r/`; the whole path is kept, so
86
+ * nested groups stay apart. Null for a path on this machine, including
87
+ * `file://` and a Windows drive path.
88
+ */
89
+ export declare function networkRemoteKey(url: string): string | null;
90
+ /**
91
+ * Apply the `requested` rows of `plan` under `grants`. An empty `requested`
92
+ * selects every row of the plan (of `repoRoot`'s repository, when given) and
93
+ * then drops each one the re-sweep does not judge `safe-to-delete`: a row to
94
+ * review runs only when it is named. A row named twice runs once. A row whose
95
+ * `requires` was not granted is dropped, never run.
58
96
  *
59
97
  * @throws ApplyArgumentError before touching anything, when a row or grant does
60
98
  * not fit the plan.
61
99
  */
62
- export declare function applyPlan(plan: Plan, requested: readonly RowRef[], grants: readonly Grant[], opts: ApplyOptions): Receipt;
100
+ export declare function applyPlan(plan: Plan, requested: readonly RowSelection[], grants: readonly Grant[], opts: ApplyOptions): Receipt;
package/dist/apply.js CHANGED
@@ -16,10 +16,11 @@
16
16
  */
17
17
  import { existsSync } from 'node:fs';
18
18
  import { resolve } from 'node:path';
19
- import { CommandFailed, Git } from '@osdlc/typed-git';
19
+ import { CommandFailed, Git, PUSH_REJECTIONS } from '@osdlc/typed-git';
20
20
  import { localDeleteCommand, remoteDeleteCommand } from "./branches.js";
21
21
  import { busyHolders, defaultProcessProbe, parseLockOwner } from "./processes.js";
22
22
  import { PLAN_CATEGORIES, fingerprint, isPlanCategory, sameFingerprint, } from "./plan.js";
23
+ import { RECEIPT_VERSION } from "./receipt.js";
23
24
  import { sweepRepo } from "./sweep.js";
24
25
  import { AUTHORIZATIONS } from "./types.js";
25
26
  import { worktreePruneCommand, worktreeRemoveCommand, worktreeUnlockCommand } from "./worktrees.js";
@@ -53,6 +54,8 @@ function errorText(err) {
53
54
  return err.detail.trim() || err.message;
54
55
  return err instanceof Error ? err.message : String(err);
55
56
  }
57
+ const sameSelection = (a, b) => sameRow(a, b) &&
58
+ (a.repo === undefined ? '' : resolve(a.repo)) === (b.repo === undefined ? '' : resolve(b.repo));
56
59
  /** Each selected row's plan repository. Unknown and ambiguous rows are errors. */
57
60
  function locate(plan, rows, repoRoot) {
58
61
  const repos = repoRoot === undefined
@@ -60,8 +63,9 @@ function locate(plan, rows, repoRoot) {
60
63
  : plan.repos.filter((r) => resolve(r.root) === resolve(repoRoot));
61
64
  if (repos.length === 0)
62
65
  throw new ApplyArgumentError(`no plan repository at ${repoRoot}`);
63
- return rows.map((ref) => {
64
- const hits = repos.filter((r) => r.rows.some((row) => sameRow(row, ref)));
66
+ return rows.map(({ repo, ...ref }) => {
67
+ const hits = repos.filter((r) => (repo === undefined || resolve(r.root) === resolve(repo)) &&
68
+ r.rows.some((row) => sameRow(row, ref)));
65
69
  if (hits.length === 0)
66
70
  throw new ApplyArgumentError(`row ${label(ref)} is not in the plan`);
67
71
  if (hits.length > 1) {
@@ -75,6 +79,10 @@ function granted(grants, ref, name) {
75
79
  }
76
80
  /** The plan repository's rows as they stand now, with the host's overlay applied. */
77
81
  function resweep(repo, opts) {
82
+ const given = opts.swept?.get(repo.root);
83
+ if (given !== undefined) {
84
+ return [...given];
85
+ }
78
86
  const sweep = sweepRepo(repo.root, {
79
87
  remote: repo.remote,
80
88
  noGh: repo.no_gh,
@@ -83,6 +91,7 @@ function resweep(repo, opts) {
83
91
  git: opts.git,
84
92
  gh: opts.gh,
85
93
  probe: opts.probe,
94
+ onProgress: opts.onSweepProgress,
86
95
  });
87
96
  if (sweep.repo === null || sweep.error !== null) {
88
97
  throw new Error(sweep.error ?? `could not read repository: ${repo.root}`);
@@ -90,7 +99,7 @@ function resweep(repo, opts) {
90
99
  return opts.annotate === undefined ? sweep.items : opts.annotate(sweep.repo, sweep.items);
91
100
  }
92
101
  /** Re-sweep, then split the selection into rows to run and rows dropped. */
93
- function recheck(selected, grants, opts) {
102
+ function recheck(selected, grants, implicit, opts) {
94
103
  const swept = new Map();
95
104
  for (const { repo } of selected) {
96
105
  if (!swept.has(repo))
@@ -112,6 +121,10 @@ function recheck(selected, grants, opts) {
112
121
  drop('not-deletable', item.reason);
113
122
  continue;
114
123
  }
124
+ if (implicit && item.classification !== 'safe-to-delete') {
125
+ drop('needs-review', `${item.reason}; name it with --row to apply it`);
126
+ continue;
127
+ }
115
128
  const now = fingerprint(item);
116
129
  if (!sameFingerprint(planned.fingerprint, now)) {
117
130
  drop('facts-changed', `planned ${JSON.stringify(planned.fingerprint)}, now ${JSON.stringify(now)}`);
@@ -234,7 +247,14 @@ function execute(step, row, git, probe) {
234
247
  case 'delete-remote': {
235
248
  const remote = String(row.item.facts['remote']);
236
249
  const ref = `refs/heads/${String(row.item.facts['branch'])}`;
237
- at.push(remote, `:${ref}`, { forceWithLease: { ref, expect: step.expected_sha } });
250
+ const report = at.push(remote, `:${ref}`, {
251
+ forceWithLease: { ref, expect: step.expected_sha },
252
+ });
253
+ const refused = report.refs.find((r) => r.flag === '!');
254
+ if (refused?.flag === '!') {
255
+ const why = refused.rawReason === null ? '' : ` (${refused.rawReason})`;
256
+ throw new Error(`${refused.summary}${why}: ${PUSH_REJECTIONS[refused.rejection].message}`);
257
+ }
238
258
  if (at.lsRemote(remote, ref).some((r) => r.ref === ref)) {
239
259
  throw new Error(`ls-remote still lists ${ref}`);
240
260
  }
@@ -243,24 +263,102 @@ function execute(step, row, git, probe) {
243
263
  }
244
264
  }
245
265
  /**
246
- * Apply the `requested` rows of `plan` under `grants`. A row named twice runs once.
266
+ * A network URL as `host[:port]/path`, the same for `git@host:o/r.git`,
267
+ * `ssh://git@host/o/r` and `https://host/o/r/`; the whole path is kept, so
268
+ * nested groups stay apart. Null for a path on this machine, including
269
+ * `file://` and a Windows drive path.
270
+ */
271
+ export function networkRemoteKey(url) {
272
+ const u = url.trim();
273
+ if (/^[a-z]:[\\/]/i.test(u) || /^file:/i.test(u))
274
+ return null;
275
+ const uri = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::(\d+))?(\/.*)?$/i.exec(u);
276
+ const scp = uri === null ? /^(?:[^@/:]+@)?([^/:]+):(?!\/\/)(.+)$/.exec(u) : null;
277
+ const host = uri?.[1] ?? scp?.[1];
278
+ if (host === undefined)
279
+ return null;
280
+ const port = uri?.[2] === undefined ? '' : `:${uri[2]}`;
281
+ const path = (uri?.[3] ?? scp?.[2] ?? '').replace(/^\/+|\/+$/g, '').replace(/\.git$/, '');
282
+ return `${host.toLowerCase()}${port}/${path}`;
283
+ }
284
+ /**
285
+ * One string per remote repository, so two clones that push to the same one
286
+ * agree. Built from the push URLs git would use (`remote get-url --push
287
+ * --all`, which applies `url.*.insteadOf` and `pushInsteadOf`): a network URL
288
+ * by {@link networkRemoteKey}, a local path resolved against the repository
289
+ * and kept as written, `.git` and all. Null when git reports no URL; such a
290
+ * row is never treated as a duplicate.
291
+ */
292
+ function remoteKey(git, repo, remote) {
293
+ const urls = git.at(repo).try.remoteGetUrls(remote, { push: true });
294
+ if (!urls.ok || urls.value === null || urls.value.length === 0)
295
+ return null;
296
+ const keys = urls.value.map((url) => networkRemoteKey(url) ?? resolve(repo, url.trim().replace(/^file:\/\/(localhost)?/i, '')));
297
+ return [...new Set(keys)].sort().join('\n');
298
+ }
299
+ /**
300
+ * Keep the first row for each remote branch. Two clones of one remote each
301
+ * list its branches; deleting one twice fails the second push and stops the
302
+ * run, so the later row is dropped as `duplicate`.
303
+ */
304
+ function onePerRemoteBranch({ accepted, dropped }, git) {
305
+ const seen = new Map();
306
+ const kept = [];
307
+ const extra = [];
308
+ for (const row of accepted) {
309
+ if (row.item.category !== 'remote_branch') {
310
+ kept.push(row);
311
+ continue;
312
+ }
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'])}`;
315
+ const first = key === null ? undefined : seen.get(key);
316
+ if (first !== undefined) {
317
+ extra.push({
318
+ repo: row.repo,
319
+ category: row.item.category,
320
+ id: row.item.id,
321
+ reason: 'duplicate',
322
+ detail: `the same remote branch is deleted from ${first}`,
323
+ });
324
+ continue;
325
+ }
326
+ if (key !== null)
327
+ seen.set(key, row.repo);
328
+ kept.push(row);
329
+ }
330
+ return { accepted: kept, dropped: [...dropped, ...extra] };
331
+ }
332
+ /**
333
+ * Apply the `requested` rows of `plan` under `grants`. An empty `requested`
334
+ * selects every row of the plan (of `repoRoot`'s repository, when given) and
335
+ * then drops each one the re-sweep does not judge `safe-to-delete`: a row to
336
+ * review runs only when it is named. A row named twice runs once. A row whose
337
+ * `requires` was not granted is dropped, never run.
247
338
  *
248
339
  * @throws ApplyArgumentError before touching anything, when a row or grant does
249
340
  * not fit the plan.
250
341
  */
251
342
  export function applyPlan(plan, requested, grants, opts) {
252
343
  const now = opts.now ?? (() => new Date());
253
- const rows = requested.filter((row, i) => requested.findIndex((other) => sameRow(other, row)) === i);
344
+ // Every row of the plan keeps its repository, so a branch name two
345
+ // repositories share is two rows, not an ambiguity.
346
+ const wanted = requested.length > 0
347
+ ? requested
348
+ : plan.repos
349
+ .filter((r) => opts.repoRoot === undefined || resolve(r.root) === resolve(opts.repoRoot))
350
+ .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);
254
352
  if (rows.length === 0)
255
- throw new ApplyArgumentError('no rows selected');
353
+ throw new ApplyArgumentError('the plan has no rows to apply');
256
354
  const selected = locate(plan, rows, opts.repoRoot);
257
355
  for (const grant of grants) {
258
356
  if (grant.row !== null && !rows.some((r) => sameRow(r, grant.row))) {
259
- throw new ApplyArgumentError(`--authorize names unselected row ${label(grant.row)}`);
357
+ throw new ApplyArgumentError(`--allow names unselected row ${label(grant.row)}`);
260
358
  }
261
359
  }
262
360
  const receipt = {
263
- version: 1,
361
+ version: RECEIPT_VERSION,
264
362
  plan: opts.planPath,
265
363
  status: opts.dryRun ? 'dry-run' : 'applying',
266
364
  started_at: now().toISOString(),
@@ -268,8 +366,9 @@ export function applyPlan(plan, requested, grants, opts) {
268
366
  dropped: [],
269
367
  steps: [],
270
368
  };
271
- const { accepted, dropped } = recheck(selected, grants, opts);
272
- receipt.dropped = dropped;
369
+ const rechecked = recheck(selected, grants, opts.safeOnly ?? requested.length === 0, opts);
370
+ const { accepted, dropped } = onePerRemoteBranch(rechecked, new Git(opts.callerCwd, { runner: opts.git }));
371
+ receipt.dropped = [...(opts.dropped ?? []), ...dropped];
273
372
  const ordered = PLAN_CATEGORIES.flatMap((category) => accepted.filter((row) => row.item.category === category));
274
373
  const plannedSteps = ordered.flatMap((row) => stepsFor(row).map((step) => [step, row]));
275
374
  for (const repo of new Set(ordered.map((row) => row.repo))) {
@@ -282,14 +381,19 @@ export function applyPlan(plan, requested, grants, opts) {
282
381
  receipt.finished_at = now().toISOString();
283
382
  return receipt;
284
383
  }
285
- const save = () => writeJsonAtomic(opts.receiptPath, receipt);
384
+ const receiptPath = opts.receiptPath;
385
+ const save = () => {
386
+ if (receiptPath !== null)
387
+ writeJsonAtomic(receiptPath, receipt);
388
+ };
286
389
  save();
287
390
  const git = new Git(opts.callerCwd, { runner: opts.git });
288
391
  const probe = opts.probe ?? defaultProcessProbe();
289
392
  // Worktrees whose lock this run cleared. typed-git has no `worktree lock`
290
393
  // verb, so a failed remove cannot put the lock back; the receipt says so.
291
394
  const unlocked = new Set();
292
- for (const [step, row] of plannedSteps) {
395
+ for (const [i, [step, row]] of plannedSteps.entries()) {
396
+ opts.onStep?.(i, plannedSteps.length, step);
293
397
  try {
294
398
  step.verification = execute(step, row, git, probe);
295
399
  step.result = 'ok';
@@ -27,6 +27,16 @@ export declare function recordPr(facts: Record<string, unknown>, pr: PRInfo | un
27
27
  };
28
28
  /** One sentence on why a merged PR did not count, for `investigationParts`. */
29
29
  export declare function gapSentence(pr: PRInfo, gap: PrEvidenceGap, repo: RepoInfo): string;
30
+ /**
31
+ * `facts.merged`: the work is on the default branch, by an exact-head merged
32
+ * PR, by ancestry or by a patch match. Every category records it this way, so
33
+ * `summary` counts one definition.
34
+ */
35
+ export declare function mergedFact(e: {
36
+ landed: boolean;
37
+ ancestor: boolean;
38
+ patchLanded: boolean;
39
+ }): boolean;
30
40
  /** `HEAD` as the branch checked out at `git`'s directory; any other name as given. */
31
41
  export declare function resolveBranch(value: string, git: Git): string;
32
42
  /**
@@ -38,7 +48,7 @@ export declare function resolveBranch(value: string, git: Git): string;
38
48
  export declare function localDeleteCommand(name: string, sha: string): string;
39
49
  /** Delete `remote`'s branch `name` only while it still points at `sha`. */
40
50
  export declare function remoteDeleteCommand(remote: string, name: string, sha: string): string;
41
- export declare function gatherLocalBranches(repo: RepoInfo, prs: Map<string, PRInfo>, worktreeBranches: ReadonlySet<string>, git: Git, only?: string): SweepItem[];
51
+ export declare function gatherLocalBranches(repo: RepoInfo, prs: Map<string, PRInfo>, worktreeBranches: ReadonlySet<string>, git: Git, only?: string, tick?: (done: number, total: number) => void): SweepItem[];
42
52
  export declare function classifyLocalBranch(opts: {
43
53
  name: string;
44
54
  upstream: string;
@@ -50,7 +60,7 @@ export declare function classifyLocalBranch(opts: {
50
60
  worktreeBranches: ReadonlySet<string>;
51
61
  git: Git;
52
62
  }): SweepItem;
53
- export declare function gatherRemoteBranches(repo: RepoInfo, prs: Map<string, PRInfo>, localBranchNames: ReadonlySet<string>, git: Git, only?: string): SweepItem[];
63
+ export declare function gatherRemoteBranches(repo: RepoInfo, prs: Map<string, PRInfo>, localBranchNames: ReadonlySet<string>, git: Git, only?: string, tick?: (done: number, total: number) => void): SweepItem[];
54
64
  export declare function classifyRemoteBranch(opts: {
55
65
  name: string;
56
66
  sha: string;