@osdlc/repo-cleaner 0.17.2 → 0.19.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +691 -305
  3. package/dist/branches.d.ts +37 -36
  4. package/dist/branches.js +199 -196
  5. package/dist/clean.d.ts +179 -0
  6. package/dist/clean.js +824 -0
  7. package/dist/cli.d.ts +12 -16
  8. package/dist/cli.js +367 -478
  9. package/dist/hooks.d.ts +8 -12
  10. package/dist/hooks.js +2 -2
  11. package/dist/index.d.ts +18 -25
  12. package/dist/index.js +16 -23
  13. package/dist/landing.d.ts +3 -3
  14. package/dist/landing.js +7 -7
  15. package/dist/plan.d.ts +76 -70
  16. package/dist/plan.js +82 -77
  17. package/dist/providers/github.d.ts +2 -1
  18. package/dist/providers/github.js +2 -1
  19. package/dist/providers/gitlab.d.ts +3 -2
  20. package/dist/providers/gitlab.js +14 -8
  21. package/dist/providers/index.d.ts +5 -4
  22. package/dist/providers/index.js +8 -5
  23. package/dist/providers/types.d.ts +8 -2
  24. package/dist/prs.d.ts +35 -7
  25. package/dist/prs.js +45 -12
  26. package/dist/receipt.d.ts +46 -57
  27. package/dist/receipt.js +52 -28
  28. package/dist/reference.d.ts +12 -0
  29. package/dist/reference.js +152 -0
  30. package/dist/remote-mirror.d.ts +9 -1
  31. package/dist/remote-mirror.js +19 -1
  32. package/dist/render.d.ts +66 -61
  33. package/dist/render.js +206 -255
  34. package/dist/resolve.d.ts +3 -3
  35. package/dist/resolve.js +4 -12
  36. package/dist/status-line.d.ts +1 -1
  37. package/dist/status-line.js +5 -5
  38. package/dist/step-board.d.ts +1 -1
  39. package/dist/step-board.js +2 -5
  40. package/dist/submodules.d.ts +120 -0
  41. package/dist/submodules.js +292 -0
  42. package/dist/sweep.d.ts +107 -64
  43. package/dist/sweep.js +333 -119
  44. package/dist/types.d.ts +294 -105
  45. package/dist/types.js +106 -54
  46. package/dist/util.d.ts +13 -0
  47. package/dist/util.js +36 -0
  48. package/dist/version.generated.d.ts +1 -1
  49. package/dist/version.generated.js +1 -1
  50. package/dist/worktrees.d.ts +21 -37
  51. package/dist/worktrees.js +198 -165
  52. package/package.json +5 -5
  53. package/dist/apply.d.ts +0 -131
  54. package/dist/apply.js +0 -541
package/dist/cli.js CHANGED
@@ -1,150 +1,129 @@
1
1
  /**
2
2
  * `repo-cleaner` — what has been merged, and what can go away.
3
3
  *
4
- * Four verbs over one set of sweep filters:
4
+ * repo-cleaner [target...] the report; deletes nothing
5
+ * repo-cleaner plan save the sweep's rows and their evidence, to run later
6
+ * repo-cleaner clean check again, show the steps, ask, delete
7
+ * repo-cleaner cache list or remove the clones kept for remote URL targets
5
8
  *
6
- * status (the default) inventory and classify; read-only but for the fetch
7
- * summary the same sweep as counts: totals, merged, delete, prune, review, keep
8
- * plan write the deletable rows as a plan, to stdout or a file
9
- * apply re-check, show the steps, ask, then delete
10
- *
11
- * and `cache`, which lists and removes the clones `--remote-url` keeps.
12
- *
13
- * `apply` works from a saved plan (`--plan`) or sweeps first. Either way it
14
- * re-checks every row, prints the steps it would run and asks before deleting;
15
- * `--yes` skips the question. Without a terminal and without `--yes` it
16
- * refuses rather than deleting. Scope is opt-in: the sweep covers the
17
- * repository you are standing in unless a path says otherwise.
9
+ * Every command takes `--help`, and `--help --extended` for the full
10
+ * reference: what each option means, the words the report uses, and which
11
+ * combinations are refused. A combination that contradicts itself, or one
12
+ * with an option that would have no effect, is refused with exit 2 before
13
+ * anything is swept.
18
14
  *
19
15
  * Exit codes:
20
- * 0 swept; or apply ran, was declined, or had nothing to run
16
+ * 0 swept; or clean ran, was declined, or had nothing to do
21
17
  * 1 failed against the state it found
22
18
  * 2 cannot run as typed
23
- * 10 nothing to sweep: no entry named a repository
24
- * 11 apply stopped at a failed step; the receipt says which
19
+ * 10 no target named a repository
20
+ * 11 clean stopped at a failed step; the receipt says which
25
21
  */
26
- import { existsSync, readSync, realpathSync, rmSync } from 'node:fs';
27
- import { basename, dirname, join, resolve, sep } from 'node:path';
22
+ import { readSync, rmSync } from 'node:fs';
23
+ import { basename, dirname, join, resolve } from 'node:path';
28
24
  import { isatty } from 'node:tty';
29
- import { EXIT, askYesNo, canPrompt, defineCli, systemDirs } from '@osdlc/cli-tool';
30
- import { Git } from '@osdlc/typed-git';
25
+ import { EXIT, askYesNo, canPrompt, defineCli, renderError, } from '@osdlc/cli-tool';
31
26
  import { detectColorLevel, stripControls } from '@osdlc/ux';
32
- import { resolveBranch } from "./branches.js";
33
- import { VENDOR_DIRS } from "./devlink.js";
34
- import { cachedMirrorOf, ensureMirror, listMirrors, mirrorProblem, redactUrl, } from "./remote-mirror.js";
35
- import { ApplyArgumentError, applyPlan, parseGrant, parseRowRef } from "./apply.js";
36
- import { PLAN_VERSION, fileStamp, parsePlan, planRepo, readPlan, writePlan, } from "./plan.js";
27
+ import { check, checkApproval, parseAllow, run } from "./clean.js";
28
+ import { fileStamp, parsePlan, plan as planOf, readPlan, writePlan } from "./plan.js";
37
29
  import { PROVIDER_CHOICES } from "./providers/index.js";
38
- import { RECEIPT_VERSION } from "./receipt.js";
39
- import { PLAIN, proposedCommands, renderCountsJson, renderCountsText, renderJson, renderPreview, renderPreviewDrops, renderReceipt, renderStepFailure, renderSummary, renderText, rowsActedOn, summarizeReceipt, TERMINAL_STYLE, } from "./render.js";
30
+ import { REFERENCE } from "./reference.js";
31
+ import { cachedMirrorOf, defaultCacheDir, listMirrors, redactUrl, } from "./remote-mirror.js";
32
+ import { PLAIN, TERMINAL_STYLE, renderCommands, renderJson, renderLateDrops, renderPreview, renderReceipt, renderStepFailure, renderSummary, renderText, renderTotals, rowsActedOn, summarizeRun, } from "./render.js";
40
33
  import { NO_STATUS_LINE, describeStep, describeSweep, statusLine, } from "./status-line.js";
41
34
  import { stepBoard } from "./step-board.js";
42
- import { sweepRepos } from "./sweep.js";
35
+ import { sweep as sweepTargets, sweptWith } from "./sweep.js";
36
+ import { KINDS, RISKS, RepoCleanerError, sweepNotes, } from "./types.js";
43
37
  const EXIT_NOTHING_TO_SWEEP = 10;
44
38
  const EXIT_STEP_FAILED = 11;
45
39
  const NAME = 'repo-cleaner';
46
- /** The `--only` names, and the sweep scope each selects. */
47
- const ONLY = {
48
- worktrees: 'worktrees',
49
- branches: 'local-branches',
50
- remotes: 'remote-branches',
40
+ /** `--help` and its modifier: commander must not own `--help`, or it would never see `--extended`. */
41
+ const HELP_FLAGS = {
42
+ help: { kind: 'boolean', short: 'h', help: 'show this message' },
43
+ extended: { kind: 'boolean', help: 'with --help: the full reference' },
51
44
  };
52
- /** The flags every verb that sweeps takes. */
45
+ /** The flags every command that sweeps takes. */
53
46
  const SWEEP_FLAGS = {
47
+ remote: {
48
+ kind: 'boolean',
49
+ help: 'also judge remote branches: fetches first and looks up pull requests; slower',
50
+ },
51
+ offline: { kind: 'boolean', help: 'with --remote: do not fetch' },
54
52
  only: {
55
53
  kind: 'list',
56
- valueName: 'list',
57
- help: `comma-separated: ${Object.keys(ONLY).join(', ')} (default: worktrees, branches; remotes with --remotes)`,
58
- },
59
- remotes: {
60
- kind: 'boolean',
61
- help: 'also sweep remote branches, which is slower: each one is compared with the default branch',
54
+ valueName: 'kinds',
55
+ help: `only these kinds of row: ${KINDS.join(', ')} (default: worktree,branch; remote too with --remote)`,
62
56
  },
63
57
  branch: {
64
58
  kind: 'string',
65
59
  valueName: 'name|HEAD',
66
- help: 'only this branch of this repository: its worktree and local branch, and its remote branch with --remotes',
60
+ help: "only one branch's rows: its worktree, local branch, and remote branch with --remote",
67
61
  },
68
- measureRemote: {
62
+ against: {
69
63
  kind: 'string',
70
- valueName: 'name',
71
- help: 'the remote whose default branch work is measured against (default: origin)',
72
- },
73
- remoteUrl: {
74
- kind: 'list',
75
- valueName: 'url',
76
- default: [],
77
- commaSeparated: false,
78
- help: 'sweep the branches of this remote with no checkout of your own, through a clone kept in the cache directory; repeatable',
64
+ valueName: 'remote',
65
+ help: 'the remote whose default branch counts as merged into (default: origin)',
79
66
  },
80
67
  provider: {
81
68
  kind: 'enum',
82
69
  choices: PROVIDER_CHOICES,
83
70
  valueName: 'name',
84
- help: 'with --remotes: where pull requests come from: auto (by remote host), github (gh), gitlab (glab), or none to classify on git evidence alone',
85
- },
86
- fetch: {
87
- kind: 'boolean',
88
- negatable: true,
89
- help: 'with --remotes: classify on the remote-tracking refs already on disk (without --remotes nothing is fetched)',
90
- },
91
- progress: {
92
- kind: 'boolean',
93
- negatable: true,
94
- help: 'no status line on stderr (drawn only when stderr is a terminal)',
71
+ help: 'with --remote: where pull requests come from',
95
72
  },
73
+ progress: { kind: 'boolean', negatable: true, help: 'no progress line on stderr' },
96
74
  };
97
- const PATHS = [
75
+ /** A repeatable flag that takes a row (or `risk[=row]`) each time, never split on commas. */
76
+ const rowList = (valueName, help) => ({
77
+ kind: 'list',
78
+ valueName,
79
+ default: [],
80
+ commaSeparated: false,
81
+ help,
82
+ });
83
+ const TARGETS = [
98
84
  {
99
- name: 'path',
85
+ name: 'target',
100
86
  variadic: true,
101
- help: 'a checkout, or a directory containing checkouts (default: the one you are in)',
87
+ help: 'a checkout, a directory of checkouts, or a remote URL (default: the directory you are in)',
102
88
  },
103
89
  ];
104
- const OUTPUT = {
105
- kind: 'enum',
106
- choices: ['text', 'json'],
107
- valueName: 'form',
108
- default: 'text',
109
- help: 'report format',
110
- };
111
- const statusCli = () => defineCli({
90
+ const MORE = `Run with --help --extended for the full reference: what every option means, the words the report uses, and which combinations are refused.`;
91
+ const reportCli = () => defineCli({
112
92
  name: NAME,
113
- summary: 'Inventory local branches and worktrees (remote branches too with --remotes), and say ' +
114
- 'which have been merged into the default branch. Offline unless --remotes, which fetches ' +
115
- 'each remote first and asks the remote host about pull requests; deletes nothing.',
116
- positionals: PATHS,
93
+ summary: 'Say which worktrees and local branches (remote branches too, with --remote) are merged ' +
94
+ 'into the default branch and can go, with the evidence and the command that would delete ' +
95
+ 'each. Deletes nothing.',
96
+ autoHelp: false,
97
+ positionals: TARGETS,
117
98
  flags: {
118
99
  ...SWEEP_FLAGS,
119
- keep: { kind: 'boolean', help: 'include safe-to-keep rows in the report' },
120
- commands: { kind: 'boolean', help: 'print the proposed commands only' },
121
- output: OUTPUT,
100
+ all: { kind: 'boolean', help: 'also show keep rows (with --format text)' },
101
+ format: {
102
+ kind: 'enum',
103
+ choices: ['text', 'json', 'summary', 'commands'],
104
+ valueName: 'form',
105
+ default: 'text',
106
+ help: 'text (the report), json (everything), summary (counts), commands (only the safe commands)',
107
+ },
108
+ ...HELP_FLAGS,
122
109
  },
123
- epilogue: `commands:
124
- ${NAME} [status] this report (the default)
125
- ${NAME} summary counts per repository: total, merged, delete, prune, review, keep
126
- ${NAME} plan write the deletable rows as a plan
127
- ${NAME} apply re-check, show the steps, ask, delete
128
- ${NAME} cache list or remove the clones --remote-url keeps
129
- ${NAME} dev run ${NAME} from a development checkout
110
+ shortEpilogue: `commands:
111
+ ${NAME} [target...] this report (the default)
112
+ ${NAME} plan save the sweep's rows and their evidence, to run later
113
+ ${NAME} clean check again, show the steps, ask, delete
114
+ ${NAME} cache list or remove the clones kept for remote URL targets
115
+ ${NAME} dev run ${NAME} from a development checkout
130
116
 
131
- Each command takes --help. A checkout directory named like a command is
132
- swept as ./<name>.`,
133
- });
134
- const summaryCli = () => defineCli({
135
- name: `${NAME} summary`,
136
- summary: 'Count local branches and worktrees (and remote branches with --remotes) per repository: how many there are, ' +
137
- 'how many are merged into the default branch, and how many would be deleted, pruned, reviewed or kept.',
138
- positionals: PATHS,
139
- flags: { ...SWEEP_FLAGS, output: OUTPUT },
140
- epilogue: 'merged: the work is on the default branch. Rows kept for structure (the default ' +
141
- 'branch, the primary worktree) are counted in total but not judged merged.',
117
+ A checkout directory named like a command is swept as ./<name>.
118
+ ${MORE}`,
119
+ epilogue: REFERENCE.report,
142
120
  });
143
121
  const planCli = () => defineCli({
144
122
  name: `${NAME} plan`,
145
- summary: 'Sweep, and write the deletable rows as a plan for `apply`: each row with the facts its ' +
146
- 'verdict rests on, so `apply` can tell what moved since.',
147
- positionals: PATHS,
123
+ summary: 'Sweep, and save every row, each with a fingerprint of the evidence ' +
124
+ 'behind its verdict, so `clean --plan` can run it later and drop what moved since.',
125
+ autoHelp: false,
126
+ positionals: TARGETS,
148
127
  flags: {
149
128
  ...SWEEP_FLAGS,
150
129
  out: {
@@ -154,77 +133,72 @@ const planCli = () => defineCli({
154
133
  default: '-',
155
134
  help: 'write the plan here; - is stdout (the default)',
156
135
  },
136
+ ...HELP_FLAGS,
157
137
  },
158
- epilogue: 'The one-line summary goes to stderr, so stdout carries only the plan.',
138
+ shortEpilogue: `The totals line goes to stderr, so stdout carries only the plan.\n${MORE}`,
139
+ epilogue: REFERENCE.plan,
159
140
  });
160
- const applyCli = () => defineCli({
161
- name: `${NAME} apply`,
162
- summary: 'Delete what a sweep or a saved plan found deletable: re-check each row, show the steps, ' +
163
- 'ask, then delete compare-and-swap, verifying each step.',
164
- positionals: PATHS,
141
+ const cleanCli = () => defineCli({
142
+ name: `${NAME} clean`,
143
+ summary: 'Delete what the sweep (or a saved plan) found: check each row again, show the steps, ask, ' +
144
+ 'then delete, compare-and-swap, verifying each step. Without --pick, every row judged ' +
145
+ 'delete runs.',
146
+ autoHelp: false,
147
+ positionals: TARGETS,
165
148
  flags: {
166
149
  ...SWEEP_FLAGS,
150
+ pick: rowList('row', 'work on exactly these rows; a review row runs only when picked; repeatable'),
151
+ spare: rowList('row', 'leave this row alone; a spared remote branch that moved stops the run; repeatable'),
152
+ allow: rowList('risk[=row]', 'accept a risk for every row, or for one row; repeatable'),
167
153
  plan: {
168
154
  kind: 'string',
169
155
  valueName: 'file',
170
- help: 'apply this plan (- reads stdin) instead of sweeping',
171
- },
172
- row: {
173
- kind: 'list',
174
- valueName: 'category:id',
175
- default: [],
176
- commaSeparated: false,
177
- help: 'apply only this row, e.g. local_branch:feat/x; repeatable (default: every row)',
178
- },
179
- keep: {
180
- kind: 'list',
181
- valueName: 'remote_branch:id',
182
- default: [],
183
- commaSeparated: false,
184
- help: 'keep this remote branch, but refuse the run if its tip moved since the plan, e.g. remote_branch:origin/feat/x; repeatable',
185
- },
186
- allow: {
187
- kind: 'list',
188
- valueName: 'risk[=category:id]',
189
- default: [],
190
- commaSeparated: false,
191
- help: 'accept a risk for every row, or for one row; repeatable',
192
- },
193
- repoRoot: {
194
- kind: 'string',
195
- valueName: 'path',
196
- help: 'with a plan of several repositories: apply only the one rooted here',
156
+ help: 'run this plan (- reads stdin) instead of sweeping',
197
157
  },
158
+ yes: { kind: 'boolean', short: 'y', help: 'delete without asking' },
159
+ dryRun: { kind: 'boolean', help: 'check and show the steps; delete nothing' },
198
160
  receipt: {
199
161
  kind: 'string',
200
162
  valueName: 'file',
201
163
  help: 'write the receipt here (default: beside a --plan file; none otherwise)',
202
164
  },
203
- yes: { kind: 'boolean', short: 'y', help: 'delete without asking' },
204
- dryRun: { kind: 'boolean', help: 're-check and print the steps; delete nothing' },
205
- output: OUTPUT,
165
+ format: {
166
+ kind: 'enum',
167
+ choices: ['text', 'json'],
168
+ valueName: 'form',
169
+ default: 'text',
170
+ help: 'how the steps and the outcome are shown',
171
+ },
172
+ ...HELP_FLAGS,
206
173
  },
207
- epilogue: `risks: abandon-unmerged, discard-changes, confirm-open-pr
208
- A row that carries a risk you did not --allow is skipped, never deleted.`,
174
+ shortEpilogue: `rows: worktree:<path>, branch:<name>, remote:<remote>/<name>, or a bare name
175
+ risks: ${RISKS.join(', ')}
176
+ A row that carries a risk you did not --allow is skipped, never deleted.
177
+ ${MORE}`,
178
+ epilogue: REFERENCE.clean,
209
179
  });
210
180
  const cacheCli = () => defineCli({
211
181
  name: `${NAME} cache`,
212
- summary: 'List the clones --remote-url keeps in the cache directory, or remove them. ' +
213
- 'A removed clone is cloned again by the next --remote-url run.',
182
+ summary: 'List the clones kept for remote URL targets, or remove them. A removed clone is cloned ' +
183
+ 'again by the next sweep of its URL.',
184
+ autoHelp: false,
214
185
  positionals: [
215
186
  { name: 'verb', help: 'list (the default) or remove' },
216
- {
217
- name: 'url',
218
- variadic: true,
219
- help: 'with remove: the URL whose clone to remove, as given to --remote-url',
220
- },
187
+ { name: 'url', variadic: true, help: 'with remove: the URL whose clone to remove' },
221
188
  ],
222
189
  flags: {
223
190
  all: { kind: 'boolean', help: 'with remove: remove every clone' },
224
- output: OUTPUT,
191
+ format: {
192
+ kind: 'enum',
193
+ choices: ['text', 'json'],
194
+ valueName: 'form',
195
+ default: 'text',
196
+ help: 'how the clones are shown',
197
+ },
198
+ ...HELP_FLAGS,
225
199
  },
226
- epilogue: 'A plan written with --remote-url names its clone. Removing the clone means that plan ' +
227
- 'can no longer be applied; sweep again instead.',
200
+ shortEpilogue: `A plan that names a removed clone cannot run; sweep again instead.\n${MORE}`,
201
+ epilogue: REFERENCE.cache,
228
202
  });
229
203
  const terminalConfirm = (question) => (canPrompt() ? askYesNo(question) : null);
230
204
  const PAUSE = new Int32Array(new SharedArrayBuffer(4));
@@ -256,211 +230,156 @@ function usageError(ctx, name, message) {
256
230
  ctx.io.stderr(`${name}: ${message}\n`);
257
231
  return EXIT.usage;
258
232
  }
259
- function failure(ctx, err) {
260
- ctx.io.stderr(`${NAME}: ${err instanceof Error ? err.message : String(err)}\n`);
233
+ function failure(ctx, name, err) {
234
+ ctx.io.stderr(`${name}: ${err instanceof Error ? err.message : String(err)}\n`);
261
235
  return EXIT.error;
262
236
  }
263
- /** Color for a report on `stream`, when it is a terminal that takes color. */
237
+ /** A refusal as its exit code: 2 for what was typed, 1 for the state found. */
238
+ function refusal(ctx, name, err) {
239
+ if (err instanceof RepoCleanerError && (err.code === 'conflict' || err.code === 'bad-input')) {
240
+ return usageError(ctx, name, err.message);
241
+ }
242
+ return failure(ctx, name, err);
243
+ }
244
+ /** Parse, and answer `--help` (and `--help --extended`) itself. */
245
+ function parseWith(cli, name, argv, ctx) {
246
+ const parsed = cli.parse(argv, ctx.io);
247
+ if (parsed.status === 'help')
248
+ return { status: 'done', code: EXIT.ok };
249
+ if (parsed.status === 'error') {
250
+ return { status: 'done', code: usageError(ctx, name, parsed.message) };
251
+ }
252
+ const v = parsed.values;
253
+ if (v.help) {
254
+ ctx.io.stdout(cli.help({ all: v.extended }));
255
+ return { status: 'done', code: EXIT.ok };
256
+ }
257
+ if (v.extended) {
258
+ return { status: 'done', code: usageError(ctx, name, '--extended goes with --help') };
259
+ }
260
+ return parsed;
261
+ }
262
+ /** The sweep options as typed, for {@link sweptWith} and {@link sweepTargets}. */
263
+ function sweepOptions(v, ctx) {
264
+ const only = v.only?.filter((s) => s !== '');
265
+ return {
266
+ cwd: ctx.cwd,
267
+ remote: v.remote,
268
+ offline: v.offline,
269
+ ...(only === undefined ? {} : { only: only }),
270
+ ...(v.branch === undefined ? {} : { branch: v.branch }),
271
+ ...(v.against === undefined ? {} : { against: v.against }),
272
+ ...(v.provider === undefined ? {} : { provider: v.provider }),
273
+ cacheDir: defaultCacheDir(ctx.env),
274
+ };
275
+ }
276
+ /** Color for output on `stream`, when it is a terminal that takes color. */
264
277
  function styleFor(ctx, stream) {
265
278
  const level = detectColorLevel({ env: ctx.env, isTTY: ctx.terminal?.[stream] === true });
266
279
  return level === 'none' ? PLAIN : TERMINAL_STYLE;
267
280
  }
268
281
  /** The note under a text report that left remote branches out, or '' when it did not. */
269
- function remotesNote(swept, style) {
270
- if (swept.kind !== 'ok' || swept.scope.includes('remote-branches'))
282
+ function remoteNote(s, style) {
283
+ if (s.options.only.includes('remote'))
271
284
  return '';
272
- if (!swept.result.repos.some((r) => r.repo?.remote != null))
285
+ if (!s.repos.some((r) => r.repo?.remote != null))
273
286
  return '';
274
- return (style.muted('Local only: nothing fetched, no pull requests looked up, remote branches not checked; add --remotes for all three.') + '\n');
275
- }
276
- /** Where `--remote-url` keeps its clones. */
277
- function mirrorCache(ctx) {
278
- return systemDirs(VENDOR_DIRS, { env: ctx.env }).standalone(NAME).cache().app('remotes').path;
287
+ return (style.muted('Local only: nothing fetched, no pull requests looked up, remote branches not checked; add --remote for all three.') + '\n');
279
288
  }
280
- /** Local work by default: remote branches only when asked for, with `--remotes` or `--only remotes`. */
281
- const LOCAL_SCOPE = ['worktrees', 'local-branches'];
282
- function sweepScope(only, remotes) {
283
- const scope = only === undefined ? [...LOCAL_SCOPE] : only.map((s) => ONLY[s]);
284
- if (remotes && !scope.includes('remote-branches'))
285
- scope.push('remote-branches');
286
- return scope;
287
- }
288
- /** Validate the sweep flags and run the sweep, or answer with the exit code. */
289
- function sweep(name, v, paths, ctx, status) {
290
- const only = v.only?.filter((s) => s !== '');
291
- const unknown = (only ?? []).filter((s) => !(s in ONLY));
292
- if (unknown.length > 0) {
293
- const message = `unknown --only value(s): ${unknown.join(', ')}`;
294
- return { kind: 'exit', code: usageError(ctx, name, message) };
295
- }
296
- if (v.branch !== undefined && paths.length > 0) {
297
- const message = '--branch sweeps this repository only; drop the paths';
298
- return { kind: 'exit', code: usageError(ctx, name, message) };
299
- }
300
- // An empty URL is refused, not dropped: `--remote-url "$UNSET"` must not
301
- // fall back to sweeping the checkout you are standing in.
302
- if (v.remoteUrl.some((u) => u.trim() === '')) {
303
- return {
304
- kind: 'exit',
305
- code: usageError(ctx, name, '--remote-url needs a URL; got an empty one'),
306
- };
307
- }
308
- const urls = v.remoteUrl;
309
- if (urls.length > 0) {
310
- const conflict = paths.length > 0
311
- ? '--remote-url sweeps a remote, not a checkout; drop the paths'
312
- : v.branch !== undefined
313
- ? '--branch reads the checkout you are in; it does not combine with --remote-url'
314
- : (only ?? []).some((s) => s !== 'remotes')
315
- ? '--remote-url has remote branches only; drop --only or pass --only remotes'
316
- : null;
317
- if (conflict !== null)
318
- return { kind: 'exit', code: usageError(ctx, name, conflict) };
319
- }
320
- let branch;
321
- try {
322
- branch = v.branch === undefined ? undefined : resolveBranch(v.branch, new Git(ctx.cwd));
323
- }
324
- catch (err) {
325
- return { kind: 'exit', code: failure(ctx, err) };
326
- }
327
- const scope = urls.length > 0 ? ['remote-branches'] : sweepScope(only, v.remotes);
328
- const online = scope.includes('remote-branches');
329
- const options = {
330
- baseDir: ctx.cwd,
331
- cwd: ctx.cwd,
332
- // Local by default means offline: the fetch and the pull-request lookup go with remote branches.
333
- noProvider: !online || v.provider === 'none',
334
- fetch: online && v.fetch,
335
- provider: v.provider,
336
- branch,
337
- // Mid-walk, while the wait is happening — the summary comes too late to
289
+ /** Sweep, or answer with the exit code. */
290
+ function doSweep(name, v, targets, ctx, status) {
291
+ const opts = {
292
+ ...sweepOptions(v, ctx),
293
+ // Mid-walk, while the wait is happening: the totals come too late to
338
294
  // explain why the tool appeared to hang.
339
295
  onWarn: (message) => ctx.io.stderr(`${NAME}: ${message}\n`),
340
296
  ...(v.progress ? { onProgress: (p) => status.update(describeSweep(p)) } : {}),
341
- scope,
342
- ...(v.measureRemote === undefined ? {} : { remote: v.measureRemote }),
343
297
  };
344
- let entries = paths.length > 0 ? [...paths] : [ctx.cwd];
345
- if (urls.length > 0) {
346
- const cache = mirrorCache(ctx);
347
- try {
348
- entries = urls.map((url, i) => {
349
- if (v.progress)
350
- status.update(`${NAME}: cloning remote ${i + 1}/${urls.length}`);
351
- return ensureMirror(url, cache);
352
- });
353
- }
354
- catch (err) {
355
- status.clear();
356
- return { kind: 'exit', code: failure(ctx, err) };
357
- }
358
- }
359
298
  if (v.progress)
360
299
  status.update(`${NAME}: finding repositories`);
361
- const result = sweepRepos(entries, options);
362
- status.clear();
363
- if (branch !== undefined && result.repos.length > 1) {
364
- const err = new Error(`--branch sweeps one repository; ${result.repos.length} found under ${ctx.cwd}`);
365
- return { kind: 'exit', code: failure(ctx, err) };
300
+ let result;
301
+ try {
302
+ result = sweepTargets(targets, opts);
366
303
  }
367
- const refused = branch === undefined ? null : (result.repos[0]?.error ?? null);
304
+ catch (err) {
305
+ status.clear();
306
+ return { kind: 'exit', code: refusal(ctx, name, err) };
307
+ }
308
+ status.clear();
309
+ const refused = v.branch === undefined ? null : (result.repos[0]?.error ?? null);
368
310
  if (refused !== null)
369
- return { kind: 'exit', code: failure(ctx, new Error(refused)) };
311
+ return { kind: 'exit', code: failure(ctx, name, new Error(refused)) };
370
312
  if (result.repos.length === 0)
371
- return { kind: 'nothing', result };
372
- return { kind: 'ok', result, branch, values: v, scope, noProvider: options.noProvider === true };
313
+ return { kind: 'nothing', sweep: result };
314
+ return { kind: 'ok', sweep: result };
373
315
  }
374
316
  /**
375
317
  * Name each repository the sweep could not read, on stderr. True when there
376
- * was one: a verb that acts on the sweep must not pass an unread repository
377
- * off as an empty one.
318
+ * was one: a command that acts on the sweep must not pass an unread
319
+ * repository off as an empty one.
378
320
  */
379
- function reportUnreadable(ctx, name, result) {
380
- const failed = result.repos.filter((r) => r.error !== null);
321
+ function reportUnreadable(ctx, name, s) {
322
+ const failed = s.repos.filter((r) => r.error !== null);
381
323
  for (const r of failed)
382
324
  ctx.io.stderr(`${name}: ${r.repo?.root ?? r.path}: ${r.error}\n`);
383
325
  return failed.length > 0;
384
326
  }
385
- function planOf(swept, now) {
386
- const opts = {
387
- remote: swept.values.measureRemote ?? null,
388
- noProvider: swept.noProvider,
389
- provider: swept.values.provider,
390
- branch: swept.branch ?? null,
391
- };
392
- return {
393
- version: PLAN_VERSION,
394
- created_at: now.toISOString(),
395
- repos: swept.result.repos.flatMap((s) => s.repo === null ? [] : [planRepo(s.repo.root, { ...opts, fetch: s.repo.fetched }, s.items)]),
396
- };
327
+ /** Each repository's `RepoSweep.notes`, on stderr, for the outputs that do not carry them. */
328
+ function reportNotes(ctx, name, s) {
329
+ writeNotes(ctx, name, sweepNotes(s));
397
330
  }
398
- function runStatus(argv, ctx, status) {
399
- const parsed = statusCli().parse(argv, ctx.io);
400
- if (parsed.status === 'help')
401
- return EXIT.ok;
402
- if (parsed.status === 'error')
403
- return usageError(ctx, NAME, parsed.message);
331
+ /** Notes as `<name>: <repo>: <note>` lines on stderr. A note quotes git's and the provider's output, so it goes out as an error line does, controls removed. */
332
+ function writeNotes(ctx, name, notes) {
333
+ for (const { repo, note } of notes)
334
+ ctx.io.stderr(renderError(`${repo}: ${note}`, name));
335
+ }
336
+ function runReport(argv, ctx, status) {
337
+ const parsed = parseWith(reportCli(), NAME, argv, ctx);
338
+ if (parsed.status === 'done')
339
+ return parsed.code;
404
340
  const v = parsed.values;
341
+ if (v.all && v.format !== 'text') {
342
+ return usageError(ctx, NAME, `--all has no effect with --format ${v.format}: it shows keep rows in the text report`);
343
+ }
405
344
  const style = styleFor(ctx, 'stdout');
406
- const render = (r) => v.output === 'json' ? renderJson(r) : renderText(r, { keep: v.keep, style });
407
- const swept = sweep(NAME, v, parsed.positionals, ctx, status);
345
+ const swept = doSweep(NAME, v, parsed.positionals, ctx, status);
408
346
  if (swept.kind === 'exit')
409
347
  return swept.code;
410
- if (swept.kind === 'nothing') {
411
- ctx.io.stdout(render(swept.result));
412
- return EXIT_NOTHING_TO_SWEEP;
413
- }
414
- if (v.commands) {
415
- const commands = proposedCommands(swept.result);
348
+ const s = swept.sweep;
349
+ if (v.format === 'json')
350
+ ctx.io.stdout(renderJson(s));
351
+ else if (v.format === 'summary')
352
+ ctx.io.stdout(renderSummary(s, style) + remoteNote(s, style));
353
+ else if (v.format === 'commands') {
354
+ reportNotes(ctx, NAME, s);
355
+ const commands = renderCommands(s);
416
356
  if (commands.length > 0)
417
357
  ctx.io.stdout(commands.join('\n') + '\n');
418
358
  }
419
359
  else {
420
- ctx.io.stdout(render(swept.result));
421
- if (v.output === 'text')
422
- ctx.io.stdout(remotesNote(swept, style));
360
+ ctx.io.stdout(renderText(s, { all: v.all, style }) + remoteNote(s, style));
423
361
  }
424
- return EXIT.ok;
425
- }
426
- function runSummary(argv, ctx, status) {
427
- const name = `${NAME} summary`;
428
- const parsed = summaryCli().parse(argv, ctx.io);
429
- if (parsed.status === 'help')
430
- return EXIT.ok;
431
- if (parsed.status === 'error')
432
- return usageError(ctx, name, parsed.message);
433
- const v = parsed.values;
434
- const style = styleFor(ctx, 'stdout');
435
- const render = (r) => v.output === 'json' ? renderCountsJson(r) : renderCountsText(r, style);
436
- const swept = sweep(name, v, parsed.positionals, ctx, status);
437
- if (swept.kind === 'exit')
438
- return swept.code;
439
- ctx.io.stdout(render(swept.result));
440
- if (v.output === 'text')
441
- ctx.io.stdout(remotesNote(swept, style));
442
362
  return swept.kind === 'nothing' ? EXIT_NOTHING_TO_SWEEP : EXIT.ok;
443
363
  }
444
364
  function runPlan(argv, ctx, status) {
445
365
  const name = `${NAME} plan`;
446
- const parsed = planCli().parse(argv, ctx.io);
447
- if (parsed.status === 'help')
448
- return EXIT.ok;
449
- if (parsed.status === 'error')
450
- return usageError(ctx, name, parsed.message);
366
+ const parsed = parseWith(planCli(), name, argv, ctx);
367
+ if (parsed.status === 'done')
368
+ return parsed.code;
451
369
  const v = parsed.values;
452
- const swept = sweep(name, v, parsed.positionals, ctx, status);
370
+ const swept = doSweep(name, v, parsed.positionals, ctx, status);
453
371
  if (swept.kind === 'exit')
454
372
  return swept.code;
455
- // stdout carries only the plan, so the summary goes to stderr either way.
373
+ // stdout carries only the plan, so the totals go to stderr either way.
456
374
  if (swept.kind === 'nothing') {
457
- ctx.io.stderr(renderSummary(swept.result) + '\n');
375
+ ctx.io.stderr(renderTotals(swept.sweep) + '\n');
458
376
  return EXIT_NOTHING_TO_SWEEP;
459
377
  }
460
- const plan = planOf(swept, new Date());
461
- const unreadable = reportUnreadable(ctx, name, swept.result);
378
+ const plan = planOf(swept.sweep);
379
+ reportNotes(ctx, name, swept.sweep);
380
+ const unreadable = reportUnreadable(ctx, name, swept.sweep);
462
381
  const style = styleFor(ctx, 'stderr');
463
- ctx.io.stderr(renderSummary(swept.result, style) + '\n' + remotesNote(swept, style));
382
+ ctx.io.stderr(renderTotals(swept.sweep, style) + '\n' + remoteNote(swept.sweep, style));
464
383
  if (v.out === '-') {
465
384
  ctx.io.stdout(`${JSON.stringify(plan, null, 2)}\n`);
466
385
  }
@@ -476,174 +395,153 @@ function receiptBeside(planPath, now) {
476
395
  const stem = basename(planPath).replace(/\.json$/i, '');
477
396
  return join(dirname(planPath), `${stem}.receipt-${fileStamp(now)}.json`);
478
397
  }
479
- const dropKey = (d) => JSON.stringify([d.repo, d.category, d.id]);
480
- /** Rows the run would delete or prune: one each, however many steps (an unlock, a remove) it takes. */
481
- function wouldRun(receipt) {
482
- return rowsActedOn(summarizeReceipt(receipt));
398
+ const dropKey = (d) => JSON.stringify([d.repo, d.row]);
399
+ /** The run's options that contradict each other, as a usage message, or null. */
400
+ function cleanConflict(v, targets) {
401
+ if (v.plan !== undefined) {
402
+ const fixed = [
403
+ targets.length > 0 ? 'targets' : null,
404
+ v.only !== undefined ? '--only' : null,
405
+ v.branch !== undefined ? '--branch' : null,
406
+ v.remote ? '--remote' : null,
407
+ v.offline ? '--offline' : null,
408
+ v.against !== undefined ? '--against' : null,
409
+ v.provider !== undefined ? '--provider' : null,
410
+ ].filter((f) => f !== null);
411
+ if (fixed.length > 0)
412
+ return `--plan already fixed what is swept; drop ${fixed.join(', ')}`;
413
+ }
414
+ if (v.plan === '-' && !v.yes && !v.dryRun) {
415
+ return '--plan - reads the plan from stdin, so nothing can ask; pass --yes or --dry-run';
416
+ }
417
+ if (v.yes && v.dryRun)
418
+ return '--yes and --dry-run contradict: one deletes without asking, the other deletes nothing';
419
+ if (v.receipt !== undefined && v.dryRun)
420
+ return '--receipt has no effect with --dry-run: nothing runs to record';
421
+ return null;
483
422
  }
484
- /** Sweep and plan in memory, or read `--plan`; or answer with the exit code. */
485
- function loadPlan(name, v, paths, ctx, deps, status, now) {
486
- const receiptPath = v.receipt === undefined ? null : resolve(ctx.cwd, v.receipt);
423
+ function loadPlan(name, v, targets, ctx, deps, status, now) {
424
+ const receiptPath = v.receipt === undefined ? undefined : resolve(ctx.cwd, v.receipt);
487
425
  if (v.plan === undefined) {
488
- const swept = sweep(name, v, paths, ctx, status);
426
+ const swept = doSweep(name, v, targets, ctx, status);
489
427
  if (swept.kind === 'exit')
490
428
  return swept.code;
491
429
  if (swept.kind === 'nothing') {
492
- ctx.io.stdout(v.output === 'json' ? renderJson(swept.result) : renderText(swept.result));
430
+ ctx.io.stdout(v.format === 'json' ? renderJson(swept.sweep) : renderText(swept.sweep));
493
431
  return EXIT_NOTHING_TO_SWEEP;
494
432
  }
495
- const items = new Map();
496
- for (const r of swept.result.repos)
497
- if (r.repo !== null)
498
- items.set(r.repo.root, r.items);
499
433
  return {
500
- plan: planOf(swept, now),
434
+ plan: planOf(swept.sweep, now),
501
435
  source: '(swept)',
502
436
  receiptPath,
503
- unreadable: reportUnreadable(ctx, name, swept.result),
504
- swept: items,
437
+ unreadable: reportUnreadable(ctx, name, swept.sweep),
438
+ sweep: swept.sweep,
505
439
  };
506
440
  }
507
441
  try {
508
442
  if (v.plan === '-') {
509
443
  const plan = parsePlan((deps.readStdin ?? readAllStdin)(), 'stdin');
510
- return { plan, source: '(stdin)', receiptPath, unreadable: false, swept: undefined };
444
+ return { plan, source: '(stdin)', receiptPath, unreadable: false, sweep: undefined };
511
445
  }
512
446
  const source = resolve(ctx.cwd, v.plan);
513
- const plan = readPlan(source);
514
447
  return {
515
- plan,
448
+ plan: readPlan(source),
516
449
  source,
517
450
  receiptPath: receiptPath ?? receiptBeside(source, now),
518
451
  unreadable: false,
519
- swept: undefined,
452
+ sweep: undefined,
520
453
  };
521
454
  }
522
455
  catch (err) {
523
- return failure(ctx, err);
524
- }
525
- }
526
- /** The run's flags that contradict each other, as a usage message, or null. */
527
- function applyConflict(v, paths) {
528
- if (v.plan !== undefined) {
529
- const sweepFlags = [
530
- paths.length > 0 ? 'paths' : null,
531
- v.only !== undefined ? '--only' : null,
532
- v.branch !== undefined ? '--branch' : null,
533
- v.measureRemote !== undefined ? '--measure-remote' : null,
534
- v.remoteUrl.length > 0 ? '--remote-url' : null,
535
- ].filter((f) => f !== null);
536
- if (sweepFlags.length > 0)
537
- return `--plan fixes what is swept; drop ${sweepFlags.join(', ')}`;
456
+ return refusal(ctx, name, err);
538
457
  }
539
- if (v.plan === '-' && !v.yes && !v.dryRun) {
540
- return '--plan - reads the plan from stdin, so it cannot ask; pass --yes or --dry-run';
541
- }
542
- return null;
543
458
  }
544
- function runApply(argv, ctx, deps, status) {
545
- const name = `${NAME} apply`;
546
- const parsed = applyCli().parse(argv, ctx.io);
547
- if (parsed.status === 'help')
548
- return EXIT.ok;
549
- if (parsed.status === 'error')
550
- return usageError(ctx, name, parsed.message);
459
+ function runClean(argv, ctx, deps, status) {
460
+ const name = `${NAME} clean`;
461
+ const parsed = parseWith(cleanCli(), name, argv, ctx);
462
+ if (parsed.status === 'done')
463
+ return parsed.code;
551
464
  const v = parsed.values;
552
- const paths = parsed.positionals;
553
- let rows;
554
- let keep;
555
- let grants;
465
+ const targets = parsed.positionals;
466
+ let approval;
556
467
  try {
557
- rows = v.row.map(parseRowRef);
558
- keep = v.keep.map(parseRowRef);
559
- grants = v.allow.map(parseGrant);
468
+ approval = { pick: v.pick, spare: v.spare, allow: v.allow.map(parseAllow) };
560
469
  }
561
470
  catch (err) {
562
- if (err instanceof ApplyArgumentError)
563
- return usageError(ctx, name, err.message);
564
- throw err;
471
+ return refusal(ctx, name, err);
565
472
  }
566
- const conflict = applyConflict(v, paths);
473
+ const conflict = cleanConflict(v, targets);
567
474
  if (conflict !== null)
568
475
  return usageError(ctx, name, conflict);
476
+ // Refuse what can be refused before sweeping; a saved plan's own options
477
+ // are checked when the approval is resolved against it.
478
+ if (v.plan === undefined) {
479
+ try {
480
+ checkApproval(sweptWith(targets, sweepOptions(v, ctx)), approval);
481
+ }
482
+ catch (err) {
483
+ return refusal(ctx, name, err);
484
+ }
485
+ }
569
486
  const now = new Date();
570
- const loaded = loadPlan(name, v, paths, ctx, deps, status, now);
487
+ const loaded = loadPlan(name, v, targets, ctx, deps, status, now);
571
488
  if (typeof loaded === 'number')
572
489
  return loaded;
573
490
  const { plan, source, receiptPath } = loaded;
574
- // A plan from `--remote-url` names a clone in the cache. Its deletes are
575
- // pushed from there, so the clone must still push only where it fetches from.
576
- const cacheDir = mirrorCache(ctx);
577
- const cache = (existsSync(cacheDir) ? realpathSync(cacheDir) : cacheDir) + sep;
578
- for (const repo of plan.repos) {
579
- if (!repo.root.startsWith(cache))
580
- continue;
581
- const problem = mirrorProblem(repo.root);
582
- if (problem !== null)
583
- return failure(ctx, new Error(`${problem}; refusing to apply`));
584
- }
585
491
  const ok = () => (loaded.unreadable ? EXIT.error : EXIT.ok);
586
- const repoRoot = v.repoRoot === undefined ? undefined : resolve(ctx.cwd, v.repoRoot);
587
492
  // On a terminal the run is shown live: every step first, then each one
588
- // checked off as it finishes, with a bar on the status line.
589
- const live = v.output === 'text' && ctx.terminal?.stdout === true;
493
+ // checked off as it finishes.
494
+ const live = v.format === 'text' && ctx.terminal?.stdout === true;
590
495
  const style = styleFor(ctx, 'stdout');
591
- const print = (receipt, shownPath, steps = true) => {
592
- ctx.io.stdout(v.output === 'json'
593
- ? `${JSON.stringify({ receiptPath: shownPath, summary: summarizeReceipt(receipt), receipt }, null, 2)}\n`
594
- : renderReceipt(receipt, shownPath, style, { steps }));
595
- };
596
- const planned = plan.repos.filter((r) => repoRoot === undefined || resolve(r.root) === resolve(repoRoot));
597
- if (repoRoot !== undefined && planned.length === 0) {
598
- return failure(ctx, new Error(`no plan repository at ${repoRoot}`));
599
- }
600
- if (rows.length === 0 && planned.every((r) => r.rows.length === 0)) {
601
- if (v.output === 'json')
602
- print(emptyReceipt(source, now, v.dryRun), null);
603
- else
604
- ctx.io.stdout('nothing to delete\n');
605
- return ok();
606
- }
607
- const base = {
608
- callerCwd: ctx.cwd,
609
- planPath: source,
610
- repoRoot,
611
- keep,
612
- // An implicit selection runs only safe-to-delete rows, in the preview and
613
- // in the run it confirms, whose rows are passed explicitly.
614
- safeOnly: rows.length === 0,
496
+ const shared = {
497
+ cwd: ctx.cwd,
615
498
  ...(v.progress
616
499
  ? {
617
- onSweepProgress: (p) => status.update(`re-checking ${describeSweep(p)}`),
618
- onStep: (i, total, step) => status.update(describeStep(i, total, step)),
500
+ onProgress: (p) => status.update(`re-checking ${describeSweep(p)}`),
619
501
  }
620
502
  : {}),
621
503
  };
622
504
  let preview;
623
505
  try {
624
- // A sweep that just ran is the preview's re-check; the run that deletes sweeps again.
625
- preview = applyPlan(plan, rows, grants, {
626
- ...base,
627
- dryRun: true,
628
- receiptPath: null,
629
- swept: loaded.swept,
506
+ // A sweep that just ran is the preview's re-check; the run sweeps again.
507
+ preview = check(plan, approval, {
508
+ ...shared,
509
+ sweep: loaded.sweep,
510
+ cacheDir: defaultCacheDir(ctx.env),
630
511
  });
631
512
  }
632
513
  catch (err) {
633
- if (err instanceof ApplyArgumentError)
634
- return usageError(ctx, name, err.message);
635
- return failure(ctx, err);
514
+ status.clear();
515
+ return refusal(ctx, name, err);
636
516
  }
637
- const count = wouldRun(preview);
638
- if (v.dryRun || count === 0) {
639
- if (live)
517
+ status.clear();
518
+ // The re-check's notes: from the sweep just made, or from sweeping a saved
519
+ // plan's repositories again. The run re-checks once more; only a note that
520
+ // is new by then is printed again.
521
+ const noted = new Set();
522
+ const notesOnce = (notes) => writeNotes(ctx, name, notes.filter(({ repo, note }) => {
523
+ const key = `${repo}\0${note}`;
524
+ if (noted.has(key))
525
+ return false;
526
+ noted.add(key);
527
+ return true;
528
+ }));
529
+ notesOnce(preview.notes);
530
+ const showPreview = () => {
531
+ if (v.format === 'json') {
532
+ ctx.io.stdout(`${JSON.stringify({ dryRun: v.dryRun, summary: summarizeRun(preview), steps: preview.steps, dropped: preview.dropped, unusedAllows: preview.unusedAllows, notes: preview.notes }, null, 2)}\n`);
533
+ }
534
+ else {
640
535
  ctx.io.stdout(renderPreview(preview, style));
641
- else
642
- print(preview, null);
536
+ }
537
+ };
538
+ const count = rowsActedOn(summarizeRun(preview));
539
+ if (v.dryRun || count === 0) {
540
+ showPreview();
643
541
  return ok();
644
542
  }
645
543
  if (!v.yes) {
646
- const declined = confirm(ctx, name, preview, count, v.output === 'json', deps);
544
+ const declined = confirm(ctx, name, preview, count, v.format === 'json', deps);
647
545
  if (declined !== null)
648
546
  return declined;
649
547
  }
@@ -652,11 +550,6 @@ function runApply(argv, ctx, deps, status) {
652
550
  }
653
551
  if (live)
654
552
  ctx.io.stdout('\n');
655
- // Only what the preview showed: rows it dropped stay dropped.
656
- // Pinned to their repositories: a branch name two repositories share stays two rows.
657
- // A kept row's tip check is not a selection; `keep` carries it again.
658
- const approved = preview.steps.flatMap((s) => s.row === null || s.action === 'verify-remote' ? [] : [{ ...s.row, repo: s.repo }]);
659
- const approvedGrants = grants.filter((g) => g.row === null || approved.some((r) => r.category === g.row.category && r.id === g.row.id));
660
553
  const size = deps.terminalSize ??
661
554
  (() => ({
662
555
  width: ctx.terminal?.columns || process.stdout.columns || 80,
@@ -665,11 +558,16 @@ function runApply(argv, ctx, deps, status) {
665
558
  let board;
666
559
  let receipt;
667
560
  try {
668
- receipt = applyPlan(plan, approved, approvedGrants, {
669
- ...base,
670
- dryRun: false,
561
+ receipt = run(preview, {
562
+ ...shared,
563
+ source,
671
564
  receiptPath,
672
- dropped: preview.dropped,
565
+ now: () => new Date(),
566
+ onNotes: (notes) => {
567
+ // The run's re-check draws its progress on the status line.
568
+ status.clear();
569
+ notesOnce(notes);
570
+ },
673
571
  ...(live
674
572
  ? {
675
573
  // The board carries the run's progress; the status line stays clear.
@@ -682,16 +580,20 @@ function runApply(argv, ctx, deps, status) {
682
580
  board?.draw(null);
683
581
  // Here rather than after the run: a receipt that then cannot be
684
582
  // saved must not take the step's own error with it.
685
- if (step.result === 'failed') {
583
+ if (step.result === 'failed')
686
584
  ctx.io.stdout(`\n${renderStepFailure(step, style)}\n`);
687
- }
688
585
  },
689
586
  }
690
- : {}),
587
+ : v.progress
588
+ ? {
589
+ onStep: (i, total, step) => status.update(describeStep(i, total, step)),
590
+ }
591
+ : {}),
691
592
  });
692
593
  }
693
594
  catch (err) {
694
- return failure(ctx, err);
595
+ status.clear();
596
+ return refusal(ctx, name, err);
695
597
  }
696
598
  status.clear();
697
599
  if (live) {
@@ -700,11 +602,16 @@ function runApply(argv, ctx, deps, status) {
700
602
  const seen = new Set(preview.dropped.map(dropKey));
701
603
  const late = receipt.dropped.filter((d) => !seen.has(dropKey(d)));
702
604
  if (late.length > 0)
703
- ctx.io.stdout(`\n${renderPreviewDrops(late, style)}`);
605
+ ctx.io.stdout(`\n${renderLateDrops(late, style)}`);
704
606
  ctx.io.stdout('\n');
705
607
  }
706
- print(receipt, receiptPath, !live);
707
- if (receiptPath === null) {
608
+ if (v.format === 'json') {
609
+ ctx.io.stdout(`${JSON.stringify({ receiptPath: receiptPath ?? null, summary: summarizeRun(receipt), receipt }, null, 2)}\n`);
610
+ }
611
+ else {
612
+ ctx.io.stdout(renderReceipt(receipt, receiptPath ?? null, style, { steps: !live }));
613
+ }
614
+ if (receiptPath === undefined) {
708
615
  ctx.io.stderr(`${name}: no receipt written; pass --receipt <file> to keep one\n`);
709
616
  }
710
617
  return receipt.status === 'failed' ? EXIT_STEP_FAILED : ok();
@@ -745,31 +652,6 @@ function cacheLine(m) {
745
652
  return (stripControls(`${sizeText(m.bytes).padStart(9)} ${m.url ?? '(no origin)'} ${m.dir}${note}`) +
746
653
  '\n');
747
654
  }
748
- function runCache(argv, ctx) {
749
- const name = `${NAME} cache`;
750
- const parsed = cacheCli().parse(argv, ctx.io);
751
- if (parsed.status === 'help')
752
- return EXIT.ok;
753
- if (parsed.status === 'error')
754
- return usageError(ctx, name, parsed.message);
755
- const v = parsed.values;
756
- const [verb = 'list', ...urls] = parsed.positionals;
757
- const dir = mirrorCache(ctx);
758
- const json = v.output === 'json';
759
- if (verb === 'list') {
760
- if (urls.length > 0 || v.all)
761
- return usageError(ctx, name, 'list takes no URLs and no --all');
762
- return cacheList(ctx, dir, json);
763
- }
764
- if (verb === 'remove') {
765
- const hasUrls = urls.length > 0;
766
- if (hasUrls === v.all) {
767
- return usageError(ctx, name, 'remove takes URLs or --all, not both and not neither');
768
- }
769
- return cacheRemove(ctx, name, dir, v.all ? null : urls, json);
770
- }
771
- return usageError(ctx, name, `unknown verb ${JSON.stringify(verb)}; use list or remove`);
772
- }
773
655
  function cacheList(ctx, dir, json) {
774
656
  const mirrors = listMirrors(dir);
775
657
  if (json) {
@@ -841,17 +723,28 @@ function cacheRemove(ctx, name, dir, urls, json) {
841
723
  }
842
724
  return missing.length > 0 || failed.length > 0 ? EXIT.error : EXIT.ok;
843
725
  }
844
- function emptyReceipt(plan, now, dryRun) {
845
- const at = now.toISOString();
846
- return {
847
- version: RECEIPT_VERSION,
848
- plan,
849
- status: dryRun ? 'dry-run' : 'complete',
850
- started_at: at,
851
- finished_at: at,
852
- dropped: [],
853
- steps: [],
854
- };
726
+ function runCache(argv, ctx) {
727
+ const name = `${NAME} cache`;
728
+ const parsed = parseWith(cacheCli(), name, argv, ctx);
729
+ if (parsed.status === 'done')
730
+ return parsed.code;
731
+ const v = parsed.values;
732
+ const [verb = 'list', ...urls] = parsed.positionals;
733
+ const dir = defaultCacheDir(ctx.env);
734
+ const json = v.format === 'json';
735
+ if (verb === 'list') {
736
+ if (urls.length > 0 || v.all)
737
+ return usageError(ctx, name, 'list takes no URLs and no --all');
738
+ return cacheList(ctx, dir, json);
739
+ }
740
+ if (verb === 'remove') {
741
+ const hasUrls = urls.length > 0;
742
+ if (hasUrls === v.all) {
743
+ return usageError(ctx, name, 'remove takes URLs or --all, not both and not neither');
744
+ }
745
+ return cacheRemove(ctx, name, dir, v.all ? null : urls, json);
746
+ }
747
+ return usageError(ctx, name, `unknown verb ${JSON.stringify(verb)}; use list or remove`);
855
748
  }
856
749
  /** `main(argv, ctx)`: returns an exit code, never exits. */
857
750
  export function main(argv, ctx, deps = {}) {
@@ -873,17 +766,13 @@ export function main(argv, ctx, deps = {}) {
873
766
  const c = { ...ctx, io };
874
767
  const [sub, ...rest] = argv;
875
768
  try {
876
- if (sub === 'summary')
877
- return runSummary(rest, c, status);
878
769
  if (sub === 'plan')
879
770
  return runPlan(rest, c, status);
880
- if (sub === 'apply')
881
- return runApply(rest, c, deps, status);
771
+ if (sub === 'clean')
772
+ return runClean(rest, c, deps, status);
882
773
  if (sub === 'cache')
883
774
  return runCache(rest, c);
884
- if (sub === 'status')
885
- return runStatus(rest, c, status);
886
- return runStatus(argv, c, status);
775
+ return runReport(argv, c, status);
887
776
  }
888
777
  finally {
889
778
  status.clear();