@osdlc/repo-cleaner 0.10.0 → 0.11.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
@@ -4,7 +4,7 @@
4
4
  * Four verbs over one set of sweep filters:
5
5
  *
6
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
7
+ * summary the same sweep as counts: totals, merged, delete, prune, review, keep
8
8
  * plan write the deletable rows as a plan, to stdout or a file
9
9
  * apply re-check, show the steps, ask, then delete
10
10
  *
@@ -21,15 +21,18 @@
21
21
  * 10 nothing to sweep: no entry named a repository
22
22
  * 11 apply stopped at a failed step; the receipt says which
23
23
  */
24
- import { readSync } from 'node:fs';
25
- import { basename, dirname, join, resolve } from 'node:path';
24
+ import { existsSync, readSync, realpathSync } from 'node:fs';
25
+ import { basename, dirname, join, resolve, sep } from 'node:path';
26
26
  import { isatty } from 'node:tty';
27
- import { EXIT, askYesNo, canPrompt, defineCli } from '@osdlc/cli-tool';
27
+ import { EXIT, askYesNo, canPrompt, defineCli, systemDirs } from '@osdlc/cli-tool';
28
28
  import { Git } from '@osdlc/typed-git';
29
29
  import { resolveBranch } from "./branches.js";
30
+ import { VENDOR_DIRS } from "./devlink.js";
31
+ import { ensureMirror, mirrorProblem } from "./remote-mirror.js";
30
32
  import { ApplyArgumentError, applyPlan, parseGrant, parseRowRef } from "./apply.js";
31
33
  import { PLAN_VERSION, fileStamp, parsePlan, planRepo, readPlan, writePlan, } from "./plan.js";
32
- import { proposedCommands, renderCountsJson, renderCountsText, renderJson, renderReceipt, renderSummary, renderText, } from "./render.js";
34
+ import { RECEIPT_VERSION } from "./receipt.js";
35
+ import { proposedCommands, renderCountsJson, renderCountsText, renderJson, renderReceipt, renderSummary, renderText, rowsActedOn, summarizeReceipt, } from "./render.js";
33
36
  import { NO_STATUS_LINE, describeStep, describeSweep, statusLine, } from "./status-line.js";
34
37
  import { sweepRepos } from "./sweep.js";
35
38
  const EXIT_NOTHING_TO_SWEEP = 10;
@@ -58,6 +61,13 @@ const SWEEP_FLAGS = {
58
61
  valueName: 'name',
59
62
  help: 'remote to measure against (default: origin)',
60
63
  },
64
+ remoteUrl: {
65
+ kind: 'list',
66
+ valueName: 'url',
67
+ default: [],
68
+ commaSeparated: false,
69
+ help: 'sweep the branches of this remote with no checkout of your own, through a clone kept in the cache directory; repeatable',
70
+ },
61
71
  gh: { kind: 'boolean', negatable: true, help: 'classify on git evidence alone' },
62
72
  fetch: {
63
73
  kind: 'boolean',
@@ -97,7 +107,7 @@ const statusCli = () => defineCli({
97
107
  },
98
108
  epilogue: `commands:
99
109
  ${NAME} [status] this report (the default)
100
- ${NAME} summary counts per repository: total, merged, delete, review, keep
110
+ ${NAME} summary counts per repository: total, merged, delete, prune, review, keep
101
111
  ${NAME} plan write the deletable rows as a plan
102
112
  ${NAME} apply re-check, show the steps, ask, delete
103
113
  ${NAME} dev run ${NAME} from a development checkout
@@ -108,7 +118,7 @@ swept as ./<name>.`,
108
118
  const summaryCli = () => defineCli({
109
119
  name: `${NAME} summary`,
110
120
  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.',
121
+ 'how many are merged into the default branch, and how many would be deleted, pruned, reviewed or kept.',
112
122
  positionals: PATHS,
113
123
  flags: { ...SWEEP_FLAGS, output: OUTPUT },
114
124
  epilogue: 'merged: the work is on the default branch. Rows kept for structure (the default ' +
@@ -160,7 +170,7 @@ const applyCli = () => defineCli({
160
170
  repoRoot: {
161
171
  kind: 'string',
162
172
  valueName: 'path',
163
- help: 'apply only the plan repository rooted here',
173
+ help: 'with a plan of several repositories: apply only the one rooted here',
164
174
  },
165
175
  receipt: {
166
176
  kind: 'string',
@@ -208,28 +218,48 @@ function failure(ctx, err) {
208
218
  ctx.io.stderr(`${NAME}: ${err instanceof Error ? err.message : String(err)}\n`);
209
219
  return EXIT.error;
210
220
  }
221
+ /** Where `--remote-url` keeps its clones. */
222
+ function mirrorCache(ctx) {
223
+ return systemDirs(VENDOR_DIRS, { env: ctx.env }).standalone(NAME).cache().app('remotes').path;
224
+ }
211
225
  /** Validate the sweep flags and run the sweep, or answer with the exit code. */
212
- function sweep(name, v, paths, ctx, render, status) {
226
+ function sweep(name, v, paths, ctx, status) {
213
227
  const only = v.only?.filter((s) => s !== '');
214
228
  const unknown = (only ?? []).filter((s) => !(s in ONLY));
215
229
  if (unknown.length > 0) {
216
- return {
217
- ok: false,
218
- code: usageError(ctx, name, `unknown --only value(s): ${unknown.join(', ')}`),
219
- };
230
+ const message = `unknown --only value(s): ${unknown.join(', ')}`;
231
+ return { kind: 'exit', code: usageError(ctx, name, message) };
220
232
  }
221
233
  if (v.branch !== undefined && paths.length > 0) {
234
+ const message = '--branch sweeps this repository only; drop the paths';
235
+ return { kind: 'exit', code: usageError(ctx, name, message) };
236
+ }
237
+ // An empty URL is refused, not dropped: `--remote-url "$UNSET"` must not
238
+ // fall back to sweeping the checkout you are standing in.
239
+ if (v.remoteUrl.some((u) => u.trim() === '')) {
222
240
  return {
223
- ok: false,
224
- code: usageError(ctx, name, '--branch sweeps this repository only; drop the paths'),
241
+ kind: 'exit',
242
+ code: usageError(ctx, name, '--remote-url needs a URL; got an empty one'),
225
243
  };
226
244
  }
245
+ const urls = v.remoteUrl;
246
+ if (urls.length > 0) {
247
+ const conflict = paths.length > 0
248
+ ? '--remote-url sweeps a remote, not a checkout; drop the paths'
249
+ : v.branch !== undefined
250
+ ? '--branch reads the checkout you are in; it does not combine with --remote-url'
251
+ : (only ?? []).some((s) => s !== 'remotes')
252
+ ? '--remote-url has remote branches only; drop --only or pass --only remotes'
253
+ : null;
254
+ if (conflict !== null)
255
+ return { kind: 'exit', code: usageError(ctx, name, conflict) };
256
+ }
227
257
  let branch;
228
258
  try {
229
259
  branch = v.branch === undefined ? undefined : resolveBranch(v.branch, new Git(ctx.cwd));
230
260
  }
231
261
  catch (err) {
232
- return { ok: false, code: failure(ctx, err) };
262
+ return { kind: 'exit', code: failure(ctx, err) };
233
263
  }
234
264
  const options = {
235
265
  baseDir: ctx.cwd,
@@ -244,22 +274,36 @@ function sweep(name, v, paths, ctx, render, status) {
244
274
  ...(only === undefined ? {} : { scope: only.map((s) => ONLY[s]) }),
245
275
  ...(v.remote === undefined ? {} : { remote: v.remote }),
246
276
  };
277
+ let entries = paths.length > 0 ? [...paths] : [ctx.cwd];
278
+ if (urls.length > 0) {
279
+ const cache = mirrorCache(ctx);
280
+ try {
281
+ entries = urls.map((url, i) => {
282
+ if (v.progress)
283
+ status.update(`${NAME}: cloning remote ${i + 1}/${urls.length}`);
284
+ return ensureMirror(url, cache);
285
+ });
286
+ }
287
+ catch (err) {
288
+ status.clear();
289
+ return { kind: 'exit', code: failure(ctx, err) };
290
+ }
291
+ options.scope = ['remote-branches'];
292
+ }
247
293
  if (v.progress)
248
294
  status.update(`${NAME}: finding repositories`);
249
- const result = sweepRepos(paths.length > 0 ? paths : [ctx.cwd], options);
295
+ const result = sweepRepos(entries, options);
250
296
  status.clear();
251
297
  if (branch !== undefined && result.repos.length > 1) {
252
298
  const err = new Error(`--branch sweeps one repository; ${result.repos.length} found under ${ctx.cwd}`);
253
- return { ok: false, code: failure(ctx, err) };
299
+ return { kind: 'exit', code: failure(ctx, err) };
254
300
  }
255
301
  const refused = branch === undefined ? null : (result.repos[0]?.error ?? null);
256
302
  if (refused !== null)
257
- return { ok: false, code: failure(ctx, new Error(refused)) };
258
- if (result.repos.length === 0) {
259
- ctx.io.stdout(render(result));
260
- return { ok: false, code: EXIT_NOTHING_TO_SWEEP };
261
- }
262
- return { ok: true, result, branch, values: v };
303
+ return { kind: 'exit', code: failure(ctx, new Error(refused)) };
304
+ if (result.repos.length === 0)
305
+ return { kind: 'nothing', result };
306
+ return { kind: 'ok', result, branch, values: v };
263
307
  }
264
308
  /**
265
309
  * Name each repository the sweep could not read, on stderr. True when there
@@ -292,9 +336,13 @@ function runStatus(argv, ctx, status) {
292
336
  return usageError(ctx, NAME, parsed.message);
293
337
  const v = parsed.values;
294
338
  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)
339
+ const swept = sweep(NAME, v, parsed.positionals, ctx, status);
340
+ if (swept.kind === 'exit')
297
341
  return swept.code;
342
+ if (swept.kind === 'nothing') {
343
+ ctx.io.stdout(render(swept.result));
344
+ return EXIT_NOTHING_TO_SWEEP;
345
+ }
298
346
  if (v.commands) {
299
347
  const commands = proposedCommands(swept.result);
300
348
  if (commands.length > 0)
@@ -314,11 +362,11 @@ function runSummary(argv, ctx, status) {
314
362
  return usageError(ctx, name, parsed.message);
315
363
  const v = parsed.values;
316
364
  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)
365
+ const swept = sweep(name, v, parsed.positionals, ctx, status);
366
+ if (swept.kind === 'exit')
319
367
  return swept.code;
320
368
  ctx.io.stdout(render(swept.result));
321
- return EXIT.ok;
369
+ return swept.kind === 'nothing' ? EXIT_NOTHING_TO_SWEEP : EXIT.ok;
322
370
  }
323
371
  function runPlan(argv, ctx, status) {
324
372
  const name = `${NAME} plan`;
@@ -328,12 +376,14 @@ function runPlan(argv, ctx, status) {
328
376
  if (parsed.status === 'error')
329
377
  return usageError(ctx, name, parsed.message);
330
378
  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)
379
+ const swept = sweep(name, v, parsed.positionals, ctx, status);
380
+ if (swept.kind === 'exit')
336
381
  return swept.code;
382
+ // stdout carries only the plan, so the summary goes to stderr either way.
383
+ if (swept.kind === 'nothing') {
384
+ ctx.io.stderr(renderSummary(swept.result) + '\n');
385
+ return EXIT_NOTHING_TO_SWEEP;
386
+ }
337
387
  const plan = planOf(swept, new Date());
338
388
  const unreadable = reportUnreadable(ctx, name, swept.result);
339
389
  ctx.io.stderr(renderSummary(swept.result) + '\n');
@@ -352,8 +402,69 @@ function receiptBeside(planPath, now) {
352
402
  const stem = basename(planPath).replace(/\.json$/i, '');
353
403
  return join(dirname(planPath), `${stem}.receipt-${fileStamp(now)}.json`);
354
404
  }
405
+ /** Rows the run would delete or prune: one each, however many steps (an unlock, a remove) it takes. */
355
406
  function wouldRun(receipt) {
356
- return receipt.steps.filter((s) => s.row !== null).length;
407
+ return rowsActedOn(summarizeReceipt(receipt));
408
+ }
409
+ /** Sweep and plan in memory, or read `--plan`; or answer with the exit code. */
410
+ function loadPlan(name, v, paths, ctx, deps, status, now) {
411
+ const receiptPath = v.receipt === undefined ? null : resolve(ctx.cwd, v.receipt);
412
+ if (v.plan === undefined) {
413
+ const swept = sweep(name, v, paths, ctx, status);
414
+ if (swept.kind === 'exit')
415
+ return swept.code;
416
+ if (swept.kind === 'nothing') {
417
+ ctx.io.stdout(v.output === 'json' ? renderJson(swept.result) : renderText(swept.result));
418
+ return EXIT_NOTHING_TO_SWEEP;
419
+ }
420
+ const items = new Map();
421
+ for (const r of swept.result.repos)
422
+ if (r.repo !== null)
423
+ items.set(r.repo.root, r.items);
424
+ return {
425
+ plan: planOf(swept, now),
426
+ source: '(swept)',
427
+ receiptPath,
428
+ unreadable: reportUnreadable(ctx, name, swept.result),
429
+ swept: items,
430
+ };
431
+ }
432
+ try {
433
+ if (v.plan === '-') {
434
+ const plan = parsePlan((deps.readStdin ?? readAllStdin)(), 'stdin');
435
+ return { plan, source: '(stdin)', receiptPath, unreadable: false, swept: undefined };
436
+ }
437
+ const source = resolve(ctx.cwd, v.plan);
438
+ const plan = readPlan(source);
439
+ return {
440
+ plan,
441
+ source,
442
+ receiptPath: receiptPath ?? receiptBeside(source, now),
443
+ unreadable: false,
444
+ swept: undefined,
445
+ };
446
+ }
447
+ catch (err) {
448
+ return failure(ctx, err);
449
+ }
450
+ }
451
+ /** The run's flags that contradict each other, as a usage message, or null. */
452
+ function applyConflict(v, paths) {
453
+ if (v.plan !== undefined) {
454
+ const sweepFlags = [
455
+ paths.length > 0 ? 'paths' : null,
456
+ v.only !== undefined ? '--only' : null,
457
+ v.branch !== undefined ? '--branch' : null,
458
+ v.remote !== undefined ? '--remote' : null,
459
+ v.remoteUrl.length > 0 ? '--remote-url' : null,
460
+ ].filter((f) => f !== null);
461
+ if (sweepFlags.length > 0)
462
+ return `--plan fixes what is swept; drop ${sweepFlags.join(', ')}`;
463
+ }
464
+ if (v.plan === '-' && !v.yes && !v.dryRun) {
465
+ return '--plan - reads the plan from stdin, so it cannot ask; pass --yes or --dry-run';
466
+ }
467
+ return null;
357
468
  }
358
469
  function runApply(argv, ctx, deps, status) {
359
470
  const name = `${NAME} apply`;
@@ -375,57 +486,50 @@ function runApply(argv, ctx, deps, status) {
375
486
  return usageError(ctx, name, err.message);
376
487
  throw err;
377
488
  }
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
- }
489
+ const conflict = applyConflict(v, paths);
490
+ if (conflict !== null)
491
+ return usageError(ctx, name, conflict);
393
492
  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
- }
493
+ const loaded = loadPlan(name, v, paths, ctx, deps, status, now);
494
+ if (typeof loaded === 'number')
495
+ return loaded;
496
+ const { plan, source, receiptPath } = loaded;
497
+ // A plan from `--remote-url` names a clone in the cache. Its deletes are
498
+ // pushed from there, so the clone must still push only where it fetches from.
499
+ const cacheDir = mirrorCache(ctx);
500
+ const cache = (existsSync(cacheDir) ? realpathSync(cacheDir) : cacheDir) + sep;
501
+ for (const repo of plan.repos) {
502
+ if (!repo.root.startsWith(cache))
503
+ continue;
504
+ const problem = mirrorProblem(repo.root);
505
+ if (problem !== null)
506
+ return failure(ctx, new Error(`${problem}; refusing to apply`));
423
507
  }
508
+ const ok = () => (loaded.unreadable ? EXIT.error : EXIT.ok);
424
509
  const repoRoot = v.repoRoot === undefined ? undefined : resolve(ctx.cwd, v.repoRoot);
510
+ const print = (receipt, shownPath) => {
511
+ ctx.io.stdout(v.output === 'json'
512
+ ? `${JSON.stringify({ receiptPath: shownPath, summary: summarizeReceipt(receipt), receipt }, null, 2)}\n`
513
+ : renderReceipt(receipt, shownPath));
514
+ };
515
+ const planned = plan.repos.filter((r) => repoRoot === undefined || resolve(r.root) === resolve(repoRoot));
516
+ if (repoRoot !== undefined && planned.length === 0) {
517
+ return failure(ctx, new Error(`no plan repository at ${repoRoot}`));
518
+ }
519
+ if (rows.length === 0 && planned.every((r) => r.rows.length === 0)) {
520
+ if (v.output === 'json')
521
+ print(emptyReceipt(source, now, v.dryRun), null);
522
+ else
523
+ ctx.io.stdout('nothing to delete\n');
524
+ return ok();
525
+ }
425
526
  const base = {
426
527
  callerCwd: ctx.cwd,
427
- planPath: planSource,
528
+ planPath: source,
428
529
  repoRoot,
530
+ // An implicit selection runs only safe-to-delete rows, in the preview and
531
+ // in the run it confirms, whose rows are passed explicitly.
532
+ safeOnly: rows.length === 0,
429
533
  ...(v.progress
430
534
  ? {
431
535
  onSweepProgress: (p) => status.update(`re-checking ${describeSweep(p)}`),
@@ -433,25 +537,17 @@ function runApply(argv, ctx, deps, status) {
433
537
  }
434
538
  : {}),
435
539
  };
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
540
  let preview;
442
541
  try {
443
- preview = applyPlan(plan, rows, grants, { ...base, dryRun: true, receiptPath: null });
542
+ // A sweep that just ran is the preview's re-check; the run that deletes sweeps again.
543
+ preview = applyPlan(plan, rows, grants, {
544
+ ...base,
545
+ dryRun: true,
546
+ receiptPath: null,
547
+ swept: loaded.swept,
548
+ });
444
549
  }
445
550
  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
551
  return failure(ctx, err);
456
552
  }
457
553
  const count = wouldRun(preview);
@@ -460,17 +556,9 @@ function runApply(argv, ctx, deps, status) {
460
556
  return ok();
461
557
  }
462
558
  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
- }
559
+ const declined = confirm(ctx, name, preview, count, v.output === 'json', deps);
560
+ if (declined !== null)
561
+ return declined;
474
562
  }
475
563
  // Only what the preview showed: rows it dropped stay dropped.
476
564
  // Pinned to their repositories: a branch name two repositories share stays two rows.
@@ -489,14 +577,35 @@ function runApply(argv, ctx, deps, status) {
489
577
  return failure(ctx, err);
490
578
  }
491
579
  print(receipt, receiptPath);
580
+ if (receiptPath === null) {
581
+ ctx.io.stderr(`${name}: no receipt written; pass --receipt <file> to keep one\n`);
582
+ }
492
583
  return receipt.status === 'failed' ? EXIT_STEP_FAILED : ok();
493
584
  }
494
- function emptyReceipt(plan, now) {
585
+ /**
586
+ * Show the preview and ask. Null to go ahead; otherwise the exit code: 0 for
587
+ * a no, 2 when there is no one to ask.
588
+ */
589
+ function confirm(ctx, name, preview, count, json, deps) {
590
+ // The preview is for the person answering, so it goes where the question does.
591
+ const show = json ? ctx.io.stderr : ctx.io.stdout;
592
+ show(renderReceipt(preview, null));
593
+ const answer = (deps.confirm ?? terminalConfirm)(`Delete ${count} item${count === 1 ? '' : 's'}?`);
594
+ if (answer === null) {
595
+ return usageError(ctx, name, 'refusing to delete without a terminal to ask; pass --yes');
596
+ }
597
+ if (!answer) {
598
+ ctx.io.stderr('nothing deleted\n');
599
+ return EXIT.ok;
600
+ }
601
+ return null;
602
+ }
603
+ function emptyReceipt(plan, now, dryRun) {
495
604
  const at = now.toISOString();
496
605
  return {
497
- version: 1,
606
+ version: RECEIPT_VERSION,
498
607
  plan,
499
- status: 'dry-run',
608
+ status: dryRun ? 'dry-run' : 'complete',
500
609
  started_at: at,
501
610
  finished_at: at,
502
611
  dropped: [],
package/dist/index.d.ts CHANGED
@@ -21,10 +21,10 @@ export { assessLanding, patchEquivalent, type Landing, type LandingCheck } from
21
21
  export { ApplyArgumentError, applyPlan, type RowSelection, parseGrant, parseRowRef, type ApplyOptions, type Grant, } from './apply.ts';
22
22
  export type { Annotate, HostHooks } from './hooks.ts';
23
23
  export { PLAN_VERSION, PlanReadError, planRepo, parsePlan, readPlan, writePlan, type Plan, type PlanRepo, type PlannableRow, } from './plan.ts';
24
- export { ReceiptSchema, type Dropped, type Receipt, type RowRef, type Step } from './receipt.ts';
24
+ export { RECEIPT_VERSION, ReceiptSchema, type Dropped, type Receipt, type RowRef, type Step, } from './receipt.ts';
25
25
  export { defaultProcessProbe, isIdleShell, parseLockOwner, parseLsofCwd, type DefaultProbeOptions, type LockOwner, type ProcessHolder, type ProcessProbe, } from './processes.ts';
26
26
  export { fetchPrs } from './prs.ts';
27
27
  export { expandTilde, isCheckoutRoot, resolveEntries, type Resolution, type ResolveOptions, } from './resolve.ts';
28
28
  export { sweepRepo, sweepRepos, type SweepOptions, type SweepReposOptions } from './sweep.ts';
29
- export { proposedCommands, renderJson, renderReceipt, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from './render.ts';
29
+ export { proposedCommands, renderJson, renderApplySummary, renderReceipt, summarizeReceipt, type ApplySummary, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from './render.ts';
30
30
  export { ALL_SCOPES, AUTHORIZATIONS, authorizationsFor, emptyBucket, emptyScan, findDuplicateCheckouts, isUnfetched, makeItem, summarize, type Authorization, type Category, type Classification, type DuplicateCheckout, type PRInfo, type RepoInfo, type RepoSweep, type ScanStats, type Scope, type SummaryBucket, type SweepItem, type SweepProgress, type SweepResult, type UnresolvedEntry, } from './types.ts';
package/dist/index.js CHANGED
@@ -20,10 +20,10 @@ export { DiscoveryFailed, discoverRepo } from "./discover.js";
20
20
  export { assessLanding, patchEquivalent } from "./landing.js";
21
21
  export { ApplyArgumentError, applyPlan, parseGrant, parseRowRef, } from "./apply.js";
22
22
  export { PLAN_VERSION, PlanReadError, planRepo, parsePlan, readPlan, writePlan, } from "./plan.js";
23
- export { ReceiptSchema } from "./receipt.js";
23
+ export { RECEIPT_VERSION, ReceiptSchema, } from "./receipt.js";
24
24
  export { defaultProcessProbe, isIdleShell, parseLockOwner, parseLsofCwd, } from "./processes.js";
25
25
  export { fetchPrs } from "./prs.js";
26
26
  export { expandTilde, isCheckoutRoot, resolveEntries, } from "./resolve.js";
27
27
  export { sweepRepo, sweepRepos } from "./sweep.js";
28
- export { proposedCommands, renderJson, renderReceipt, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from "./render.js";
28
+ export { proposedCommands, renderJson, renderApplySummary, renderReceipt, summarizeReceipt, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from "./render.js";
29
29
  export { ALL_SCOPES, AUTHORIZATIONS, authorizationsFor, emptyBucket, emptyScan, findDuplicateCheckouts, isUnfetched, makeItem, summarize, } from "./types.js";
package/dist/receipt.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  /** What `applyPlan` did, step by step, rewritten after every step. */
2
2
  import { z } from 'zod';
3
+ /** The receipt format's version, written into every receipt. */
4
+ export declare const RECEIPT_VERSION = 1;
3
5
  export declare const RowRefSchema: z.ZodObject<{
4
6
  category: z.ZodEnum<{
5
7
  local_branch: "local_branch";
@@ -23,6 +25,7 @@ export declare const DroppedSchema: z.ZodObject<{
23
25
  "needs-review": "needs-review";
24
26
  "facts-changed": "facts-changed";
25
27
  "not-authorized": "not-authorized";
28
+ duplicate: "duplicate";
26
29
  }>;
27
30
  detail: z.ZodString;
28
31
  }, z.core.$strip>;
@@ -81,6 +84,7 @@ export declare const ReceiptSchema: z.ZodObject<{
81
84
  "needs-review": "needs-review";
82
85
  "facts-changed": "facts-changed";
83
86
  "not-authorized": "not-authorized";
87
+ duplicate: "duplicate";
84
88
  }>;
85
89
  detail: z.ZodString;
86
90
  }, z.core.$strip>>;
package/dist/receipt.js CHANGED
@@ -1,10 +1,19 @@
1
1
  /** What `applyPlan` did, step by step, rewritten after every step. */
2
2
  import { z } from 'zod';
3
3
  import { PLAN_CATEGORIES } from "./plan.js";
4
+ /** The receipt format's version, written into every receipt. */
5
+ export const RECEIPT_VERSION = 1;
4
6
  export const RowRefSchema = z.object({ category: z.enum(PLAN_CATEGORIES), id: z.string() });
5
7
  export const DroppedSchema = RowRefSchema.extend({
6
8
  repo: z.string(),
7
- reason: z.enum(['gone', 'not-deletable', 'needs-review', 'facts-changed', 'not-authorized']),
9
+ reason: z.enum([
10
+ 'gone',
11
+ 'not-deletable',
12
+ 'needs-review',
13
+ 'facts-changed',
14
+ 'not-authorized',
15
+ 'duplicate',
16
+ ]),
8
17
  detail: z.string(),
9
18
  });
10
19
  export const StepSchema = z.object({
@@ -25,7 +34,7 @@ export const StepSchema = z.object({
25
34
  verification: z.string().nullable(),
26
35
  });
27
36
  export const ReceiptSchema = z.object({
28
- version: z.literal(1),
37
+ version: z.literal(RECEIPT_VERSION),
29
38
  plan: z.string(),
30
39
  status: z.enum(['dry-run', 'applying', 'complete', 'failed']),
31
40
  started_at: z.string(),
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Sweep a remote with no checkout of your own: a clone of it, kept in
3
+ * repo-cleaner's cache directory, stands in for the checkout.
4
+ *
5
+ * The clone is `--no-checkout`, so no files are written outside `.git`. It is
6
+ * not a partial (`--filter=blob:none`) clone: the patch comparison that
7
+ * finds squash-merged branches reads file contents, and a partial clone
8
+ * fetches them over the network one comparison at a time, which made a sweep
9
+ * of a few hundred branches take minutes instead of seconds. It is kept
10
+ * rather than thrown away so a plan written from it can be applied later: the
11
+ * plan names the clone's directory, and `apply` re-sweeps and pushes deletes
12
+ * from there. A later run reuses it, and the sweep's fetch brings it up to
13
+ * date.
14
+ */
15
+ import type { CommandRunner } from '@osdlc/command-seam';
16
+ /** A remote URL that cannot be mirrored, or a mirror that cannot be made or reused. */
17
+ export declare class RemoteMirrorError extends Error {
18
+ constructor(message: string);
19
+ }
20
+ /**
21
+ * `text` with every URL's credentials removed: the user-info before `@` in a
22
+ * `scheme://` URL, and any query string or fragment. For a message or a name
23
+ * that must not carry a token.
24
+ */
25
+ export declare function redactUrl(text: string): string;
26
+ /**
27
+ * The directory name for `url`'s mirror: readable `host-owner-repo`, then a
28
+ * hash of the whole URL so two URLs never share a mirror. The readable part
29
+ * is read from the URL with its credentials, query and fragment removed, so a
30
+ * token never reaches the name.
31
+ */
32
+ export declare function mirrorName(url: string): string;
33
+ /**
34
+ * Why `dir` is not a mirror of `url` — or of whatever it was cloned from, when
35
+ * `url` is omitted — or null when it is. A mirror's `origin` has one fetch URL
36
+ * and pushes to that URL alone, so a delete cannot be sent elsewhere.
37
+ */
38
+ export declare function mirrorProblem(dir: string, url?: string, opts?: {
39
+ git?: CommandRunner | undefined;
40
+ }): string | null;
41
+ /**
42
+ * The directory of `url`'s mirror under `cacheDir`, cloned on first use. An
43
+ * existing mirror is reused as it is, once {@link mirrorProblem} finds
44
+ * nothing wrong with it; the sweep's own fetch brings it up to date. A first
45
+ * clone goes to a sibling directory and is renamed into place, so a clone
46
+ * that fails leaves nothing behind, and a run that loses a race to clone the
47
+ * same URL uses the winner's clone. Messages carry the URL with its
48
+ * credentials removed.
49
+ *
50
+ * @throws RemoteMirrorError when `url` is empty or looks like an option, when
51
+ * the clone fails, or when the directory is not a mirror of `url`.
52
+ */
53
+ export declare function ensureMirror(url: string, cacheDir: string, opts?: {
54
+ git?: CommandRunner | undefined;
55
+ }): string;