@osdlc/repo-cleaner 0.9.2 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,203 +1,233 @@
1
1
  /**
2
2
  * `repo-cleaner` — what has been merged, and what can go away.
3
3
  *
4
- * Two commands. A bare invocation sweeps: it inventories, fetching each
5
- * remote first unless `--no-fetch`, and writes nothing but an optional plan.
6
- * `repo-cleaner apply` deletes the rows of a plan that were selected and
7
- * authorized. Scope is opt-in: the sweep covers the repository you are
8
- * standing in unless `--all` or a path says otherwise.
4
+ * Four verbs over one set of sweep filters:
5
+ *
6
+ * status (the default) inventory and classify; read-only but for the fetch
7
+ * summary the same sweep as counts: totals, merged, delete, review, keep
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
+ * `apply` works from a saved plan (`--plan`) or sweeps first. Either way it
12
+ * re-checks every row, prints the steps it would run and asks before deleting;
13
+ * `--yes` skips the question. Without a terminal and without `--yes` it
14
+ * refuses rather than deleting. Scope is opt-in: the sweep covers the
15
+ * repository you are standing in unless a path says otherwise.
9
16
  *
10
17
  * Exit codes:
11
- * 0 swept; or every selected row was applied or dropped with a reason
18
+ * 0 swept; or apply ran, was declined, or had nothing to run
12
19
  * 1 failed against the state it found
13
20
  * 2 cannot run as typed
14
21
  * 10 nothing to sweep: no entry named a repository
15
22
  * 11 apply stopped at a failed step; the receipt says which
16
23
  */
17
- import { resolve } from 'node:path';
18
- import { EXIT, TieredDirs, defineCli } from '@osdlc/cli-tool';
24
+ import { readSync } from 'node:fs';
25
+ import { basename, dirname, join, resolve } from 'node:path';
26
+ import { isatty } from 'node:tty';
27
+ import { EXIT, askYesNo, canPrompt, defineCli } from '@osdlc/cli-tool';
19
28
  import { Git } from '@osdlc/typed-git';
20
29
  import { resolveBranch } from "./branches.js";
21
30
  import { ApplyArgumentError, applyPlan, parseGrant, parseRowRef } from "./apply.js";
22
- import { CONFIG_ENV, TOOL, VENDOR_DIRS, clearConfigCache, configPath, configuredRepos, } from "./config.js";
23
- import { PLAN_VERSION, planRepo, readPlan, stampedPath, writePlan } from "./plan.js";
24
- import { proposedCommands, renderJson, renderReceipt, renderText } from "./render.js";
31
+ import { PLAN_VERSION, fileStamp, parsePlan, planRepo, readPlan, writePlan, } from "./plan.js";
32
+ import { RECEIPT_VERSION } from "./receipt.js";
33
+ import { proposedCommands, renderCountsJson, renderCountsText, renderJson, renderReceipt, renderSummary, renderText, rowsActedOn, summarizeReceipt, } from "./render.js";
34
+ import { NO_STATUS_LINE, describeStep, describeSweep, statusLine, } from "./status-line.js";
25
35
  import { sweepRepos } from "./sweep.js";
26
- import { ALL_SCOPES } from "./types.js";
27
36
  const EXIT_NOTHING_TO_SWEEP = 10;
28
37
  const EXIT_STEP_FAILED = 11;
29
- /** `<durable-home>/repo-cleaner/<kind>`, where plans and receipts go by default. */
30
- function stateDir(ctx, kind) {
31
- return resolve(TieredDirs.fromEnv(VENDOR_DIRS.vendor, VENDOR_DIRS.envVars, { env: ctx.env })
32
- .standalone(TOOL)
33
- .durable().path, kind);
34
- }
35
- /** Built per run: the help names the config path as the environment has it then. */
36
- const sweepCli = () => defineCli({
37
- name: 'repo-cleaner',
38
+ const NAME = 'repo-cleaner';
39
+ /** The `--only` names, and the sweep scope each selects. */
40
+ const ONLY = {
41
+ worktrees: 'worktrees',
42
+ branches: 'local-branches',
43
+ remotes: 'remote-branches',
44
+ };
45
+ /** The flags every verb that sweeps takes. */
46
+ const SWEEP_FLAGS = {
47
+ only: {
48
+ kind: 'list',
49
+ valueName: 'list',
50
+ help: `comma-separated: ${Object.keys(ONLY).join(', ')} (default: all)`,
51
+ },
52
+ branch: {
53
+ kind: 'string',
54
+ valueName: 'name|HEAD',
55
+ help: 'only this branch of this repository: its worktree, local and remote branch',
56
+ },
57
+ remote: {
58
+ kind: 'string',
59
+ valueName: 'name',
60
+ help: 'remote to measure against (default: origin)',
61
+ },
62
+ gh: { kind: 'boolean', negatable: true, help: 'classify on git evidence alone' },
63
+ fetch: {
64
+ kind: 'boolean',
65
+ negatable: true,
66
+ help: 'classify on the remote-tracking refs already on disk',
67
+ },
68
+ progress: {
69
+ kind: 'boolean',
70
+ negatable: true,
71
+ help: 'no status line on stderr (drawn only when stderr is a terminal)',
72
+ },
73
+ };
74
+ const PATHS = [
75
+ {
76
+ name: 'path',
77
+ variadic: true,
78
+ help: 'a checkout, or a directory containing checkouts (default: the one you are in)',
79
+ },
80
+ ];
81
+ const OUTPUT = {
82
+ kind: 'enum',
83
+ choices: ['text', 'json'],
84
+ valueName: 'form',
85
+ default: 'text',
86
+ help: 'report format',
87
+ };
88
+ const statusCli = () => defineCli({
89
+ name: NAME,
38
90
  summary: 'Inventory branches, worktrees and remote branches, and say which have been merged into ' +
39
- 'the default branch. Fetches each remote first; writes nothing but an optional plan.',
40
- positionals: [
41
- {
42
- name: 'path',
43
- variadic: true,
44
- help: 'a checkout, or a directory containing checkouts (default: the one you are in)',
45
- },
46
- ],
91
+ 'the default branch. Fetches each remote first; deletes nothing.',
92
+ positionals: PATHS,
47
93
  flags: {
48
- all: { kind: 'boolean', help: 'sweep the repositories in the config file' },
49
- config: { kind: 'string', valueName: 'path', help: 'config file to read' },
50
- scope: {
51
- kind: 'list',
52
- valueName: 'list',
53
- help: `comma-separated: ${ALL_SCOPES.join(', ')}`,
54
- },
55
- remote: {
56
- kind: 'string',
57
- valueName: 'name',
58
- help: 'remote to measure against (default: origin)',
59
- },
60
- gh: { kind: 'boolean', negatable: true, help: 'classify on git evidence alone' },
61
- fetch: {
62
- kind: 'boolean',
63
- negatable: true,
64
- help: 'classify on the remote-tracking refs already on disk',
65
- },
94
+ ...SWEEP_FLAGS,
66
95
  keep: { kind: 'boolean', help: 'include safe-to-keep rows in the report' },
67
96
  commands: { kind: 'boolean', help: 'print the proposed commands only' },
68
- branch: {
69
- kind: 'string',
70
- valueName: 'name|HEAD',
71
- help: 'sweep only this branch of this repository: its worktree, local and remote branch',
72
- },
73
- writePlan: { kind: 'boolean', help: 'write the deletable rows as a plan for `apply`' },
74
- planOut: {
75
- kind: 'string',
76
- valueName: 'path',
77
- help: 'write the plan here; implies --write-plan',
78
- },
79
- output: {
80
- kind: 'enum',
81
- choices: ['text', 'json'],
82
- valueName: 'form',
83
- default: 'text',
84
- help: 'report format',
85
- },
97
+ output: OUTPUT,
86
98
  },
87
99
  epilogue: `commands:
88
- repo-cleaner apply --help delete the approved rows of a plan
89
- repo-cleaner dev run repo-cleaner from a development checkout
100
+ ${NAME} [status] this report (the default)
101
+ ${NAME} summary counts per repository: total, merged, delete, review, keep
102
+ ${NAME} plan write the deletable rows as a plan
103
+ ${NAME} apply re-check, show the steps, ask, delete
104
+ ${NAME} dev run ${NAME} from a development checkout
90
105
 
91
- A checkout directory named "apply" or "dev" is swept as ./apply or ./dev.
92
-
93
- config: ${configPath()}
94
- plans: <durable home>/repo-cleaner/plans/ unless --plan-out`,
106
+ Each command takes --help. A checkout directory named like a command is
107
+ swept as ./<name>.`,
108
+ });
109
+ const summaryCli = () => defineCli({
110
+ name: `${NAME} summary`,
111
+ summary: 'Count local branches, worktrees and remote branches per repository: how many there are, ' +
112
+ 'how many are merged into the default branch, and how many would be deleted, reviewed or kept.',
113
+ positionals: PATHS,
114
+ flags: { ...SWEEP_FLAGS, output: OUTPUT },
115
+ epilogue: 'merged: the work is on the default branch. Rows kept for structure (the default ' +
116
+ 'branch, the primary worktree) are counted in total but not judged merged.',
117
+ });
118
+ const planCli = () => defineCli({
119
+ name: `${NAME} plan`,
120
+ summary: 'Sweep, and write the deletable rows as a plan for `apply`: each row with the facts its ' +
121
+ 'verdict rests on, so `apply` can tell what moved since.',
122
+ positionals: PATHS,
123
+ flags: {
124
+ ...SWEEP_FLAGS,
125
+ out: {
126
+ kind: 'string',
127
+ short: 'o',
128
+ valueName: 'file',
129
+ default: '-',
130
+ help: 'write the plan here; - is stdout (the default)',
131
+ },
132
+ },
133
+ epilogue: 'The one-line summary goes to stderr, so stdout carries only the plan.',
95
134
  });
96
135
  const applyCli = () => defineCli({
97
- name: 'repo-cleaner apply',
98
- summary: 'Apply approved rows of a plan: re-check them, delete compare-and-swap, verify each step, ' +
99
- 'and write a receipt.',
136
+ name: `${NAME} apply`,
137
+ summary: 'Delete what a sweep or a saved plan found deletable: re-check each row, show the steps, ' +
138
+ 'ask, then delete compare-and-swap, verifying each step.',
139
+ positionals: PATHS,
100
140
  flags: {
141
+ ...SWEEP_FLAGS,
101
142
  plan: {
102
143
  kind: 'string',
103
- valueName: 'path',
104
- required: true,
105
- help: 'plan file from `repo-cleaner --write-plan`',
144
+ valueName: 'file',
145
+ help: 'apply this plan (- reads stdin) instead of sweeping',
106
146
  },
107
147
  row: {
108
148
  kind: 'list',
109
149
  valueName: 'category:id',
110
150
  default: [],
111
151
  commaSeparated: false,
112
- help: 'row to apply, e.g. local_branch:feat/x; repeatable',
152
+ help: 'apply only this row, e.g. local_branch:feat/x; repeatable (default: every row)',
113
153
  },
114
- authorize: {
154
+ allow: {
115
155
  kind: 'list',
116
- valueName: 'name[=category:id]',
156
+ valueName: 'risk[=category:id]',
117
157
  default: [],
118
158
  commaSeparated: false,
119
- help: 'authorization for every row, or for one row; repeatable',
159
+ help: 'accept a risk for every row, or for one row; repeatable',
120
160
  },
121
161
  repoRoot: {
122
162
  kind: 'string',
123
163
  valueName: 'path',
124
- help: 'apply only the plan repository rooted here',
164
+ help: 'with a plan of several repositories: apply only the one rooted here',
125
165
  },
126
- receiptDir: { kind: 'string', valueName: 'dir', help: 'write the receipt here' },
127
- dryRun: { kind: 'boolean', help: 're-check and print the steps; delete nothing' },
128
- output: {
129
- kind: 'enum',
130
- choices: ['text', 'json'],
131
- valueName: 'form',
132
- default: 'text',
133
- help: 'report format',
166
+ receipt: {
167
+ kind: 'string',
168
+ valueName: 'file',
169
+ help: 'write the receipt here (default: beside a --plan file; none otherwise)',
134
170
  },
171
+ yes: { kind: 'boolean', short: 'y', help: 'delete without asking' },
172
+ dryRun: { kind: 'boolean', help: 're-check and print the steps; delete nothing' },
173
+ output: OUTPUT,
135
174
  },
136
- epilogue: 'receipts: <durable home>/repo-cleaner/receipts/ unless --receipt-dir',
175
+ epilogue: `risks: abandon-unmerged, discard-changes, confirm-open-pr
176
+ A row that carries a risk you did not --allow is dropped, never deleted.`,
137
177
  });
178
+ const terminalConfirm = (question) => (canPrompt() ? askYesNo(question) : null);
179
+ const PAUSE = new Int32Array(new SharedArrayBuffer(4));
180
+ /**
181
+ * All of stdin, synchronously. A pipe can be non-blocking and still empty
182
+ * when the writer upstream has not produced anything yet; `readFileSync(0)`
183
+ * fails with EAGAIN there, so this waits and retries until end of input.
184
+ */
185
+ function readAllStdin() {
186
+ const chunks = [];
187
+ const buf = Buffer.alloc(65536);
188
+ for (;;) {
189
+ let n;
190
+ try {
191
+ n = readSync(0, buf, 0, buf.length, null);
192
+ }
193
+ catch (e) {
194
+ if (e.code !== 'EAGAIN')
195
+ throw e;
196
+ Atomics.wait(PAUSE, 0, 0, 50);
197
+ continue;
198
+ }
199
+ if (n === 0)
200
+ return Buffer.concat(chunks).toString('utf8');
201
+ chunks.push(Buffer.from(buf.subarray(0, n)));
202
+ }
203
+ }
138
204
  function usageError(ctx, name, message) {
139
205
  ctx.io.stderr(`${name}: ${message}\n`);
140
206
  return EXIT.usage;
141
207
  }
142
208
  function failure(ctx, err) {
143
- ctx.io.stderr(`repo-cleaner: ${err instanceof Error ? err.message : String(err)}\n`);
209
+ ctx.io.stderr(`${NAME}: ${err instanceof Error ? err.message : String(err)}\n`);
144
210
  return EXIT.error;
145
211
  }
146
- /** Runs `fn` with the shared loader pointed at `path`, then restores it. */
147
- function withConfigFile(path, fn) {
148
- if (path === undefined)
149
- return fn();
150
- const prior = process.env[CONFIG_ENV];
151
- process.env[CONFIG_ENV] = path;
152
- clearConfigCache();
153
- try {
154
- return fn();
155
- }
156
- finally {
157
- if (prior === undefined)
158
- delete process.env[CONFIG_ENV];
159
- else
160
- process.env[CONFIG_ENV] = prior;
161
- clearConfigCache();
162
- }
163
- }
164
- function runSweep(argv, ctx) {
165
- const parsed = sweepCli().parse(argv, ctx.io);
166
- if (parsed.status === 'help')
167
- return EXIT.ok;
168
- if (parsed.status === 'error')
169
- return usageError(ctx, 'repo-cleaner', parsed.message);
170
- const v = parsed.values;
171
- const paths = parsed.positionals;
172
- const scope = v.scope?.filter((s) => s !== '');
173
- const unknown = (scope ?? []).filter((s) => !ALL_SCOPES.includes(s));
212
+ /** Validate the sweep flags and run the sweep, or answer with the exit code. */
213
+ function sweep(name, v, paths, ctx, status) {
214
+ const only = v.only?.filter((s) => s !== '');
215
+ const unknown = (only ?? []).filter((s) => !(s in ONLY));
174
216
  if (unknown.length > 0) {
175
- return usageError(ctx, 'repo-cleaner', `unknown scope(s): ${unknown.join(', ')}`);
217
+ const message = `unknown --only value(s): ${unknown.join(', ')}`;
218
+ return { kind: 'exit', code: usageError(ctx, name, message) };
176
219
  }
177
- if (v.branch !== undefined && (v.all || paths.length > 0)) {
178
- return usageError(ctx, 'repo-cleaner', '--branch sweeps this repository only; drop --all and paths');
179
- }
180
- if (v.config !== undefined && !v.all) {
181
- return usageError(ctx, 'repo-cleaner', '--config is read only with --all');
220
+ if (v.branch !== undefined && paths.length > 0) {
221
+ const message = '--branch sweeps this repository only; drop the paths';
222
+ return { kind: 'exit', code: usageError(ctx, name, message) };
182
223
  }
183
224
  let branch;
184
225
  try {
185
226
  branch = v.branch === undefined ? undefined : resolveBranch(v.branch, new Git(ctx.cwd));
186
227
  }
187
228
  catch (err) {
188
- return failure(ctx, err);
189
- }
190
- const entries = [...paths];
191
- if (v.all) {
192
- try {
193
- entries.push(...withConfigFile(v.config, () => configuredRepos(undefined, ctx.cwd)));
194
- }
195
- catch (err) {
196
- return failure(ctx, err);
197
- }
229
+ return { kind: 'exit', code: failure(ctx, err) };
198
230
  }
199
- if (entries.length === 0)
200
- entries.push(ctx.cwd);
201
231
  const options = {
202
232
  baseDir: ctx.cwd,
203
233
  cwd: ctx.cwd,
@@ -206,94 +236,352 @@ function runSweep(argv, ctx) {
206
236
  branch,
207
237
  // Mid-walk, while the wait is happening — the summary comes too late to
208
238
  // explain why the tool appeared to hang.
209
- onWarn: (message) => ctx.io.stderr(`repo-cleaner: ${message}\n`),
210
- ...(scope === undefined ? {} : { scope }),
239
+ onWarn: (message) => ctx.io.stderr(`${NAME}: ${message}\n`),
240
+ ...(v.progress ? { onProgress: (p) => status.update(describeSweep(p)) } : {}),
241
+ ...(only === undefined ? {} : { scope: only.map((s) => ONLY[s]) }),
211
242
  ...(v.remote === undefined ? {} : { remote: v.remote }),
212
243
  };
213
- const result = sweepRepos(entries, options);
244
+ if (v.progress)
245
+ status.update(`${NAME}: finding repositories`);
246
+ const result = sweepRepos(paths.length > 0 ? paths : [ctx.cwd], options);
247
+ status.clear();
214
248
  if (branch !== undefined && result.repos.length > 1) {
215
- return failure(ctx, new Error(`--branch sweeps one repository; ${result.repos.length} found under ${ctx.cwd}`));
249
+ const err = new Error(`--branch sweeps one repository; ${result.repos.length} found under ${ctx.cwd}`);
250
+ return { kind: 'exit', code: failure(ctx, err) };
216
251
  }
217
252
  const refused = branch === undefined ? null : (result.repos[0]?.error ?? null);
218
253
  if (refused !== null)
219
- return failure(ctx, new Error(refused));
220
- if (result.repos.length === 0) {
221
- ctx.io.stdout(v.output === 'json' ? renderJson(result) : renderText(result));
254
+ return { kind: 'exit', code: failure(ctx, new Error(refused)) };
255
+ if (result.repos.length === 0)
256
+ return { kind: 'nothing', result };
257
+ return { kind: 'ok', result, branch, values: v };
258
+ }
259
+ /**
260
+ * Name each repository the sweep could not read, on stderr. True when there
261
+ * was one: a verb that acts on the sweep must not pass an unread repository
262
+ * off as an empty one.
263
+ */
264
+ function reportUnreadable(ctx, name, result) {
265
+ const failed = result.repos.filter((r) => r.error !== null);
266
+ for (const r of failed)
267
+ ctx.io.stderr(`${name}: ${r.repo?.root ?? r.path}: ${r.error}\n`);
268
+ return failed.length > 0;
269
+ }
270
+ function planOf(swept, now) {
271
+ const opts = {
272
+ remote: swept.values.remote ?? null,
273
+ noGh: !swept.values.gh,
274
+ branch: swept.branch ?? null,
275
+ };
276
+ return {
277
+ version: PLAN_VERSION,
278
+ created_at: now.toISOString(),
279
+ repos: swept.result.repos.flatMap((s) => s.repo === null ? [] : [planRepo(s.repo.root, opts, s.items)]),
280
+ };
281
+ }
282
+ function runStatus(argv, ctx, status) {
283
+ const parsed = statusCli().parse(argv, ctx.io);
284
+ if (parsed.status === 'help')
285
+ return EXIT.ok;
286
+ if (parsed.status === 'error')
287
+ return usageError(ctx, NAME, parsed.message);
288
+ const v = parsed.values;
289
+ const render = (r) => v.output === 'json' ? renderJson(r) : renderText(r, { keep: v.keep });
290
+ const swept = sweep(NAME, v, parsed.positionals, ctx, status);
291
+ if (swept.kind === 'exit')
292
+ return swept.code;
293
+ if (swept.kind === 'nothing') {
294
+ ctx.io.stdout(render(swept.result));
222
295
  return EXIT_NOTHING_TO_SWEEP;
223
296
  }
224
- let planPath = null;
225
- if (v.writePlan || v.planOut !== undefined) {
226
- const now = new Date();
227
- planPath =
228
- v.planOut !== undefined
229
- ? resolve(ctx.cwd, v.planOut)
230
- : stampedPath(stateDir(ctx, 'plans'), now);
231
- const planOpts = { remote: v.remote ?? null, noGh: !v.gh, branch: branch ?? null };
232
- writePlan(planPath, {
233
- version: PLAN_VERSION,
234
- created_at: now.toISOString(),
235
- repos: result.repos.flatMap((sweep) => sweep.repo === null ? [] : [planRepo(sweep.repo.root, planOpts, sweep.items)]),
236
- });
237
- }
238
297
  if (v.commands) {
239
- const commands = proposedCommands(result);
298
+ const commands = proposedCommands(swept.result);
240
299
  if (commands.length > 0)
241
300
  ctx.io.stdout(commands.join('\n') + '\n');
242
301
  }
243
- else if (v.output === 'json') {
244
- ctx.io.stdout(renderJson(result, planPath === null ? {} : { plan_path: planPath }));
245
- }
246
302
  else {
247
- ctx.io.stdout(renderText(result, { keep: v.keep }));
303
+ ctx.io.stdout(render(swept.result));
248
304
  }
249
- if (planPath !== null && v.output !== 'json')
250
- ctx.io.stderr(`plan: ${planPath}\n`);
251
305
  return EXIT.ok;
252
306
  }
253
- function runApply(argv, ctx) {
254
- const name = 'repo-cleaner apply';
307
+ function runSummary(argv, ctx, status) {
308
+ const name = `${NAME} summary`;
309
+ const parsed = summaryCli().parse(argv, ctx.io);
310
+ if (parsed.status === 'help')
311
+ return EXIT.ok;
312
+ if (parsed.status === 'error')
313
+ return usageError(ctx, name, parsed.message);
314
+ const v = parsed.values;
315
+ const render = (r) => v.output === 'json' ? renderCountsJson(r) : renderCountsText(r);
316
+ const swept = sweep(name, v, parsed.positionals, ctx, status);
317
+ if (swept.kind === 'exit')
318
+ return swept.code;
319
+ ctx.io.stdout(render(swept.result));
320
+ return swept.kind === 'nothing' ? EXIT_NOTHING_TO_SWEEP : EXIT.ok;
321
+ }
322
+ function runPlan(argv, ctx, status) {
323
+ const name = `${NAME} plan`;
324
+ const parsed = planCli().parse(argv, ctx.io);
325
+ if (parsed.status === 'help')
326
+ return EXIT.ok;
327
+ if (parsed.status === 'error')
328
+ return usageError(ctx, name, parsed.message);
329
+ const v = parsed.values;
330
+ const swept = sweep(name, v, parsed.positionals, ctx, status);
331
+ if (swept.kind === 'exit')
332
+ return swept.code;
333
+ // stdout carries only the plan, so the summary goes to stderr either way.
334
+ if (swept.kind === 'nothing') {
335
+ ctx.io.stderr(renderSummary(swept.result) + '\n');
336
+ return EXIT_NOTHING_TO_SWEEP;
337
+ }
338
+ const plan = planOf(swept, new Date());
339
+ const unreadable = reportUnreadable(ctx, name, swept.result);
340
+ ctx.io.stderr(renderSummary(swept.result) + '\n');
341
+ if (v.out === '-') {
342
+ ctx.io.stdout(`${JSON.stringify(plan, null, 2)}\n`);
343
+ }
344
+ else {
345
+ const path = resolve(ctx.cwd, v.out);
346
+ writePlan(path, plan);
347
+ ctx.io.stderr(`plan: ${path}\n`);
348
+ }
349
+ return unreadable ? EXIT.error : EXIT.ok;
350
+ }
351
+ /** `<dir>/<name>.receipt-<stamp>.json` for plan `<dir>/<name>.json`. */
352
+ function receiptBeside(planPath, now) {
353
+ const stem = basename(planPath).replace(/\.json$/i, '');
354
+ return join(dirname(planPath), `${stem}.receipt-${fileStamp(now)}.json`);
355
+ }
356
+ /** Rows the run would delete or prune: one each, however many steps (an unlock, a remove) it takes. */
357
+ function wouldRun(receipt) {
358
+ return rowsActedOn(summarizeReceipt(receipt));
359
+ }
360
+ /** Sweep and plan in memory, or read `--plan`; or answer with the exit code. */
361
+ function loadPlan(name, v, paths, ctx, deps, status, now) {
362
+ const receiptPath = v.receipt === undefined ? null : resolve(ctx.cwd, v.receipt);
363
+ if (v.plan === undefined) {
364
+ const swept = sweep(name, v, paths, ctx, status);
365
+ if (swept.kind === 'exit')
366
+ return swept.code;
367
+ if (swept.kind === 'nothing') {
368
+ ctx.io.stdout(v.output === 'json' ? renderJson(swept.result) : renderText(swept.result));
369
+ return EXIT_NOTHING_TO_SWEEP;
370
+ }
371
+ const items = new Map();
372
+ for (const r of swept.result.repos)
373
+ if (r.repo !== null)
374
+ items.set(r.repo.root, r.items);
375
+ return {
376
+ plan: planOf(swept, now),
377
+ source: '(swept)',
378
+ receiptPath,
379
+ unreadable: reportUnreadable(ctx, name, swept.result),
380
+ swept: items,
381
+ };
382
+ }
383
+ try {
384
+ if (v.plan === '-') {
385
+ const plan = parsePlan((deps.readStdin ?? readAllStdin)(), 'stdin');
386
+ return { plan, source: '(stdin)', receiptPath, unreadable: false, swept: undefined };
387
+ }
388
+ const source = resolve(ctx.cwd, v.plan);
389
+ const plan = readPlan(source);
390
+ return {
391
+ plan,
392
+ source,
393
+ receiptPath: receiptPath ?? receiptBeside(source, now),
394
+ unreadable: false,
395
+ swept: undefined,
396
+ };
397
+ }
398
+ catch (err) {
399
+ return failure(ctx, err);
400
+ }
401
+ }
402
+ /** The run's flags that contradict each other, as a usage message, or null. */
403
+ function applyConflict(v, paths) {
404
+ if (v.plan !== undefined) {
405
+ const sweepFlags = [
406
+ paths.length > 0 ? 'paths' : null,
407
+ v.only !== undefined ? '--only' : null,
408
+ v.branch !== undefined ? '--branch' : null,
409
+ v.remote !== undefined ? '--remote' : null,
410
+ ].filter((f) => f !== null);
411
+ if (sweepFlags.length > 0)
412
+ return `--plan fixes what is swept; drop ${sweepFlags.join(', ')}`;
413
+ }
414
+ if (v.plan === '-' && !v.yes && !v.dryRun) {
415
+ return '--plan - reads the plan from stdin, so it cannot ask; pass --yes or --dry-run';
416
+ }
417
+ return null;
418
+ }
419
+ function runApply(argv, ctx, deps, status) {
420
+ const name = `${NAME} apply`;
255
421
  const parsed = applyCli().parse(argv, ctx.io);
256
422
  if (parsed.status === 'help')
257
423
  return EXIT.ok;
258
424
  if (parsed.status === 'error')
259
425
  return usageError(ctx, name, parsed.message);
260
426
  const v = parsed.values;
427
+ const paths = parsed.positionals;
261
428
  let rows;
262
429
  let grants;
263
430
  try {
264
431
  rows = v.row.map(parseRowRef);
265
- grants = v.authorize.map(parseGrant);
432
+ grants = v.allow.map(parseGrant);
266
433
  }
267
434
  catch (err) {
268
435
  if (err instanceof ApplyArgumentError)
269
436
  return usageError(ctx, name, err.message);
270
437
  throw err;
271
438
  }
272
- const planPath = resolve(ctx.cwd, v.plan);
439
+ const conflict = applyConflict(v, paths);
440
+ if (conflict !== null)
441
+ return usageError(ctx, name, conflict);
442
+ const now = new Date();
443
+ const loaded = loadPlan(name, v, paths, ctx, deps, status, now);
444
+ if (typeof loaded === 'number')
445
+ return loaded;
446
+ const { plan, source, receiptPath } = loaded;
447
+ const ok = () => (loaded.unreadable ? EXIT.error : EXIT.ok);
273
448
  const repoRoot = v.repoRoot === undefined ? undefined : resolve(ctx.cwd, v.repoRoot);
274
- const receiptDir = v.receiptDir === undefined ? stateDir(ctx, 'receipts') : resolve(ctx.cwd, v.receiptDir);
275
- const receiptPath = stampedPath(receiptDir, new Date());
449
+ const print = (receipt, shownPath) => {
450
+ ctx.io.stdout(v.output === 'json'
451
+ ? `${JSON.stringify({ receiptPath: shownPath, summary: summarizeReceipt(receipt), receipt }, null, 2)}\n`
452
+ : renderReceipt(receipt, shownPath));
453
+ };
454
+ const planned = plan.repos.filter((r) => repoRoot === undefined || resolve(r.root) === resolve(repoRoot));
455
+ if (repoRoot !== undefined && planned.length === 0) {
456
+ return failure(ctx, new Error(`no plan repository at ${repoRoot}`));
457
+ }
458
+ if (rows.length === 0 && planned.every((r) => r.rows.length === 0)) {
459
+ if (v.output === 'json')
460
+ print(emptyReceipt(source, now, v.dryRun), null);
461
+ else
462
+ ctx.io.stdout('nothing to delete\n');
463
+ return ok();
464
+ }
465
+ const base = {
466
+ callerCwd: ctx.cwd,
467
+ planPath: source,
468
+ repoRoot,
469
+ // An implicit selection runs only safe-to-delete rows, in the preview and
470
+ // in the run it confirms, whose rows are passed explicitly.
471
+ safeOnly: rows.length === 0,
472
+ ...(v.progress
473
+ ? {
474
+ onSweepProgress: (p) => status.update(`re-checking ${describeSweep(p)}`),
475
+ onStep: (i, total, step) => status.update(describeStep(i, total, step)),
476
+ }
477
+ : {}),
478
+ };
479
+ let preview;
480
+ try {
481
+ // A sweep that just ran is the preview's re-check; the run that deletes sweeps again.
482
+ preview = applyPlan(plan, rows, grants, {
483
+ ...base,
484
+ dryRun: true,
485
+ receiptPath: null,
486
+ swept: loaded.swept,
487
+ });
488
+ }
489
+ catch (err) {
490
+ return failure(ctx, err);
491
+ }
492
+ const count = wouldRun(preview);
493
+ if (v.dryRun || count === 0) {
494
+ print(preview, null);
495
+ return ok();
496
+ }
497
+ if (!v.yes) {
498
+ const declined = confirm(ctx, name, preview, count, v.output === 'json', deps);
499
+ if (declined !== null)
500
+ return declined;
501
+ }
502
+ // Only what the preview showed: rows it dropped stay dropped.
503
+ // Pinned to their repositories: a branch name two repositories share stays two rows.
504
+ const approved = preview.steps.flatMap((s) => s.row === null ? [] : [{ ...s.row, repo: s.repo }]);
505
+ const approvedGrants = grants.filter((g) => g.row === null || approved.some((r) => r.category === g.row.category && r.id === g.row.id));
276
506
  let receipt;
277
507
  try {
278
- receipt = applyPlan(readPlan(planPath), rows, grants, {
279
- callerCwd: ctx.cwd,
280
- dryRun: v.dryRun,
508
+ receipt = applyPlan(plan, approved, approvedGrants, {
509
+ ...base,
510
+ dryRun: false,
281
511
  receiptPath,
282
- planPath,
283
- repoRoot,
512
+ dropped: preview.dropped,
284
513
  });
285
514
  }
286
515
  catch (err) {
287
516
  return failure(ctx, err);
288
517
  }
289
- const shownPath = v.dryRun ? null : receiptPath;
290
- ctx.io.stdout(v.output === 'json'
291
- ? `${JSON.stringify({ receiptPath: shownPath, receipt }, null, 2)}\n`
292
- : renderReceipt(receipt, shownPath));
293
- return receipt.status === 'failed' ? EXIT_STEP_FAILED : EXIT.ok;
518
+ print(receipt, receiptPath);
519
+ if (receiptPath === null) {
520
+ ctx.io.stderr(`${name}: no receipt written; pass --receipt <file> to keep one\n`);
521
+ }
522
+ return receipt.status === 'failed' ? EXIT_STEP_FAILED : ok();
523
+ }
524
+ /**
525
+ * Show the preview and ask. Null to go ahead; otherwise the exit code: 0 for
526
+ * a no, 2 when there is no one to ask.
527
+ */
528
+ function confirm(ctx, name, preview, count, json, deps) {
529
+ // The preview is for the person answering, so it goes where the question does.
530
+ const show = json ? ctx.io.stderr : ctx.io.stdout;
531
+ show(renderReceipt(preview, null));
532
+ const answer = (deps.confirm ?? terminalConfirm)(`Delete ${count} item${count === 1 ? '' : 's'}?`);
533
+ if (answer === null) {
534
+ return usageError(ctx, name, 'refusing to delete without a terminal to ask; pass --yes');
535
+ }
536
+ if (!answer) {
537
+ ctx.io.stderr('nothing deleted\n');
538
+ return EXIT.ok;
539
+ }
540
+ return null;
541
+ }
542
+ function emptyReceipt(plan, now, dryRun) {
543
+ const at = now.toISOString();
544
+ return {
545
+ version: RECEIPT_VERSION,
546
+ plan,
547
+ status: dryRun ? 'dry-run' : 'complete',
548
+ started_at: at,
549
+ finished_at: at,
550
+ dropped: [],
551
+ steps: [],
552
+ };
294
553
  }
295
554
  /** `main(argv, ctx)`: returns an exit code, never exits. */
296
- export function main(argv, ctx) {
555
+ export function main(argv, ctx, deps = {}) {
556
+ const terminal = deps.stderrIsTerminal ?? isatty(2);
557
+ const status = terminal
558
+ ? statusLine(ctx.io.stderr, () => process.stderr.columns || 80)
559
+ : NO_STATUS_LINE;
560
+ // Anything else written erases the status line first, so it never trails into output.
561
+ const io = {
562
+ stdout: (text) => {
563
+ status.clear();
564
+ ctx.io.stdout(text);
565
+ },
566
+ stderr: (text) => {
567
+ status.clear();
568
+ ctx.io.stderr(text);
569
+ },
570
+ };
571
+ const c = { ...ctx, io };
297
572
  const [sub, ...rest] = argv;
298
- return sub === 'apply' ? runApply(rest, ctx) : runSweep(argv, ctx);
573
+ try {
574
+ if (sub === 'summary')
575
+ return runSummary(rest, c, status);
576
+ if (sub === 'plan')
577
+ return runPlan(rest, c, status);
578
+ if (sub === 'apply')
579
+ return runApply(rest, c, deps, status);
580
+ if (sub === 'status')
581
+ return runStatus(rest, c, status);
582
+ return runStatus(argv, c, status);
583
+ }
584
+ finally {
585
+ status.clear();
586
+ }
299
587
  }