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