@osdlc/repo-cleaner 0.11.0 → 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
@@ -26,14 +26,16 @@ import { basename, dirname, join, resolve, sep } from 'node:path';
26
26
  import { isatty } from 'node:tty';
27
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";
30
31
  import { VENDOR_DIRS } from "./devlink.js";
31
32
  import { ensureMirror, mirrorProblem } from "./remote-mirror.js";
32
33
  import { ApplyArgumentError, applyPlan, parseGrant, parseRowRef } from "./apply.js";
33
34
  import { PLAN_VERSION, fileStamp, parsePlan, planRepo, readPlan, writePlan, } from "./plan.js";
34
35
  import { RECEIPT_VERSION } from "./receipt.js";
35
- 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";
36
37
  import { NO_STATUS_LINE, describeStep, describeSweep, statusLine, } from "./status-line.js";
38
+ import { stepBoard } from "./step-board.js";
37
39
  import { sweepRepos } from "./sweep.js";
38
40
  const EXIT_NOTHING_TO_SWEEP = 10;
39
41
  const EXIT_STEP_FAILED = 11;
@@ -49,12 +51,16 @@ const SWEEP_FLAGS = {
49
51
  only: {
50
52
  kind: 'list',
51
53
  valueName: 'list',
52
- 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',
53
59
  },
54
60
  branch: {
55
61
  kind: 'string',
56
62
  valueName: 'name|HEAD',
57
- 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',
58
64
  },
59
65
  remote: {
60
66
  kind: 'string',
@@ -68,11 +74,15 @@ const SWEEP_FLAGS = {
68
74
  commaSeparated: false,
69
75
  help: 'sweep the branches of this remote with no checkout of your own, through a clone kept in the cache directory; repeatable',
70
76
  },
71
- gh: { kind: 'boolean', negatable: true, help: 'classify on git evidence alone' },
77
+ gh: {
78
+ kind: 'boolean',
79
+ negatable: true,
80
+ help: 'with --remotes: classify on git evidence alone (without --remotes gh is never called)',
81
+ },
72
82
  fetch: {
73
83
  kind: 'boolean',
74
84
  negatable: true,
75
- 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)',
76
86
  },
77
87
  progress: {
78
88
  kind: 'boolean',
@@ -96,8 +106,9 @@ const OUTPUT = {
96
106
  };
97
107
  const statusCli = () => defineCli({
98
108
  name: NAME,
99
- summary: 'Inventory branches, worktrees and remote branches, and say which have been merged into ' +
100
- '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.',
101
112
  positionals: PATHS,
102
113
  flags: {
103
114
  ...SWEEP_FLAGS,
@@ -117,7 +128,7 @@ swept as ./<name>.`,
117
128
  });
118
129
  const summaryCli = () => defineCli({
119
130
  name: `${NAME} summary`,
120
- summary: 'Count local branches, worktrees and remote branches per repository: how many there are, ' +
131
+ summary: 'Count local branches and worktrees (and remote branches with --remotes) per repository: how many there are, ' +
121
132
  'how many are merged into the default branch, and how many would be deleted, pruned, reviewed or kept.',
122
133
  positionals: PATHS,
123
134
  flags: { ...SWEEP_FLAGS, output: OUTPUT },
@@ -160,6 +171,13 @@ const applyCli = () => defineCli({
160
171
  commaSeparated: false,
161
172
  help: 'apply only this row, e.g. local_branch:feat/x; repeatable (default: every row)',
162
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
+ },
163
181
  allow: {
164
182
  kind: 'list',
165
183
  valueName: 'risk[=category:id]',
@@ -218,10 +236,31 @@ function failure(ctx, err) {
218
236
  ctx.io.stderr(`${NAME}: ${err instanceof Error ? err.message : String(err)}\n`);
219
237
  return EXIT.error;
220
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
+ }
221
252
  /** Where `--remote-url` keeps its clones. */
222
253
  function mirrorCache(ctx) {
223
254
  return systemDirs(VENDOR_DIRS, { env: ctx.env }).standalone(NAME).cache().app('remotes').path;
224
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
+ }
225
264
  /** Validate the sweep flags and run the sweep, or answer with the exit code. */
226
265
  function sweep(name, v, paths, ctx, status) {
227
266
  const only = v.only?.filter((s) => s !== '');
@@ -261,17 +300,20 @@ function sweep(name, v, paths, ctx, status) {
261
300
  catch (err) {
262
301
  return { kind: 'exit', code: failure(ctx, err) };
263
302
  }
303
+ const scope = urls.length > 0 ? ['remote-branches'] : sweepScope(only, v.remotes);
304
+ const online = scope.includes('remote-branches');
264
305
  const options = {
265
306
  baseDir: ctx.cwd,
266
307
  cwd: ctx.cwd,
267
- noGh: !v.gh,
268
- 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,
269
311
  branch,
270
312
  // Mid-walk, while the wait is happening — the summary comes too late to
271
313
  // explain why the tool appeared to hang.
272
314
  onWarn: (message) => ctx.io.stderr(`${NAME}: ${message}\n`),
273
315
  ...(v.progress ? { onProgress: (p) => status.update(describeSweep(p)) } : {}),
274
- ...(only === undefined ? {} : { scope: only.map((s) => ONLY[s]) }),
316
+ scope,
275
317
  ...(v.remote === undefined ? {} : { remote: v.remote }),
276
318
  };
277
319
  let entries = paths.length > 0 ? [...paths] : [ctx.cwd];
@@ -288,7 +330,6 @@ function sweep(name, v, paths, ctx, status) {
288
330
  status.clear();
289
331
  return { kind: 'exit', code: failure(ctx, err) };
290
332
  }
291
- options.scope = ['remote-branches'];
292
333
  }
293
334
  if (v.progress)
294
335
  status.update(`${NAME}: finding repositories`);
@@ -303,7 +344,7 @@ function sweep(name, v, paths, ctx, status) {
303
344
  return { kind: 'exit', code: failure(ctx, new Error(refused)) };
304
345
  if (result.repos.length === 0)
305
346
  return { kind: 'nothing', result };
306
- return { kind: 'ok', result, branch, values: v };
347
+ return { kind: 'ok', result, branch, values: v, scope, noGh: options.noGh === true };
307
348
  }
308
349
  /**
309
350
  * Name each repository the sweep could not read, on stderr. True when there
@@ -319,13 +360,13 @@ function reportUnreadable(ctx, name, result) {
319
360
  function planOf(swept, now) {
320
361
  const opts = {
321
362
  remote: swept.values.remote ?? null,
322
- noGh: !swept.values.gh,
363
+ noGh: swept.noGh,
323
364
  branch: swept.branch ?? null,
324
365
  };
325
366
  return {
326
367
  version: PLAN_VERSION,
327
368
  created_at: now.toISOString(),
328
- 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)]),
329
370
  };
330
371
  }
331
372
  function runStatus(argv, ctx, status) {
@@ -335,7 +376,8 @@ function runStatus(argv, ctx, status) {
335
376
  if (parsed.status === 'error')
336
377
  return usageError(ctx, NAME, parsed.message);
337
378
  const v = parsed.values;
338
- 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 });
339
381
  const swept = sweep(NAME, v, parsed.positionals, ctx, status);
340
382
  if (swept.kind === 'exit')
341
383
  return swept.code;
@@ -350,6 +392,8 @@ function runStatus(argv, ctx, status) {
350
392
  }
351
393
  else {
352
394
  ctx.io.stdout(render(swept.result));
395
+ if (v.output === 'text')
396
+ ctx.io.stdout(remotesNote(swept, style));
353
397
  }
354
398
  return EXIT.ok;
355
399
  }
@@ -361,11 +405,14 @@ function runSummary(argv, ctx, status) {
361
405
  if (parsed.status === 'error')
362
406
  return usageError(ctx, name, parsed.message);
363
407
  const v = parsed.values;
364
- 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);
365
410
  const swept = sweep(name, v, parsed.positionals, ctx, status);
366
411
  if (swept.kind === 'exit')
367
412
  return swept.code;
368
413
  ctx.io.stdout(render(swept.result));
414
+ if (v.output === 'text')
415
+ ctx.io.stdout(remotesNote(swept, style));
369
416
  return swept.kind === 'nothing' ? EXIT_NOTHING_TO_SWEEP : EXIT.ok;
370
417
  }
371
418
  function runPlan(argv, ctx, status) {
@@ -386,7 +433,8 @@ function runPlan(argv, ctx, status) {
386
433
  }
387
434
  const plan = planOf(swept, new Date());
388
435
  const unreadable = reportUnreadable(ctx, name, swept.result);
389
- 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));
390
438
  if (v.out === '-') {
391
439
  ctx.io.stdout(`${JSON.stringify(plan, null, 2)}\n`);
392
440
  }
@@ -402,6 +450,7 @@ function receiptBeside(planPath, now) {
402
450
  const stem = basename(planPath).replace(/\.json$/i, '');
403
451
  return join(dirname(planPath), `${stem}.receipt-${fileStamp(now)}.json`);
404
452
  }
453
+ const dropKey = (d) => JSON.stringify([d.repo, d.category, d.id]);
405
454
  /** Rows the run would delete or prune: one each, however many steps (an unlock, a remove) it takes. */
406
455
  function wouldRun(receipt) {
407
456
  return rowsActedOn(summarizeReceipt(receipt));
@@ -476,9 +525,11 @@ function runApply(argv, ctx, deps, status) {
476
525
  const v = parsed.values;
477
526
  const paths = parsed.positionals;
478
527
  let rows;
528
+ let keep;
479
529
  let grants;
480
530
  try {
481
531
  rows = v.row.map(parseRowRef);
532
+ keep = v.keep.map(parseRowRef);
482
533
  grants = v.allow.map(parseGrant);
483
534
  }
484
535
  catch (err) {
@@ -507,10 +558,14 @@ function runApply(argv, ctx, deps, status) {
507
558
  }
508
559
  const ok = () => (loaded.unreadable ? EXIT.error : EXIT.ok);
509
560
  const repoRoot = v.repoRoot === undefined ? undefined : resolve(ctx.cwd, v.repoRoot);
510
- 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) => {
511
566
  ctx.io.stdout(v.output === 'json'
512
567
  ? `${JSON.stringify({ receiptPath: shownPath, summary: summarizeReceipt(receipt), receipt }, null, 2)}\n`
513
- : renderReceipt(receipt, shownPath));
568
+ : renderReceipt(receipt, shownPath, style, { steps }));
514
569
  };
515
570
  const planned = plan.repos.filter((r) => repoRoot === undefined || resolve(r.root) === resolve(repoRoot));
516
571
  if (repoRoot !== undefined && planned.length === 0) {
@@ -527,6 +582,7 @@ function runApply(argv, ctx, deps, status) {
527
582
  callerCwd: ctx.cwd,
528
583
  planPath: source,
529
584
  repoRoot,
585
+ keep,
530
586
  // An implicit selection runs only safe-to-delete rows, in the preview and
531
587
  // in the run it confirms, whose rows are passed explicitly.
532
588
  safeOnly: rows.length === 0,
@@ -548,11 +604,16 @@ function runApply(argv, ctx, deps, status) {
548
604
  });
549
605
  }
550
606
  catch (err) {
607
+ if (err instanceof ApplyArgumentError)
608
+ return usageError(ctx, name, err.message);
551
609
  return failure(ctx, err);
552
610
  }
553
611
  const count = wouldRun(preview);
554
612
  if (v.dryRun || count === 0) {
555
- print(preview, null);
613
+ if (live)
614
+ ctx.io.stdout(renderPreview(preview, style));
615
+ else
616
+ print(preview, null);
556
617
  return ok();
557
618
  }
558
619
  if (!v.yes) {
@@ -560,10 +621,22 @@ function runApply(argv, ctx, deps, status) {
560
621
  if (declined !== null)
561
622
  return declined;
562
623
  }
624
+ else if (live) {
625
+ ctx.io.stdout(renderPreview(preview, style));
626
+ }
627
+ if (live)
628
+ ctx.io.stdout('\n');
563
629
  // Only what the preview showed: rows it dropped stay dropped.
564
630
  // Pinned to their repositories: a branch name two repositories share stays two rows.
565
- 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 }]);
566
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;
567
640
  let receipt;
568
641
  try {
569
642
  receipt = applyPlan(plan, approved, approvedGrants, {
@@ -571,12 +644,40 @@ function runApply(argv, ctx, deps, status) {
571
644
  dryRun: false,
572
645
  receiptPath,
573
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
+ : {}),
574
665
  });
575
666
  }
576
667
  catch (err) {
577
668
  return failure(ctx, err);
578
669
  }
579
- 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);
580
681
  if (receiptPath === null) {
581
682
  ctx.io.stderr(`${name}: no receipt written; pass --receipt <file> to keep one\n`);
582
683
  }
@@ -589,7 +690,7 @@ function runApply(argv, ctx, deps, status) {
589
690
  function confirm(ctx, name, preview, count, json, deps) {
590
691
  // The preview is for the person answering, so it goes where the question does.
591
692
  const show = json ? ctx.io.stderr : ctx.io.stdout;
592
- show(renderReceipt(preview, null));
693
+ show(renderPreview(preview, styleFor(ctx, json ? 'stderr' : 'stdout')));
593
694
  const answer = (deps.confirm ?? terminalConfirm)(`Delete ${count} item${count === 1 ? '' : 's'}?`);
594
695
  if (answer === null) {
595
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',
package/dist/render.d.ts CHANGED
@@ -6,8 +6,31 @@
6
6
  * find. JSON carries everything the text report elides, including the facts
7
7
  * behind every verdict.
8
8
  */
9
- import type { Receipt } from './receipt.ts';
9
+ import type { Receipt, Step } from './receipt.ts';
10
10
  import { type RepoSweep, type SweepItem, type SweepResult } from './types.ts';
11
+ /**
12
+ * How a report colors its words. {@link PLAIN} leaves them as they are; the
13
+ * CLI passes {@link TERMINAL_STYLE} when the stream is a terminal that takes
14
+ * color.
15
+ */
16
+ export interface Style {
17
+ /** What a run deletes. */
18
+ delete(text: string): string;
19
+ /** A stale link a run prunes. */
20
+ prune(text: string): string;
21
+ /** What a person has to look at: a review row, a dropped row. */
22
+ review(text: string): string;
23
+ /** What stays, or a step that succeeded. */
24
+ keep(text: string): string;
25
+ /** Detail under a row: commands, the receipt path. */
26
+ muted(text: string): string;
27
+ /** A heading or the line a person is asked to approve. */
28
+ strong(text: string): string;
29
+ }
30
+ /** No color. */
31
+ export declare const PLAIN: Style;
32
+ /** The basic terminal colors: red to delete, blue to prune, yellow to review, green to keep. */
33
+ export declare const TERMINAL_STYLE: Style;
11
34
  /**
12
35
  * A row's authorizations for a report, e.g. `requires abandon-unmerged (drops 3
13
36
  * commits), confirm-open-pr`. Empty when the row needs none.
@@ -16,6 +39,7 @@ export declare function requiresNote(item: Pick<SweepItem, 'requires' | 'facts'>
16
39
  /** One repository's rows, indented under its header. */
17
40
  export declare function renderRepo(sweep: RepoSweep, opts?: {
18
41
  keep?: boolean;
42
+ style?: Style;
19
43
  }): string;
20
44
  /**
21
45
  * What the search cost, or null when nothing was searched — every entry
@@ -27,19 +51,20 @@ export declare function renderRepo(sweep: RepoSweep, opts?: {
27
51
  */
28
52
  export declare function renderScan(result: SweepResult): string | null;
29
53
  /** The one-line verdict a person reads first, and often only. */
30
- export declare function renderSummary(result: SweepResult): string;
54
+ export declare function renderSummary(result: SweepResult, style?: Style): string;
31
55
  /**
32
56
  * One repository as counts: a row per category with its total, how many are
33
57
  * merged, and how many the sweep would delete, prune, review or keep.
34
58
  */
35
59
  export declare function renderCounts(sweep: RepoSweep): string;
36
60
  /** The whole run as counts: a table per repository, then the one-line summary. */
37
- export declare function renderCountsText(result: SweepResult): string;
61
+ export declare function renderCountsText(result: SweepResult, style?: Style): string;
38
62
  /** The whole run's counts as JSON: no rows, no facts. */
39
63
  export declare function renderCountsJson(result: SweepResult): string;
40
64
  /** The whole run as text: duplicate checkouts, a section per repository, then the summary. */
41
65
  export declare function renderText(result: SweepResult, opts?: {
42
66
  keep?: boolean;
67
+ style?: Style;
43
68
  }): string;
44
69
  /**
45
70
  * The whole run as JSON, facts included. `extra` adds top-level keys, such as
@@ -71,5 +96,23 @@ export declare function summarizeReceipt(receipt: Receipt): ApplySummary;
71
96
  export declare function rowsActedOn(summary: ApplySummary): number;
72
97
  /** The closing lines of an apply run: what it deleted, dropped and could not finish. */
73
98
  export declare function renderApplySummary(receipt: Receipt): string[];
99
+ /** The word a preview shows for a step: what it does to the row. */
100
+ export declare const STEP_VERB: Record<Step['action'], string>;
101
+ /**
102
+ * What `apply` is about to do, for the person asked to approve it: per
103
+ * repository, each row and the command that deletes it, the rows it will
104
+ * skip and why, then the counts. Terminal controls are stripped from what it
105
+ * shows, so a path or name cannot hide part of what is approved.
106
+ */
107
+ export declare function renderPreview(receipt: Receipt, style?: Style): string;
108
+ /** Rows dropped by a live run's own re-check, after the preview: each with why. */
109
+ export declare function renderPreviewDrops(dropped: Receipt['dropped'], style?: Style): string;
110
+ /**
111
+ * A failed step for a run shown live: `✗`, the row, and the error in full,
112
+ * which the step's row on the board is too narrow to hold.
113
+ */
114
+ export declare function renderStepFailure(step: Step, style?: Style): string;
74
115
  /** An apply run as text: dropped rows, each step, a summary, then where the receipt is. */
75
- export declare function renderReceipt(receipt: Receipt, receiptPath: string | null): string;
116
+ export declare function renderReceipt(receipt: Receipt, receiptPath: string | null, style?: Style, opts?: {
117
+ steps?: boolean;
118
+ }): string;