@osdlc/repo-cleaner 0.17.2 → 0.18.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/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, } 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 } 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 checkout 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,145 @@ 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
- };
397
- }
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);
327
+ function runReport(argv, ctx, status) {
328
+ const parsed = parseWith(reportCli(), NAME, argv, ctx);
329
+ if (parsed.status === 'done')
330
+ return parsed.code;
404
331
  const v = parsed.values;
332
+ if (v.all && v.format !== 'text') {
333
+ return usageError(ctx, NAME, `--all has no effect with --format ${v.format}: it shows keep rows in the text report`);
334
+ }
405
335
  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);
336
+ const swept = doSweep(NAME, v, parsed.positionals, ctx, status);
408
337
  if (swept.kind === 'exit')
409
338
  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);
339
+ const s = swept.sweep;
340
+ if (v.format === 'json')
341
+ ctx.io.stdout(renderJson(s));
342
+ else if (v.format === 'summary')
343
+ ctx.io.stdout(renderSummary(s, style) + remoteNote(s, style));
344
+ else if (v.format === 'commands') {
345
+ const commands = renderCommands(s);
416
346
  if (commands.length > 0)
417
347
  ctx.io.stdout(commands.join('\n') + '\n');
418
348
  }
419
349
  else {
420
- ctx.io.stdout(render(swept.result));
421
- if (v.output === 'text')
422
- ctx.io.stdout(remotesNote(swept, style));
350
+ ctx.io.stdout(renderText(s, { all: v.all, style }) + remoteNote(s, style));
423
351
  }
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
352
  return swept.kind === 'nothing' ? EXIT_NOTHING_TO_SWEEP : EXIT.ok;
443
353
  }
444
354
  function runPlan(argv, ctx, status) {
445
355
  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);
356
+ const parsed = parseWith(planCli(), name, argv, ctx);
357
+ if (parsed.status === 'done')
358
+ return parsed.code;
451
359
  const v = parsed.values;
452
- const swept = sweep(name, v, parsed.positionals, ctx, status);
360
+ const swept = doSweep(name, v, parsed.positionals, ctx, status);
453
361
  if (swept.kind === 'exit')
454
362
  return swept.code;
455
- // stdout carries only the plan, so the summary goes to stderr either way.
363
+ // stdout carries only the plan, so the totals go to stderr either way.
456
364
  if (swept.kind === 'nothing') {
457
- ctx.io.stderr(renderSummary(swept.result) + '\n');
365
+ ctx.io.stderr(renderTotals(swept.sweep) + '\n');
458
366
  return EXIT_NOTHING_TO_SWEEP;
459
367
  }
460
- const plan = planOf(swept, new Date());
461
- const unreadable = reportUnreadable(ctx, name, swept.result);
368
+ const plan = planOf(swept.sweep);
369
+ const unreadable = reportUnreadable(ctx, name, swept.sweep);
462
370
  const style = styleFor(ctx, 'stderr');
463
- ctx.io.stderr(renderSummary(swept.result, style) + '\n' + remotesNote(swept, style));
371
+ ctx.io.stderr(renderTotals(swept.sweep, style) + '\n' + remoteNote(swept.sweep, style));
464
372
  if (v.out === '-') {
465
373
  ctx.io.stdout(`${JSON.stringify(plan, null, 2)}\n`);
466
374
  }
@@ -476,174 +384,141 @@ function receiptBeside(planPath, now) {
476
384
  const stem = basename(planPath).replace(/\.json$/i, '');
477
385
  return join(dirname(planPath), `${stem}.receipt-${fileStamp(now)}.json`);
478
386
  }
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));
387
+ const dropKey = (d) => JSON.stringify([d.repo, d.row]);
388
+ /** The run's options that contradict each other, as a usage message, or null. */
389
+ function cleanConflict(v, targets) {
390
+ if (v.plan !== undefined) {
391
+ const fixed = [
392
+ targets.length > 0 ? 'targets' : null,
393
+ v.only !== undefined ? '--only' : null,
394
+ v.branch !== undefined ? '--branch' : null,
395
+ v.remote ? '--remote' : null,
396
+ v.offline ? '--offline' : null,
397
+ v.against !== undefined ? '--against' : null,
398
+ v.provider !== undefined ? '--provider' : null,
399
+ ].filter((f) => f !== null);
400
+ if (fixed.length > 0)
401
+ return `--plan already fixed what is swept; drop ${fixed.join(', ')}`;
402
+ }
403
+ if (v.plan === '-' && !v.yes && !v.dryRun) {
404
+ return '--plan - reads the plan from stdin, so nothing can ask; pass --yes or --dry-run';
405
+ }
406
+ if (v.yes && v.dryRun)
407
+ return '--yes and --dry-run contradict: one deletes without asking, the other deletes nothing';
408
+ if (v.receipt !== undefined && v.dryRun)
409
+ return '--receipt has no effect with --dry-run: nothing runs to record';
410
+ return null;
483
411
  }
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);
412
+ function loadPlan(name, v, targets, ctx, deps, status, now) {
413
+ const receiptPath = v.receipt === undefined ? undefined : resolve(ctx.cwd, v.receipt);
487
414
  if (v.plan === undefined) {
488
- const swept = sweep(name, v, paths, ctx, status);
415
+ const swept = doSweep(name, v, targets, ctx, status);
489
416
  if (swept.kind === 'exit')
490
417
  return swept.code;
491
418
  if (swept.kind === 'nothing') {
492
- ctx.io.stdout(v.output === 'json' ? renderJson(swept.result) : renderText(swept.result));
419
+ ctx.io.stdout(v.format === 'json' ? renderJson(swept.sweep) : renderText(swept.sweep));
493
420
  return EXIT_NOTHING_TO_SWEEP;
494
421
  }
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
422
  return {
500
- plan: planOf(swept, now),
423
+ plan: planOf(swept.sweep, now),
501
424
  source: '(swept)',
502
425
  receiptPath,
503
- unreadable: reportUnreadable(ctx, name, swept.result),
504
- swept: items,
426
+ unreadable: reportUnreadable(ctx, name, swept.sweep),
427
+ sweep: swept.sweep,
505
428
  };
506
429
  }
507
430
  try {
508
431
  if (v.plan === '-') {
509
432
  const plan = parsePlan((deps.readStdin ?? readAllStdin)(), 'stdin');
510
- return { plan, source: '(stdin)', receiptPath, unreadable: false, swept: undefined };
433
+ return { plan, source: '(stdin)', receiptPath, unreadable: false, sweep: undefined };
511
434
  }
512
435
  const source = resolve(ctx.cwd, v.plan);
513
- const plan = readPlan(source);
514
436
  return {
515
- plan,
437
+ plan: readPlan(source),
516
438
  source,
517
439
  receiptPath: receiptPath ?? receiptBeside(source, now),
518
440
  unreadable: false,
519
- swept: undefined,
441
+ sweep: undefined,
520
442
  };
521
443
  }
522
444
  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(', ')}`;
445
+ return refusal(ctx, name, err);
538
446
  }
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
447
  }
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);
448
+ function runClean(argv, ctx, deps, status) {
449
+ const name = `${NAME} clean`;
450
+ const parsed = parseWith(cleanCli(), name, argv, ctx);
451
+ if (parsed.status === 'done')
452
+ return parsed.code;
551
453
  const v = parsed.values;
552
- const paths = parsed.positionals;
553
- let rows;
554
- let keep;
555
- let grants;
454
+ const targets = parsed.positionals;
455
+ let approval;
556
456
  try {
557
- rows = v.row.map(parseRowRef);
558
- keep = v.keep.map(parseRowRef);
559
- grants = v.allow.map(parseGrant);
457
+ approval = { pick: v.pick, spare: v.spare, allow: v.allow.map(parseAllow) };
560
458
  }
561
459
  catch (err) {
562
- if (err instanceof ApplyArgumentError)
563
- return usageError(ctx, name, err.message);
564
- throw err;
460
+ return refusal(ctx, name, err);
565
461
  }
566
- const conflict = applyConflict(v, paths);
462
+ const conflict = cleanConflict(v, targets);
567
463
  if (conflict !== null)
568
464
  return usageError(ctx, name, conflict);
465
+ // Refuse what can be refused before sweeping; a saved plan's own options
466
+ // are checked when the approval is resolved against it.
467
+ if (v.plan === undefined) {
468
+ try {
469
+ checkApproval(sweptWith(targets, sweepOptions(v, ctx)), approval);
470
+ }
471
+ catch (err) {
472
+ return refusal(ctx, name, err);
473
+ }
474
+ }
569
475
  const now = new Date();
570
- const loaded = loadPlan(name, v, paths, ctx, deps, status, now);
476
+ const loaded = loadPlan(name, v, targets, ctx, deps, status, now);
571
477
  if (typeof loaded === 'number')
572
478
  return loaded;
573
479
  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
480
  const ok = () => (loaded.unreadable ? EXIT.error : EXIT.ok);
586
- const repoRoot = v.repoRoot === undefined ? undefined : resolve(ctx.cwd, v.repoRoot);
587
481
  // 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;
482
+ // checked off as it finishes.
483
+ const live = v.format === 'text' && ctx.terminal?.stdout === true;
590
484
  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,
485
+ const shared = {
486
+ cwd: ctx.cwd,
615
487
  ...(v.progress
616
488
  ? {
617
- onSweepProgress: (p) => status.update(`re-checking ${describeSweep(p)}`),
618
- onStep: (i, total, step) => status.update(describeStep(i, total, step)),
489
+ onProgress: (p) => status.update(`re-checking ${describeSweep(p)}`),
619
490
  }
620
491
  : {}),
621
492
  };
622
493
  let preview;
623
494
  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,
495
+ // A sweep that just ran is the preview's re-check; the run sweeps again.
496
+ preview = check(plan, approval, {
497
+ ...shared,
498
+ sweep: loaded.sweep,
499
+ cacheDir: defaultCacheDir(ctx.env),
630
500
  });
631
501
  }
632
502
  catch (err) {
633
- if (err instanceof ApplyArgumentError)
634
- return usageError(ctx, name, err.message);
635
- return failure(ctx, err);
503
+ status.clear();
504
+ return refusal(ctx, name, err);
636
505
  }
637
- const count = wouldRun(preview);
638
- if (v.dryRun || count === 0) {
639
- if (live)
506
+ status.clear();
507
+ const showPreview = () => {
508
+ if (v.format === 'json') {
509
+ ctx.io.stdout(`${JSON.stringify({ dryRun: v.dryRun, summary: summarizeRun(preview), steps: preview.steps, dropped: preview.dropped, unusedAllows: preview.unusedAllows }, null, 2)}\n`);
510
+ }
511
+ else {
640
512
  ctx.io.stdout(renderPreview(preview, style));
641
- else
642
- print(preview, null);
513
+ }
514
+ };
515
+ const count = rowsActedOn(summarizeRun(preview));
516
+ if (v.dryRun || count === 0) {
517
+ showPreview();
643
518
  return ok();
644
519
  }
645
520
  if (!v.yes) {
646
- const declined = confirm(ctx, name, preview, count, v.output === 'json', deps);
521
+ const declined = confirm(ctx, name, preview, count, v.format === 'json', deps);
647
522
  if (declined !== null)
648
523
  return declined;
649
524
  }
@@ -652,11 +527,6 @@ function runApply(argv, ctx, deps, status) {
652
527
  }
653
528
  if (live)
654
529
  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
530
  const size = deps.terminalSize ??
661
531
  (() => ({
662
532
  width: ctx.terminal?.columns || process.stdout.columns || 80,
@@ -665,11 +535,11 @@ function runApply(argv, ctx, deps, status) {
665
535
  let board;
666
536
  let receipt;
667
537
  try {
668
- receipt = applyPlan(plan, approved, approvedGrants, {
669
- ...base,
670
- dryRun: false,
538
+ receipt = run(preview, {
539
+ ...shared,
540
+ source,
671
541
  receiptPath,
672
- dropped: preview.dropped,
542
+ now: () => new Date(),
673
543
  ...(live
674
544
  ? {
675
545
  // The board carries the run's progress; the status line stays clear.
@@ -682,16 +552,20 @@ function runApply(argv, ctx, deps, status) {
682
552
  board?.draw(null);
683
553
  // Here rather than after the run: a receipt that then cannot be
684
554
  // saved must not take the step's own error with it.
685
- if (step.result === 'failed') {
555
+ if (step.result === 'failed')
686
556
  ctx.io.stdout(`\n${renderStepFailure(step, style)}\n`);
687
- }
688
557
  },
689
558
  }
690
- : {}),
559
+ : v.progress
560
+ ? {
561
+ onStep: (i, total, step) => status.update(describeStep(i, total, step)),
562
+ }
563
+ : {}),
691
564
  });
692
565
  }
693
566
  catch (err) {
694
- return failure(ctx, err);
567
+ status.clear();
568
+ return refusal(ctx, name, err);
695
569
  }
696
570
  status.clear();
697
571
  if (live) {
@@ -700,11 +574,16 @@ function runApply(argv, ctx, deps, status) {
700
574
  const seen = new Set(preview.dropped.map(dropKey));
701
575
  const late = receipt.dropped.filter((d) => !seen.has(dropKey(d)));
702
576
  if (late.length > 0)
703
- ctx.io.stdout(`\n${renderPreviewDrops(late, style)}`);
577
+ ctx.io.stdout(`\n${renderLateDrops(late, style)}`);
704
578
  ctx.io.stdout('\n');
705
579
  }
706
- print(receipt, receiptPath, !live);
707
- if (receiptPath === null) {
580
+ if (v.format === 'json') {
581
+ ctx.io.stdout(`${JSON.stringify({ receiptPath: receiptPath ?? null, summary: summarizeRun(receipt), receipt }, null, 2)}\n`);
582
+ }
583
+ else {
584
+ ctx.io.stdout(renderReceipt(receipt, receiptPath ?? null, style, { steps: !live }));
585
+ }
586
+ if (receiptPath === undefined) {
708
587
  ctx.io.stderr(`${name}: no receipt written; pass --receipt <file> to keep one\n`);
709
588
  }
710
589
  return receipt.status === 'failed' ? EXIT_STEP_FAILED : ok();
@@ -745,31 +624,6 @@ function cacheLine(m) {
745
624
  return (stripControls(`${sizeText(m.bytes).padStart(9)} ${m.url ?? '(no origin)'} ${m.dir}${note}`) +
746
625
  '\n');
747
626
  }
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
627
  function cacheList(ctx, dir, json) {
774
628
  const mirrors = listMirrors(dir);
775
629
  if (json) {
@@ -841,17 +695,28 @@ function cacheRemove(ctx, name, dir, urls, json) {
841
695
  }
842
696
  return missing.length > 0 || failed.length > 0 ? EXIT.error : EXIT.ok;
843
697
  }
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
- };
698
+ function runCache(argv, ctx) {
699
+ const name = `${NAME} cache`;
700
+ const parsed = parseWith(cacheCli(), name, argv, ctx);
701
+ if (parsed.status === 'done')
702
+ return parsed.code;
703
+ const v = parsed.values;
704
+ const [verb = 'list', ...urls] = parsed.positionals;
705
+ const dir = defaultCacheDir(ctx.env);
706
+ const json = v.format === 'json';
707
+ if (verb === 'list') {
708
+ if (urls.length > 0 || v.all)
709
+ return usageError(ctx, name, 'list takes no URLs and no --all');
710
+ return cacheList(ctx, dir, json);
711
+ }
712
+ if (verb === 'remove') {
713
+ const hasUrls = urls.length > 0;
714
+ if (hasUrls === v.all) {
715
+ return usageError(ctx, name, 'remove takes URLs or --all, not both and not neither');
716
+ }
717
+ return cacheRemove(ctx, name, dir, v.all ? null : urls, json);
718
+ }
719
+ return usageError(ctx, name, `unknown verb ${JSON.stringify(verb)}; use list or remove`);
855
720
  }
856
721
  /** `main(argv, ctx)`: returns an exit code, never exits. */
857
722
  export function main(argv, ctx, deps = {}) {
@@ -873,17 +738,13 @@ export function main(argv, ctx, deps = {}) {
873
738
  const c = { ...ctx, io };
874
739
  const [sub, ...rest] = argv;
875
740
  try {
876
- if (sub === 'summary')
877
- return runSummary(rest, c, status);
878
741
  if (sub === 'plan')
879
742
  return runPlan(rest, c, status);
880
- if (sub === 'apply')
881
- return runApply(rest, c, deps, status);
743
+ if (sub === 'clean')
744
+ return runClean(rest, c, deps, status);
882
745
  if (sub === 'cache')
883
746
  return runCache(rest, c);
884
- if (sub === 'status')
885
- return runStatus(rest, c, status);
886
- return runStatus(argv, c, status);
747
+ return runReport(argv, c, status);
887
748
  }
888
749
  finally {
889
750
  status.clear();