@osdlc/repo-cleaner 0.10.1 → 0.13.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,17 +21,21 @@
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
+ import { detectColorLevel } from '@osdlc/ux';
29
30
  import { resolveBranch } from "./branches.js";
31
+ import { VENDOR_DIRS } from "./devlink.js";
32
+ import { ensureMirror, mirrorProblem } from "./remote-mirror.js";
30
33
  import { ApplyArgumentError, applyPlan, parseGrant, parseRowRef } from "./apply.js";
31
34
  import { PLAN_VERSION, fileStamp, parsePlan, planRepo, readPlan, writePlan, } from "./plan.js";
32
35
  import { RECEIPT_VERSION } from "./receipt.js";
33
- import { proposedCommands, renderCountsJson, renderCountsText, renderJson, renderReceipt, renderSummary, renderText, rowsActedOn, summarizeReceipt, } from "./render.js";
36
+ import { PLAIN, proposedCommands, renderCountsJson, renderCountsText, renderJson, renderPreview, renderPreviewDrops, renderReceipt, renderStepFailure, renderSummary, renderText, rowsActedOn, summarizeReceipt, TERMINAL_STYLE, } from "./render.js";
34
37
  import { NO_STATUS_LINE, describeStep, describeSweep, statusLine, } from "./status-line.js";
38
+ import { stepBoard } from "./step-board.js";
35
39
  import { sweepRepos } from "./sweep.js";
36
40
  const EXIT_NOTHING_TO_SWEEP = 10;
37
41
  const EXIT_STEP_FAILED = 11;
@@ -47,23 +51,38 @@ const SWEEP_FLAGS = {
47
51
  only: {
48
52
  kind: 'list',
49
53
  valueName: 'list',
50
- help: `comma-separated: ${Object.keys(ONLY).join(', ')} (default: all)`,
54
+ help: `comma-separated: ${Object.keys(ONLY).join(', ')} (default: worktrees, branches; remotes with --remotes)`,
55
+ },
56
+ remotes: {
57
+ kind: 'boolean',
58
+ help: 'also sweep remote branches, which is slower: each one is compared with the default branch',
51
59
  },
52
60
  branch: {
53
61
  kind: 'string',
54
62
  valueName: 'name|HEAD',
55
- help: 'only this branch of this repository: its worktree, local and remote branch',
63
+ help: 'only this branch of this repository: its worktree and local branch, and its remote branch with --remotes',
56
64
  },
57
65
  remote: {
58
66
  kind: 'string',
59
67
  valueName: 'name',
60
68
  help: 'remote to measure against (default: origin)',
61
69
  },
62
- gh: { kind: 'boolean', negatable: true, help: 'classify on git evidence alone' },
70
+ remoteUrl: {
71
+ kind: 'list',
72
+ valueName: 'url',
73
+ default: [],
74
+ commaSeparated: false,
75
+ help: 'sweep the branches of this remote with no checkout of your own, through a clone kept in the cache directory; repeatable',
76
+ },
77
+ gh: {
78
+ kind: 'boolean',
79
+ negatable: true,
80
+ help: 'with --remotes: classify on git evidence alone (without --remotes gh is never called)',
81
+ },
63
82
  fetch: {
64
83
  kind: 'boolean',
65
84
  negatable: true,
66
- help: 'classify on the remote-tracking refs already on disk',
85
+ help: 'with --remotes: classify on the remote-tracking refs already on disk (without --remotes nothing is fetched)',
67
86
  },
68
87
  progress: {
69
88
  kind: 'boolean',
@@ -87,8 +106,9 @@ const OUTPUT = {
87
106
  };
88
107
  const statusCli = () => defineCli({
89
108
  name: NAME,
90
- summary: 'Inventory branches, worktrees and remote branches, and say which have been merged into ' +
91
- 'the default branch. Fetches each remote first; deletes nothing.',
109
+ summary: 'Inventory local branches and worktrees (remote branches too with --remotes), and say ' +
110
+ 'which have been merged into the default branch. Offline unless --remotes, which fetches ' +
111
+ 'each remote first and asks gh about pull requests; deletes nothing.',
92
112
  positionals: PATHS,
93
113
  flags: {
94
114
  ...SWEEP_FLAGS,
@@ -98,7 +118,7 @@ const statusCli = () => defineCli({
98
118
  },
99
119
  epilogue: `commands:
100
120
  ${NAME} [status] this report (the default)
101
- ${NAME} summary counts per repository: total, merged, delete, review, keep
121
+ ${NAME} summary counts per repository: total, merged, delete, prune, review, keep
102
122
  ${NAME} plan write the deletable rows as a plan
103
123
  ${NAME} apply re-check, show the steps, ask, delete
104
124
  ${NAME} dev run ${NAME} from a development checkout
@@ -108,8 +128,8 @@ swept as ./<name>.`,
108
128
  });
109
129
  const summaryCli = () => defineCli({
110
130
  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.',
131
+ summary: 'Count local branches and worktrees (and remote branches with --remotes) per repository: how many there are, ' +
132
+ 'how many are merged into the default branch, and how many would be deleted, pruned, reviewed or kept.',
113
133
  positionals: PATHS,
114
134
  flags: { ...SWEEP_FLAGS, output: OUTPUT },
115
135
  epilogue: 'merged: the work is on the default branch. Rows kept for structure (the default ' +
@@ -151,6 +171,13 @@ const applyCli = () => defineCli({
151
171
  commaSeparated: false,
152
172
  help: 'apply only this row, e.g. local_branch:feat/x; repeatable (default: every row)',
153
173
  },
174
+ keep: {
175
+ kind: 'list',
176
+ valueName: 'remote_branch:id',
177
+ default: [],
178
+ commaSeparated: false,
179
+ help: 'keep this remote branch, but refuse the run if its tip moved since the plan, e.g. remote_branch:origin/feat/x; repeatable',
180
+ },
154
181
  allow: {
155
182
  kind: 'list',
156
183
  valueName: 'risk[=category:id]',
@@ -209,6 +236,31 @@ function failure(ctx, err) {
209
236
  ctx.io.stderr(`${NAME}: ${err instanceof Error ? err.message : String(err)}\n`);
210
237
  return EXIT.error;
211
238
  }
239
+ /** Color for a report on `stream`, when it is a terminal that takes color. */
240
+ function styleFor(ctx, stream) {
241
+ const level = detectColorLevel({ env: ctx.env, isTTY: ctx.terminal?.[stream] === true });
242
+ return level === 'none' ? PLAIN : TERMINAL_STYLE;
243
+ }
244
+ /** The note under a text report that left remote branches out, or '' when it did not. */
245
+ function remotesNote(swept, style) {
246
+ if (swept.kind !== 'ok' || swept.scope.includes('remote-branches'))
247
+ return '';
248
+ if (!swept.result.repos.some((r) => r.repo?.remote != null))
249
+ return '';
250
+ return (style.muted('Local only: nothing fetched, no gh, remote branches not checked; add --remotes for all three.') + '\n');
251
+ }
252
+ /** Where `--remote-url` keeps its clones. */
253
+ function mirrorCache(ctx) {
254
+ return systemDirs(VENDOR_DIRS, { env: ctx.env }).standalone(NAME).cache().app('remotes').path;
255
+ }
256
+ /** Local work by default: remote branches only when asked for, with `--remotes` or `--only remotes`. */
257
+ const LOCAL_SCOPE = ['worktrees', 'local-branches'];
258
+ function sweepScope(only, remotes) {
259
+ const scope = only === undefined ? [...LOCAL_SCOPE] : only.map((s) => ONLY[s]);
260
+ if (remotes && !scope.includes('remote-branches'))
261
+ scope.push('remote-branches');
262
+ return scope;
263
+ }
212
264
  /** Validate the sweep flags and run the sweep, or answer with the exit code. */
213
265
  function sweep(name, v, paths, ctx, status) {
214
266
  const only = v.only?.filter((s) => s !== '');
@@ -221,6 +273,26 @@ function sweep(name, v, paths, ctx, status) {
221
273
  const message = '--branch sweeps this repository only; drop the paths';
222
274
  return { kind: 'exit', code: usageError(ctx, name, message) };
223
275
  }
276
+ // An empty URL is refused, not dropped: `--remote-url "$UNSET"` must not
277
+ // fall back to sweeping the checkout you are standing in.
278
+ if (v.remoteUrl.some((u) => u.trim() === '')) {
279
+ return {
280
+ kind: 'exit',
281
+ code: usageError(ctx, name, '--remote-url needs a URL; got an empty one'),
282
+ };
283
+ }
284
+ const urls = v.remoteUrl;
285
+ if (urls.length > 0) {
286
+ const conflict = paths.length > 0
287
+ ? '--remote-url sweeps a remote, not a checkout; drop the paths'
288
+ : v.branch !== undefined
289
+ ? '--branch reads the checkout you are in; it does not combine with --remote-url'
290
+ : (only ?? []).some((s) => s !== 'remotes')
291
+ ? '--remote-url has remote branches only; drop --only or pass --only remotes'
292
+ : null;
293
+ if (conflict !== null)
294
+ return { kind: 'exit', code: usageError(ctx, name, conflict) };
295
+ }
224
296
  let branch;
225
297
  try {
226
298
  branch = v.branch === undefined ? undefined : resolveBranch(v.branch, new Git(ctx.cwd));
@@ -228,22 +300,40 @@ function sweep(name, v, paths, ctx, status) {
228
300
  catch (err) {
229
301
  return { kind: 'exit', code: failure(ctx, err) };
230
302
  }
303
+ const scope = urls.length > 0 ? ['remote-branches'] : sweepScope(only, v.remotes);
304
+ const online = scope.includes('remote-branches');
231
305
  const options = {
232
306
  baseDir: ctx.cwd,
233
307
  cwd: ctx.cwd,
234
- noGh: !v.gh,
235
- fetch: v.fetch,
308
+ // Local by default means offline: the fetch and gh go with remote branches.
309
+ noGh: !(online && v.gh),
310
+ fetch: online && v.fetch,
236
311
  branch,
237
312
  // Mid-walk, while the wait is happening — the summary comes too late to
238
313
  // explain why the tool appeared to hang.
239
314
  onWarn: (message) => ctx.io.stderr(`${NAME}: ${message}\n`),
240
315
  ...(v.progress ? { onProgress: (p) => status.update(describeSweep(p)) } : {}),
241
- ...(only === undefined ? {} : { scope: only.map((s) => ONLY[s]) }),
316
+ scope,
242
317
  ...(v.remote === undefined ? {} : { remote: v.remote }),
243
318
  };
319
+ let entries = paths.length > 0 ? [...paths] : [ctx.cwd];
320
+ if (urls.length > 0) {
321
+ const cache = mirrorCache(ctx);
322
+ try {
323
+ entries = urls.map((url, i) => {
324
+ if (v.progress)
325
+ status.update(`${NAME}: cloning remote ${i + 1}/${urls.length}`);
326
+ return ensureMirror(url, cache);
327
+ });
328
+ }
329
+ catch (err) {
330
+ status.clear();
331
+ return { kind: 'exit', code: failure(ctx, err) };
332
+ }
333
+ }
244
334
  if (v.progress)
245
335
  status.update(`${NAME}: finding repositories`);
246
- const result = sweepRepos(paths.length > 0 ? paths : [ctx.cwd], options);
336
+ const result = sweepRepos(entries, options);
247
337
  status.clear();
248
338
  if (branch !== undefined && result.repos.length > 1) {
249
339
  const err = new Error(`--branch sweeps one repository; ${result.repos.length} found under ${ctx.cwd}`);
@@ -254,7 +344,7 @@ function sweep(name, v, paths, ctx, status) {
254
344
  return { kind: 'exit', code: failure(ctx, new Error(refused)) };
255
345
  if (result.repos.length === 0)
256
346
  return { kind: 'nothing', result };
257
- return { kind: 'ok', result, branch, values: v };
347
+ return { kind: 'ok', result, branch, values: v, scope, noGh: options.noGh === true };
258
348
  }
259
349
  /**
260
350
  * Name each repository the sweep could not read, on stderr. True when there
@@ -270,13 +360,13 @@ function reportUnreadable(ctx, name, result) {
270
360
  function planOf(swept, now) {
271
361
  const opts = {
272
362
  remote: swept.values.remote ?? null,
273
- noGh: !swept.values.gh,
363
+ noGh: swept.noGh,
274
364
  branch: swept.branch ?? null,
275
365
  };
276
366
  return {
277
367
  version: PLAN_VERSION,
278
368
  created_at: now.toISOString(),
279
- repos: swept.result.repos.flatMap((s) => s.repo === null ? [] : [planRepo(s.repo.root, opts, s.items)]),
369
+ repos: swept.result.repos.flatMap((s) => s.repo === null ? [] : [planRepo(s.repo.root, { ...opts, fetch: s.repo.fetched }, s.items)]),
280
370
  };
281
371
  }
282
372
  function runStatus(argv, ctx, status) {
@@ -286,7 +376,8 @@ function runStatus(argv, ctx, status) {
286
376
  if (parsed.status === 'error')
287
377
  return usageError(ctx, NAME, parsed.message);
288
378
  const v = parsed.values;
289
- const render = (r) => v.output === 'json' ? renderJson(r) : renderText(r, { keep: v.keep });
379
+ const style = styleFor(ctx, 'stdout');
380
+ const render = (r) => v.output === 'json' ? renderJson(r) : renderText(r, { keep: v.keep, style });
290
381
  const swept = sweep(NAME, v, parsed.positionals, ctx, status);
291
382
  if (swept.kind === 'exit')
292
383
  return swept.code;
@@ -301,6 +392,8 @@ function runStatus(argv, ctx, status) {
301
392
  }
302
393
  else {
303
394
  ctx.io.stdout(render(swept.result));
395
+ if (v.output === 'text')
396
+ ctx.io.stdout(remotesNote(swept, style));
304
397
  }
305
398
  return EXIT.ok;
306
399
  }
@@ -312,11 +405,14 @@ function runSummary(argv, ctx, status) {
312
405
  if (parsed.status === 'error')
313
406
  return usageError(ctx, name, parsed.message);
314
407
  const v = parsed.values;
315
- const render = (r) => v.output === 'json' ? renderCountsJson(r) : renderCountsText(r);
408
+ const style = styleFor(ctx, 'stdout');
409
+ const render = (r) => v.output === 'json' ? renderCountsJson(r) : renderCountsText(r, style);
316
410
  const swept = sweep(name, v, parsed.positionals, ctx, status);
317
411
  if (swept.kind === 'exit')
318
412
  return swept.code;
319
413
  ctx.io.stdout(render(swept.result));
414
+ if (v.output === 'text')
415
+ ctx.io.stdout(remotesNote(swept, style));
320
416
  return swept.kind === 'nothing' ? EXIT_NOTHING_TO_SWEEP : EXIT.ok;
321
417
  }
322
418
  function runPlan(argv, ctx, status) {
@@ -337,7 +433,8 @@ function runPlan(argv, ctx, status) {
337
433
  }
338
434
  const plan = planOf(swept, new Date());
339
435
  const unreadable = reportUnreadable(ctx, name, swept.result);
340
- ctx.io.stderr(renderSummary(swept.result) + '\n');
436
+ const style = styleFor(ctx, 'stderr');
437
+ ctx.io.stderr(renderSummary(swept.result, style) + '\n' + remotesNote(swept, style));
341
438
  if (v.out === '-') {
342
439
  ctx.io.stdout(`${JSON.stringify(plan, null, 2)}\n`);
343
440
  }
@@ -353,6 +450,7 @@ function receiptBeside(planPath, now) {
353
450
  const stem = basename(planPath).replace(/\.json$/i, '');
354
451
  return join(dirname(planPath), `${stem}.receipt-${fileStamp(now)}.json`);
355
452
  }
453
+ const dropKey = (d) => JSON.stringify([d.repo, d.category, d.id]);
356
454
  /** Rows the run would delete or prune: one each, however many steps (an unlock, a remove) it takes. */
357
455
  function wouldRun(receipt) {
358
456
  return rowsActedOn(summarizeReceipt(receipt));
@@ -407,6 +505,7 @@ function applyConflict(v, paths) {
407
505
  v.only !== undefined ? '--only' : null,
408
506
  v.branch !== undefined ? '--branch' : null,
409
507
  v.remote !== undefined ? '--remote' : null,
508
+ v.remoteUrl.length > 0 ? '--remote-url' : null,
410
509
  ].filter((f) => f !== null);
411
510
  if (sweepFlags.length > 0)
412
511
  return `--plan fixes what is swept; drop ${sweepFlags.join(', ')}`;
@@ -426,9 +525,11 @@ function runApply(argv, ctx, deps, status) {
426
525
  const v = parsed.values;
427
526
  const paths = parsed.positionals;
428
527
  let rows;
528
+ let keep;
429
529
  let grants;
430
530
  try {
431
531
  rows = v.row.map(parseRowRef);
532
+ keep = v.keep.map(parseRowRef);
432
533
  grants = v.allow.map(parseGrant);
433
534
  }
434
535
  catch (err) {
@@ -444,12 +545,27 @@ function runApply(argv, ctx, deps, status) {
444
545
  if (typeof loaded === 'number')
445
546
  return loaded;
446
547
  const { plan, source, receiptPath } = loaded;
548
+ // A plan from `--remote-url` names a clone in the cache. Its deletes are
549
+ // pushed from there, so the clone must still push only where it fetches from.
550
+ const cacheDir = mirrorCache(ctx);
551
+ const cache = (existsSync(cacheDir) ? realpathSync(cacheDir) : cacheDir) + sep;
552
+ for (const repo of plan.repos) {
553
+ if (!repo.root.startsWith(cache))
554
+ continue;
555
+ const problem = mirrorProblem(repo.root);
556
+ if (problem !== null)
557
+ return failure(ctx, new Error(`${problem}; refusing to apply`));
558
+ }
447
559
  const ok = () => (loaded.unreadable ? EXIT.error : EXIT.ok);
448
560
  const repoRoot = v.repoRoot === undefined ? undefined : resolve(ctx.cwd, v.repoRoot);
449
- const print = (receipt, shownPath) => {
561
+ // On a terminal the run is shown live: every step first, then each one
562
+ // checked off as it finishes, with a bar on the status line.
563
+ const live = v.output === 'text' && ctx.terminal?.stdout === true;
564
+ const style = styleFor(ctx, 'stdout');
565
+ const print = (receipt, shownPath, steps = true) => {
450
566
  ctx.io.stdout(v.output === 'json'
451
567
  ? `${JSON.stringify({ receiptPath: shownPath, summary: summarizeReceipt(receipt), receipt }, null, 2)}\n`
452
- : renderReceipt(receipt, shownPath));
568
+ : renderReceipt(receipt, shownPath, style, { steps }));
453
569
  };
454
570
  const planned = plan.repos.filter((r) => repoRoot === undefined || resolve(r.root) === resolve(repoRoot));
455
571
  if (repoRoot !== undefined && planned.length === 0) {
@@ -466,6 +582,7 @@ function runApply(argv, ctx, deps, status) {
466
582
  callerCwd: ctx.cwd,
467
583
  planPath: source,
468
584
  repoRoot,
585
+ keep,
469
586
  // An implicit selection runs only safe-to-delete rows, in the preview and
470
587
  // in the run it confirms, whose rows are passed explicitly.
471
588
  safeOnly: rows.length === 0,
@@ -487,11 +604,16 @@ function runApply(argv, ctx, deps, status) {
487
604
  });
488
605
  }
489
606
  catch (err) {
607
+ if (err instanceof ApplyArgumentError)
608
+ return usageError(ctx, name, err.message);
490
609
  return failure(ctx, err);
491
610
  }
492
611
  const count = wouldRun(preview);
493
612
  if (v.dryRun || count === 0) {
494
- print(preview, null);
613
+ if (live)
614
+ ctx.io.stdout(renderPreview(preview, style));
615
+ else
616
+ print(preview, null);
495
617
  return ok();
496
618
  }
497
619
  if (!v.yes) {
@@ -499,10 +621,22 @@ function runApply(argv, ctx, deps, status) {
499
621
  if (declined !== null)
500
622
  return declined;
501
623
  }
624
+ else if (live) {
625
+ ctx.io.stdout(renderPreview(preview, style));
626
+ }
627
+ if (live)
628
+ ctx.io.stdout('\n');
502
629
  // Only what the preview showed: rows it dropped stay dropped.
503
630
  // 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 }]);
631
+ // A kept row's tip check is not a selection; `keep` carries it again.
632
+ const approved = preview.steps.flatMap((s) => s.row === null || s.action === 'verify-remote' ? [] : [{ ...s.row, repo: s.repo }]);
505
633
  const approvedGrants = grants.filter((g) => g.row === null || approved.some((r) => r.category === g.row.category && r.id === g.row.id));
634
+ const size = deps.terminalSize ??
635
+ (() => ({
636
+ width: ctx.terminal?.columns || process.stdout.columns || 80,
637
+ height: process.stdout.rows || 24,
638
+ }));
639
+ let board;
506
640
  let receipt;
507
641
  try {
508
642
  receipt = applyPlan(plan, approved, approvedGrants, {
@@ -510,12 +644,40 @@ function runApply(argv, ctx, deps, status) {
510
644
  dryRun: false,
511
645
  receiptPath,
512
646
  dropped: preview.dropped,
647
+ ...(live
648
+ ? {
649
+ // The board carries the run's progress; the status line stays clear.
650
+ onStart: (steps) => {
651
+ board = stepBoard(ctx.io.stdout, steps, size, style);
652
+ board.draw(null);
653
+ },
654
+ onStep: (i) => board?.draw(i),
655
+ onStepDone: (_i, _total, step) => {
656
+ board?.draw(null);
657
+ // Here rather than after the run: a receipt that then cannot be
658
+ // saved must not take the step's own error with it.
659
+ if (step.result === 'failed') {
660
+ ctx.io.stdout(`\n${renderStepFailure(step, style)}\n`);
661
+ }
662
+ },
663
+ }
664
+ : {}),
513
665
  });
514
666
  }
515
667
  catch (err) {
516
668
  return failure(ctx, err);
517
669
  }
518
- print(receipt, receiptPath);
670
+ status.clear();
671
+ if (live) {
672
+ // The run's own re-check can drop a row the preview showed, if it moved
673
+ // while the question was open: say which, since it gets no tick.
674
+ const seen = new Set(preview.dropped.map(dropKey));
675
+ const late = receipt.dropped.filter((d) => !seen.has(dropKey(d)));
676
+ if (late.length > 0)
677
+ ctx.io.stdout(`\n${renderPreviewDrops(late, style)}`);
678
+ ctx.io.stdout('\n');
679
+ }
680
+ print(receipt, receiptPath, !live);
519
681
  if (receiptPath === null) {
520
682
  ctx.io.stderr(`${name}: no receipt written; pass --receipt <file> to keep one\n`);
521
683
  }
@@ -528,7 +690,7 @@ function runApply(argv, ctx, deps, status) {
528
690
  function confirm(ctx, name, preview, count, json, deps) {
529
691
  // The preview is for the person answering, so it goes where the question does.
530
692
  const show = json ? ctx.io.stderr : ctx.io.stdout;
531
- show(renderReceipt(preview, null));
693
+ show(renderPreview(preview, styleFor(ctx, json ? 'stderr' : 'stdout')));
532
694
  const answer = (deps.confirm ?? terminalConfirm)(`Delete ${count} item${count === 1 ? '' : 's'}?`);
533
695
  if (answer === null) {
534
696
  return usageError(ctx, name, 'refusing to delete without a terminal to ask; pass --yes');
package/dist/index.d.ts CHANGED
@@ -18,7 +18,7 @@ export { classifyLocalBranch, classifyRemoteBranch, gatherLocalBranches, gatherR
18
18
  export { classifyWorktree, gatherWorktrees, hasBlockers, worktreePruneCommand, worktreeRemoveCommand, worktreeUnlockCommand, type WorktreeBlocker, } from './worktrees.ts';
19
19
  export { DiscoveryFailed, discoverRepo } from './discover.ts';
20
20
  export { assessLanding, patchEquivalent, type Landing, type LandingCheck } from './landing.ts';
21
- export { ApplyArgumentError, applyPlan, type RowSelection, parseGrant, parseRowRef, type ApplyOptions, type Grant, } from './apply.ts';
21
+ export { ApplyArgumentError, ApplyRefused, 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
24
  export { RECEIPT_VERSION, ReceiptSchema, type Dropped, type Receipt, type RowRef, type Step, } from './receipt.ts';
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ export { classifyLocalBranch, classifyRemoteBranch, gatherLocalBranches, gatherR
18
18
  export { classifyWorktree, gatherWorktrees, hasBlockers, worktreePruneCommand, worktreeRemoveCommand, worktreeUnlockCommand, } from "./worktrees.js";
19
19
  export { DiscoveryFailed, discoverRepo } from "./discover.js";
20
20
  export { assessLanding, patchEquivalent } from "./landing.js";
21
- export { ApplyArgumentError, applyPlan, parseGrant, parseRowRef, } from "./apply.js";
21
+ export { ApplyArgumentError, ApplyRefused, applyPlan, parseGrant, parseRowRef, } from "./apply.js";
22
22
  export { PLAN_VERSION, PlanReadError, planRepo, parsePlan, readPlan, writePlan, } from "./plan.js";
23
23
  export { RECEIPT_VERSION, ReceiptSchema, } from "./receipt.js";
24
24
  export { defaultProcessProbe, isIdleShell, parseLockOwner, parseLsofCwd, } from "./processes.js";
package/dist/plan.d.ts CHANGED
@@ -29,6 +29,7 @@ export declare const PlanRepoSchema: z.ZodObject<{
29
29
  remote: z.ZodNullable<z.ZodString>;
30
30
  no_gh: z.ZodBoolean;
31
31
  branch: z.ZodDefault<z.ZodNullable<z.ZodString>>;
32
+ fetch: z.ZodDefault<z.ZodBoolean>;
32
33
  rows: z.ZodArray<z.ZodObject<{
33
34
  category: z.ZodEnum<{
34
35
  local_branch: "local_branch";
@@ -53,6 +54,7 @@ export declare const PlanSchema: z.ZodObject<{
53
54
  remote: z.ZodNullable<z.ZodString>;
54
55
  no_gh: z.ZodBoolean;
55
56
  branch: z.ZodDefault<z.ZodNullable<z.ZodString>>;
57
+ fetch: z.ZodDefault<z.ZodBoolean>;
56
58
  rows: z.ZodArray<z.ZodObject<{
57
59
  category: z.ZodEnum<{
58
60
  local_branch: "local_branch";
@@ -89,6 +91,7 @@ export declare function planRepo(root: string, opts: {
89
91
  remote: string | null;
90
92
  noGh: boolean;
91
93
  branch?: string | null;
94
+ fetch?: boolean;
92
95
  }, items: readonly PlannableRow[]): PlanRepo;
93
96
  /** A UTC timestamp safe in a file name, e.g. `2026-10-04T12-30-05-123Z`. */
94
97
  export declare function fileStamp(d: Date): string;
package/dist/plan.js CHANGED
@@ -23,6 +23,12 @@ export const PlanRepoSchema = z.object({
23
23
  no_gh: z.boolean(),
24
24
  /** The `branch` the sweep was narrowed to; the re-sweep uses the same. */
25
25
  branch: z.string().nullable().default(null),
26
+ /**
27
+ * Whether the sweep fetched before classifying. The re-sweep fetches too, so
28
+ * it judges against the remote's current default branch, as the sweep did.
29
+ * A plan written before this field reads as not fetched.
30
+ */
31
+ fetch: z.boolean().default(false),
26
32
  rows: z.array(PlanRowSchema),
27
33
  });
28
34
  export const PlanSchema = z.object({
@@ -78,7 +84,14 @@ export function planRepo(root, opts, items) {
78
84
  fingerprint: fingerprint(item),
79
85
  });
80
86
  }
81
- return { root, remote: opts.remote, no_gh: opts.noGh, branch: opts.branch ?? null, rows };
87
+ return {
88
+ root,
89
+ remote: opts.remote,
90
+ no_gh: opts.noGh,
91
+ branch: opts.branch ?? null,
92
+ fetch: opts.fetch ?? false,
93
+ rows,
94
+ };
82
95
  }
83
96
  /** A UTC timestamp safe in a file name, e.g. `2026-10-04T12-30-05-123Z`. */
84
97
  export function fileStamp(d) {
package/dist/receipt.d.ts CHANGED
@@ -26,6 +26,7 @@ export declare const DroppedSchema: z.ZodObject<{
26
26
  "facts-changed": "facts-changed";
27
27
  "not-authorized": "not-authorized";
28
28
  duplicate: "duplicate";
29
+ kept: "kept";
29
30
  }>;
30
31
  detail: z.ZodString;
31
32
  }, z.core.$strip>;
@@ -41,6 +42,7 @@ export declare const StepSchema: z.ZodObject<{
41
42
  id: z.ZodString;
42
43
  }, z.core.$strip>>;
43
44
  action: z.ZodEnum<{
45
+ "verify-remote": "verify-remote";
44
46
  "unlock-worktree": "unlock-worktree";
45
47
  "remove-worktree": "remove-worktree";
46
48
  "delete-local": "delete-local";
@@ -85,6 +87,7 @@ export declare const ReceiptSchema: z.ZodObject<{
85
87
  "facts-changed": "facts-changed";
86
88
  "not-authorized": "not-authorized";
87
89
  duplicate: "duplicate";
90
+ kept: "kept";
88
91
  }>;
89
92
  detail: z.ZodString;
90
93
  }, z.core.$strip>>;
@@ -99,6 +102,7 @@ export declare const ReceiptSchema: z.ZodObject<{
99
102
  id: z.ZodString;
100
103
  }, z.core.$strip>>;
101
104
  action: z.ZodEnum<{
105
+ "verify-remote": "verify-remote";
102
106
  "unlock-worktree": "unlock-worktree";
103
107
  "remove-worktree": "remove-worktree";
104
108
  "delete-local": "delete-local";
package/dist/receipt.js CHANGED
@@ -13,6 +13,7 @@ export const DroppedSchema = RowRefSchema.extend({
13
13
  'facts-changed',
14
14
  'not-authorized',
15
15
  'duplicate',
16
+ 'kept',
16
17
  ]),
17
18
  detail: z.string(),
18
19
  });
@@ -21,6 +22,7 @@ export const StepSchema = z.object({
21
22
  /** Null for the closing prune, which belongs to no row. */
22
23
  row: RowRefSchema.nullable(),
23
24
  action: z.enum([
25
+ 'verify-remote',
24
26
  'unlock-worktree',
25
27
  'remove-worktree',
26
28
  'delete-local',
@@ -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;