@osdlc/repo-cleaner 0.17.2 → 0.18.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/render.js CHANGED
@@ -1,23 +1,18 @@
1
1
  /**
2
- * Rendering a sweep for a terminal, and for a program.
2
+ * Rendering a sweep and a run, for a terminal and for a program.
3
3
  *
4
4
  * The text report is columnar because it is read by eye across repositories:
5
- * the eye finds a column, and a line-per-item paragraph gives it nothing to
6
- * find. JSON carries everything the text report elides, including the facts
7
- * behind every verdict.
5
+ * the eye finds a column, and a line-per-row paragraph gives it nothing to
6
+ * find. JSON carries everything the text report leaves out, the evidence
7
+ * behind every verdict included.
8
8
  */
9
9
  import { ansiColors, paint, stripControls } from '@osdlc/ux';
10
- import { emptyBucket, isPrune, isUnfetched, summarize, } from "./types.js";
11
- import { hasBlockers } from "./worktrees.js";
12
- /** Order categories consistently, whatever order they were gathered in. */
13
- const CATEGORY_ORDER = ['local_branch', 'worktree', 'remote_branch'];
10
+ import { KINDS, isPrune, isUnfetched, parseRowId, totalOf, } from "./types.js";
11
+ import { plural } from "./util.js";
12
+ /** Branches first, then worktrees, then remotes, whatever order they were gathered in. */
13
+ const KIND_ORDER = ['branch', 'worktree', 'remote'];
14
14
  /** Delete first: it is what a reader is looking for. */
15
- const CLASSIFICATION_ORDER = ['safe-to-delete', 'needs-investigation', 'safe-to-keep'];
16
- const ACTION_LABEL = {
17
- 'safe-to-delete': 'delete',
18
- 'needs-investigation': 'review',
19
- 'safe-to-keep': 'keep',
20
- };
15
+ const VERDICT_ORDER = ['delete', 'review', 'blocked', 'keep'];
21
16
  const same = (text) => text;
22
17
  /** No color. */
23
18
  export const PLAIN = {
@@ -47,22 +42,22 @@ function ellipsize(s, max) {
47
42
  return s.length <= max ? s : '…' + s.slice(s.length - (max - 1));
48
43
  }
49
44
  /**
50
- * A row's authorizations for a report, e.g. `requires abandon-unmerged (drops 3
51
- * commits), confirm-open-pr`. Empty when the row needs none.
45
+ * A row's risks for a report, e.g. `needs --allow abandon-unmerged (drops 3
46
+ * commits), confirm-open-pr`. Empty when the row carries none.
52
47
  */
53
- export function requiresNote(item) {
54
- if (item.requires.length === 0)
48
+ function risksNote(row) {
49
+ if (row.risks.length === 0)
55
50
  return '';
56
- const count = item.facts['unique_commits'];
57
- const names = item.requires.map((name) => name === 'abandon-unmerged' && typeof count === 'number'
51
+ const count = row.evidence.uniqueCommits;
52
+ const names = row.risks.map((name) => name === 'abandon-unmerged' && typeof count === 'number'
58
53
  ? `${name} (drops ${count} commit${count === 1 ? '' : 's'})`
59
54
  : name);
60
- return `requires ${names.join(', ')}`;
55
+ return `needs --allow ${names.join(', ')}`;
61
56
  }
62
- function sortItems(items) {
63
- const categoryRank = (item) => CATEGORY_ORDER.indexOf(item.category);
64
- const classRank = (item) => CLASSIFICATION_ORDER.indexOf(item.classification);
65
- return [...items].sort((a, b) => categoryRank(a) - categoryRank(b) || classRank(a) - classRank(b) || a.id.localeCompare(b.id));
57
+ function sortRows(rows) {
58
+ const kindRank = (r) => KIND_ORDER.indexOf(r.kind);
59
+ const verdictRank = (r) => VERDICT_ORDER.indexOf(r.verdict);
60
+ return [...rows].sort((a, b) => kindRank(a) - kindRank(b) || verdictRank(a) - verdictRank(b) || a.name.localeCompare(b.name));
66
61
  }
67
62
  function repoHeader(sweep) {
68
63
  const name = sweep.repo?.root ?? sweep.path;
@@ -76,17 +71,21 @@ function repoHeader(sweep) {
76
71
  ];
77
72
  return `${name} (${bits.filter((b) => b !== null).join(', ')})`;
78
73
  }
79
- /** The color of a row's action word. */
80
- function actionStyle(style, action) {
81
- if (action === 'delete')
74
+ /** The word a report shows for a row: its verdict, or `prune` for a `delete` that only prunes. */
75
+ function verdictWord(row) {
76
+ return isPrune(row) ? 'prune' : row.verdict;
77
+ }
78
+ /** The color of a verdict word. */
79
+ function wordStyle(style, word) {
80
+ if (word === 'delete')
82
81
  return style.delete;
83
- if (action === 'prune')
82
+ if (word === 'prune')
84
83
  return style.prune;
85
- if (action === 'review')
84
+ if (word === 'review' || word === 'blocked')
86
85
  return style.review;
87
86
  return style.keep;
88
87
  }
89
- /** One repository's rows, indented under its header. */
88
+ /** One repository's rows, indented under its header. `all` shows `keep` rows too. */
90
89
  export function renderRepo(sweep, opts = {}) {
91
90
  const style = opts.style ?? PLAIN;
92
91
  const lines = [style.strong(repoHeader(sweep))];
@@ -94,148 +93,119 @@ export function renderRepo(sweep, opts = {}) {
94
93
  lines.push(` ${style.delete('error')}: ${sweep.error}`);
95
94
  return lines.join('\n');
96
95
  }
97
- const shown = sortItems(sweep.items).filter((item) => opts.keep === true || item.classification !== 'safe-to-keep');
96
+ const shown = sortRows(sweep.rows).filter((r) => opts.all === true || r.verdict !== 'keep');
98
97
  if (shown.length === 0) {
99
98
  lines.push(' nothing to clean up');
100
99
  return lines.join('\n');
101
100
  }
102
- const idWidth = Math.min(38, Math.max(...shown.map((item) => ellipsize(item.id, 38).length)));
103
- const categoryWidth = Math.max(...shown.map((item) => item.category.length));
104
- for (const item of shown) {
105
- const action = isPrune(item)
106
- ? 'prune'
107
- : (ACTION_LABEL[item.classification] ?? item.classification);
108
- const note = requiresNote(item);
109
- lines.push(` ${pad(item.category, categoryWidth)} ${pad(ellipsize(item.id, 38), idWidth)} ${actionStyle(style, action)(pad(action, 6))} ${item.reason}${note ? `; ${note}` : ''}`);
101
+ const nameWidth = Math.min(38, Math.max(...shown.map((r) => ellipsize(r.name, 38).length)));
102
+ const kindWidth = Math.max(...shown.map((r) => r.kind.length));
103
+ for (const row of shown) {
104
+ const word = verdictWord(row);
105
+ const note = risksNote(row);
106
+ lines.push(` ${pad(row.kind, kindWidth)} ${pad(ellipsize(row.name, 38), nameWidth)} ${wordStyle(style, word)(pad(word, 7))} ${row.why}${note ? `; ${note}` : ''}`);
110
107
  // A blocked row has no commands; what to do about each blocker goes there instead.
111
- const detail = hasBlockers(item) ? item.investigationParts : item.commands;
108
+ const detail = row.verdict === 'blocked' ? row.notes : row.commands;
112
109
  for (const line of detail)
113
- lines.push(` ${' '.repeat(categoryWidth)} ${style.muted(line)}`);
110
+ lines.push(` ${' '.repeat(kindWidth)} ${style.muted(line)}`);
114
111
  }
115
112
  return lines.join('\n');
116
113
  }
117
- function totals(result) {
118
- const out = emptyBucket();
119
- for (const sweep of result.repos) {
120
- for (const bucket of Object.values(summarize(sweep.items))) {
121
- out.total += bucket.total;
122
- out.merged += bucket.merged;
123
- out['safe-to-delete'] += bucket['safe-to-delete'];
124
- out.prune += bucket.prune;
125
- out['safe-to-keep'] += bucket['safe-to-keep'];
126
- out['needs-investigation'] += bucket['needs-investigation'];
127
- }
128
- }
129
- return out;
130
- }
131
114
  /** Thousands separators, fixed to one locale so the output does not move. */
132
115
  function count(n) {
133
116
  return n.toLocaleString('en-US');
134
117
  }
118
+ /** `1 target`, `1,204 directories`. */
119
+ const counted = (n, one, many) => `${count(n)} ${n === 1 ? one : many}`;
135
120
  /**
136
- * What the search cost, or null when nothing was searched — every entry
121
+ * What the search cost, or null when nothing was searched — every target
137
122
  * named a checkout outright.
138
123
  *
139
- * Worth a line because the numbers are how a person catches an entry
124
+ * Worth a line because the numbers are how a person catches a target
140
125
  * pointing at the wrong directory: thousands of directories read for three
141
- * repositories says the entry is a home directory, not a workspace.
126
+ * repositories says the target is a home directory, not a workspace.
142
127
  */
143
- export function renderScan(result) {
144
- const { scan } = result;
128
+ export function renderScan(sweep) {
129
+ const { scan } = sweep;
145
130
  if (scan.containers === 0)
146
131
  return null;
147
- const entries = `${scan.containers} ${scan.containers === 1 ? 'entry' : 'entries'}`;
148
- const dirs = `${count(scan.directoriesScanned)} ${scan.directoriesScanned === 1 ? 'directory' : 'directories'}`;
149
- const found = `${count(scan.checkoutsFound)} ${scan.checkoutsFound === 1 ? 'checkout' : 'checkouts'}`;
132
+ const entries = counted(scan.containers, 'target', 'targets');
133
+ const dirs = counted(scan.directoriesScanned, 'directory', 'directories');
134
+ const found = counted(scan.checkoutsFound, 'checkout', 'checkouts');
150
135
  const line = `searched ${entries}: ${dirs} read, ${found} found.`;
151
136
  if (scan.depthLimited === 0)
152
137
  return line;
153
138
  return `${line} ${count(scan.depthLimited)} left unopened at the depth limit.`;
154
139
  }
155
- /** The `safe-to-delete` rows that delete something, not the ones that only prune a stale link. */
156
- function deleteOnly(bucket) {
157
- return bucket['safe-to-delete'] - bucket.prune;
158
- }
159
140
  /** The one-line verdict a person reads first, and often only. */
160
- export function renderSummary(result, style = PLAIN) {
161
- const counts = totals(result);
162
- const repos = `${result.repos.length} ${result.repos.length === 1 ? 'repository' : 'repositories'}`;
163
- const errors = result.repos.filter((sweep) => sweep.error !== null).length;
141
+ export function renderTotals(sweep, style = PLAIN) {
142
+ const t = totalOf(sweep.counts);
143
+ const repos = counted(sweep.repos.length, 'repository', 'repositories');
144
+ const errors = sweep.repos.filter((r) => r.error !== null).length;
164
145
  const tail = [];
165
146
  if (errors > 0)
166
147
  tail.push(`${errors} unreadable`);
167
- if (result.unresolved.length > 0) {
168
- tail.push(`${result.unresolved.length} ${result.unresolved.length === 1 ? 'entry' : 'entries'} unresolved`);
148
+ if (sweep.unresolved.length > 0) {
149
+ tail.push(`${counted(sweep.unresolved.length, 'target', 'targets')} unresolved`);
169
150
  }
170
151
  const suffix = tail.length > 0 ? ` (${tail.join(', ')})` : '';
171
- const prune = counts.prune > 0 ? `${style.prune(`${counts.prune} to prune`)}, ` : '';
172
- const del = style.delete(`${deleteOnly(counts)} to delete`);
173
- const review = style.review(`${counts['needs-investigation']} to review`);
174
- return `${del}, ${prune}${review}, across ${repos}${suffix}.`;
152
+ const prune = t.prune > 0 ? `${style.prune(`${t.prune} to prune`)}, ` : '';
153
+ const blocked = t.blocked > 0 ? `, ${style.review(`${t.blocked} blocked`)}` : '';
154
+ return `${style.delete(`${t.delete} to delete`)}, ${prune}${style.review(`${t.review} to review`)}${blocked}, across ${repos}${suffix}.`;
175
155
  }
176
- const COUNT_LABEL = {
177
- local_branch: 'local branches',
156
+ const KIND_LABEL = {
157
+ branch: 'local branches',
178
158
  worktree: 'worktrees',
179
- remote_branch: 'remote branches',
159
+ remote: 'remote branches',
180
160
  };
181
- /** A count table's columns. `delete` leaves out the rows `prune` counts, so each row is in one of delete, prune, review and keep. */
182
161
  const COUNT_COLUMNS = [
183
- { heading: 'total', of: (b) => b.total },
184
- { heading: 'merged', of: (b) => b.merged },
185
- { heading: 'delete', of: deleteOnly },
186
- { heading: 'prune', of: (b) => b.prune },
187
- { heading: 'review', of: (b) => b['needs-investigation'] },
188
- { heading: 'keep', of: (b) => b['safe-to-keep'] },
162
+ 'total',
163
+ 'merged',
164
+ 'delete',
165
+ 'prune',
166
+ 'review',
167
+ 'blocked',
168
+ 'keep',
189
169
  ];
190
- /**
191
- * One repository as counts: a row per category with its total, how many are
192
- * merged, and how many the sweep would delete, prune, review or keep.
193
- */
194
- export function renderCounts(sweep) {
195
- const lines = [repoHeader(sweep)];
196
- if (sweep.error !== null) {
197
- lines.push(` error: ${sweep.error}`);
198
- return lines.join('\n');
199
- }
200
- const buckets = summarize(sweep.items);
201
- const labelWidth = Math.max(...Object.values(COUNT_LABEL).map((l) => l.length));
202
- const cell = (text, heading) => text.padStart(Math.max(heading.length, 5));
203
- lines.push(` ${' '.repeat(labelWidth)} ${COUNT_COLUMNS.map(({ heading }) => cell(heading, heading)).join(' ')}`);
204
- for (const category of CATEGORY_ORDER) {
205
- const bucket = buckets[category] ?? emptyBucket();
206
- const cells = COUNT_COLUMNS.map(({ heading, of }) => cell(count(of(bucket)), heading));
207
- lines.push(` ${pad(COUNT_LABEL[category], labelWidth)} ${cells.join(' ')}`);
208
- }
170
+ function renderUnresolved(sweep) {
171
+ if (sweep.unresolved.length === 0)
172
+ return null;
173
+ const lines = ['unresolved'];
174
+ const width = Math.max(...sweep.unresolved.map((row) => row.entry.length));
175
+ for (const row of sweep.unresolved)
176
+ lines.push(` ${pad(row.entry, width)} ${row.reason}`);
209
177
  return lines.join('\n');
210
178
  }
211
- /** The whole run as counts: a table per repository, then the one-line summary. */
212
- export function renderCountsText(result, style = PLAIN) {
213
- const blocks = result.repos.map(renderCounts);
214
- if (result.unresolved.length > 0) {
215
- const lines = ['unresolved'];
216
- const width = Math.max(...result.unresolved.map((row) => row.entry.length));
217
- for (const row of result.unresolved)
218
- lines.push(` ${pad(row.entry, width)} ${row.reason}`);
219
- blocks.push(lines.join('\n'));
220
- }
221
- const scan = renderScan(result);
222
- const summary = renderSummary(result, style);
223
- blocks.push(scan === null ? summary : `${scan}\n${summary}`);
224
- return blocks.join('\n\n') + '\n';
179
+ /** `blocks`, then the unresolved targets, what the search cost and the totals, as one report. */
180
+ function report(blocks, sweep, style) {
181
+ const unresolved = renderUnresolved(sweep);
182
+ const scan = renderScan(sweep);
183
+ const totals = renderTotals(sweep, style);
184
+ return ([
185
+ ...blocks,
186
+ ...(unresolved === null ? [] : [unresolved]),
187
+ scan === null ? totals : `${scan}\n${totals}`,
188
+ ].join('\n\n') + '\n');
225
189
  }
226
- /** The whole run's counts as JSON: no rows, no facts. */
227
- export function renderCountsJson(result) {
228
- return (JSON.stringify({
229
- repos: result.repos.map((sweep) => ({
230
- path: sweep.path,
231
- error: sweep.error,
232
- repo: sweep.repo,
233
- summary: summarize(sweep.items),
234
- })),
235
- unresolved: result.unresolved,
236
- scan: result.scan,
237
- summary: totals(result),
238
- }, null, 2) + '\n');
190
+ /** The sweep as counts (`--format summary`): a table per repository, then the totals line. */
191
+ export function renderSummary(sweep, style = PLAIN) {
192
+ const blocks = sweep.repos.map((r) => {
193
+ const lines = [repoHeader(r)];
194
+ if (r.error !== null) {
195
+ lines.push(` error: ${r.error}`);
196
+ return lines.join('\n');
197
+ }
198
+ const labelWidth = Math.max(...Object.values(KIND_LABEL).map((l) => l.length));
199
+ const cell = (text, heading) => text.padStart(Math.max(heading.length, 5));
200
+ lines.push(` ${' '.repeat(labelWidth)} ${COUNT_COLUMNS.map((h) => cell(h, h)).join(' ')}`);
201
+ for (const kind of KIND_ORDER) {
202
+ const bucket = r.counts[kind];
203
+ const cells = COUNT_COLUMNS.map((h) => cell(count(bucket[h]), h));
204
+ lines.push(` ${pad(KIND_LABEL[kind], labelWidth)} ${cells.join(' ')}`);
205
+ }
206
+ return lines.join('\n');
207
+ });
208
+ return report(blocks, sweep, style);
239
209
  }
240
210
  /** The "Duplicate checkouts" section, or null when there are none. */
241
211
  function renderDuplicates(duplicates) {
@@ -249,83 +219,53 @@ function renderDuplicates(duplicates) {
249
219
  }
250
220
  return lines.join('\n');
251
221
  }
252
- /** The whole run as text: duplicate checkouts, a section per repository, then the summary. */
253
- export function renderText(result, opts = {}) {
254
- const duplicates = renderDuplicates(result.duplicates);
222
+ /** The sweep as text: duplicate checkouts, a section per repository, then the totals. */
223
+ export function renderText(sweep, opts = {}) {
224
+ const duplicates = renderDuplicates(sweep.duplicates);
255
225
  const blocks = [
256
226
  ...(duplicates === null ? [] : [duplicates]),
257
- ...result.repos.map((sweep) => renderRepo(sweep, opts)),
227
+ ...sweep.repos.map((r) => renderRepo(r, opts)),
258
228
  ];
259
- if (result.unresolved.length > 0) {
260
- const lines = ['unresolved'];
261
- const width = Math.max(...result.unresolved.map((row) => row.entry.length));
262
- for (const row of result.unresolved)
263
- lines.push(` ${pad(row.entry, width)} ${row.reason}`);
264
- blocks.push(lines.join('\n'));
265
- }
266
- const scan = renderScan(result);
267
- const summary = renderSummary(result, opts.style);
268
- blocks.push(scan === null ? summary : `${scan}\n${summary}`);
269
- return blocks.join('\n\n') + '\n';
229
+ return report(blocks, sweep, opts.style ?? PLAIN);
270
230
  }
271
- /**
272
- * The whole run as JSON, facts included. `extra` adds top-level keys, such as
273
- * the path of a plan the run wrote.
274
- */
275
- export function renderJson(result, extra = {}) {
231
+ /** The sweep as JSON: every row with its evidence, the counts, and what was swept. */
232
+ export function renderJson(sweep, extra = {}) {
276
233
  return (JSON.stringify({
277
234
  ...extra,
278
- repos: result.repos.map((sweep) => ({
279
- path: sweep.path,
280
- error: sweep.error,
281
- repo: sweep.repo,
282
- summary: summarize(sweep.items),
283
- items: sweep.items.map((item) => ({
284
- category: item.category,
285
- id: item.id,
286
- classification: item.classification,
287
- action: item.action,
288
- reason: item.reason,
289
- facts: item.facts,
290
- commands: item.commands,
291
- requires: item.requires,
292
- deletable: item.deletable,
293
- investigation_prompt: item.investigationParts.length > 0 ? item.investigationParts.join(' ') : null,
294
- })),
295
- })),
296
- unresolved: result.unresolved,
297
- scan: result.scan,
298
- duplicates: result.duplicates,
299
- summary: totals(result),
235
+ options: sweep.options,
236
+ repos: sweep.repos,
237
+ unresolved: sweep.unresolved,
238
+ scan: sweep.scan,
239
+ duplicates: sweep.duplicates,
240
+ counts: sweep.counts,
241
+ totals: totalOf(sweep.counts),
300
242
  }, null, 2) + '\n');
301
243
  }
302
- /** Every command a `safe-to-delete` row that needs no authorization proposes, in report order. */
303
- export function proposedCommands(result) {
244
+ /** Every command of a `delete` row that carries no risk, in report order (`--format commands`). */
245
+ export function renderCommands(sweep) {
304
246
  const out = [];
305
- for (const sweep of result.repos) {
306
- for (const item of sortItems(sweep.items)) {
307
- if (item.classification === 'safe-to-delete' && item.requires.length === 0)
308
- out.push(...item.commands);
247
+ for (const r of sweep.repos) {
248
+ for (const row of sortRows(r.rows)) {
249
+ if (row.verdict === 'delete' && row.risks.length === 0)
250
+ out.push(...row.commands);
309
251
  }
310
252
  }
311
253
  return out;
312
254
  }
313
- /** Count a receipt's outcome. A dry run's `would-run` steps count as done. */
314
- export function summarizeReceipt(receipt) {
255
+ /** Count what `steps` did and `dropped` left out. A preview's `would-run` steps count as done. */
256
+ export function summarizeRun(r) {
315
257
  const out = {
316
- deleted: { worktree: 0, local_branch: 0, remote_branch: 0 },
258
+ deleted: { worktree: 0, branch: 0, remote: 0 },
317
259
  pruned: 0,
318
260
  dropped: {},
319
261
  failed: 0,
320
262
  notRun: 0,
321
263
  };
322
- for (const d of receipt.dropped)
264
+ for (const d of r.dropped)
323
265
  out.dropped[d.reason] = (out.dropped[d.reason] ?? 0) + 1;
324
- for (const s of receipt.steps) {
266
+ for (const s of r.steps) {
325
267
  if (s.result === 'failed')
326
268
  out.failed += 1;
327
- if (s.row === null)
328
- continue;
329
269
  if (s.result === 'pending') {
330
270
  // Rows not reached are rows a run would have deleted; an unlock or a
331
271
  // tip check deletes nothing.
@@ -339,69 +279,59 @@ export function summarizeReceipt(receipt) {
339
279
  out.deleted.worktree += 1;
340
280
  else if (s.action === 'prune-worktrees')
341
281
  out.pruned += 1;
342
- else if (s.action === 'delete-local')
343
- out.deleted.local_branch += 1;
282
+ else if (s.action === 'delete-branch')
283
+ out.deleted.branch += 1;
344
284
  else if (s.action === 'delete-remote')
345
- out.deleted.remote_branch += 1;
285
+ out.deleted.remote += 1;
346
286
  }
347
287
  return out;
348
288
  }
349
- /** Rows a receipt deletes or prunes, which is what a person is asked to approve. */
289
+ /** Rows a run deletes or prunes, which is what a person is asked to approve. */
350
290
  export function rowsActedOn(summary) {
351
- const d = summary.deleted;
352
- return d.worktree + d.local_branch + d.remote_branch + summary.pruned;
291
+ return KINDS.reduce((n, k) => n + summary.deleted[k], 0) + summary.pruned;
353
292
  }
354
- const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
355
293
  /**
356
- * How to include a skipped row next time, for the reasons a person can act
357
- * on. A `not-authorized` detail is `needs <risk>, <risk>` (see `recheck`).
294
+ * How to include a dropped row next time, for the reasons a person can act
295
+ * on. A `not-allowed` detail is `needs <risk>, <risk>`.
358
296
  */
359
- function dropHints(receipt) {
297
+ function dropHints(dropped) {
360
298
  const risks = new Set();
361
- for (const d of receipt.dropped) {
362
- if (d.reason !== 'not-authorized')
299
+ for (const d of dropped) {
300
+ if (d.reason !== 'not-allowed')
363
301
  continue;
364
302
  for (const risk of d.detail.replace(/^needs /, '').split(', '))
365
303
  if (risk)
366
304
  risks.add(risk);
367
305
  }
368
306
  return {
369
- 'needs-review': 'name with --row',
370
- ...(risks.size > 0
371
- ? { 'not-authorized': [...risks].map((r) => `--allow ${r}`).join(', ') }
372
- : {}),
307
+ 'needs-pick': 'name with --pick',
308
+ ...(risks.size > 0 ? { 'not-allowed': [...risks].map((r) => `--allow ${r}`).join(', ') } : {}),
373
309
  };
374
310
  }
375
- /** The closing lines of an apply run: what it deleted, skipped and could not finish. */
376
- export function renderApplySummary(receipt) {
377
- const s = summarizeReceipt(receipt);
378
- const dry = receipt.status === 'dry-run';
311
+ /** The closing lines of a run or preview: what it deleted, skipped and could not finish. */
312
+ export function renderRunSummary(r, opts = {}) {
313
+ const s = summarizeRun(r);
379
314
  const parts = [
380
315
  s.deleted.worktree > 0 ? plural(s.deleted.worktree, 'worktree', 'worktrees') : null,
381
- s.deleted.local_branch > 0
382
- ? plural(s.deleted.local_branch, 'local branch', 'local branches')
383
- : null,
384
- s.deleted.remote_branch > 0
385
- ? plural(s.deleted.remote_branch, 'remote branch', 'remote branches')
386
- : null,
316
+ s.deleted.branch > 0 ? plural(s.deleted.branch, 'local branch', 'local branches') : null,
317
+ s.deleted.remote > 0 ? plural(s.deleted.remote, 'remote branch', 'remote branches') : null,
387
318
  s.pruned > 0 ? `${plural(s.pruned, 'stale worktree', 'stale worktrees')} pruned` : null,
388
319
  ].filter((p) => p !== null);
389
320
  const lines = [
390
- `${dry ? 'would delete' : 'deleted'}: ${parts.length > 0 ? parts.join(', ') : 'nothing'}`,
321
+ `${opts.preview === true ? 'would delete' : 'deleted'}: ${parts.length > 0 ? parts.join(', ') : 'nothing'}`,
391
322
  ];
392
323
  const dropped = Object.entries(s.dropped);
393
324
  if (dropped.length > 0) {
394
325
  const total = dropped.reduce((n, [, c]) => n + c, 0);
395
- const hints = dropHints(receipt);
326
+ const hints = dropHints(r.dropped);
396
327
  const counts = dropped.map(([reason, c]) => {
397
328
  const hint = hints[reason];
398
329
  return hint === undefined ? `${c} ${reason}` : `${c} ${reason} (${hint})`;
399
330
  });
400
331
  lines.push(`skipped ${total}, not deleted: ${counts.join(', ')}`);
401
332
  }
402
- if (s.failed > 0) {
333
+ if (s.failed > 0)
403
334
  lines.push(`stopped at a failed step; ${plural(s.notRun, 'row', 'rows')} not run`);
404
- }
405
335
  return lines;
406
336
  }
407
337
  /** The word a preview shows for a step: what it does to the row. */
@@ -409,7 +339,7 @@ export const STEP_VERB = {
409
339
  'verify-remote': 'verify',
410
340
  'unlock-worktree': 'unlock',
411
341
  'remove-worktree': 'delete',
412
- 'delete-local': 'delete',
342
+ 'delete-branch': 'delete',
413
343
  'delete-remote': 'delete',
414
344
  'prune-worktrees': 'prune',
415
345
  };
@@ -425,77 +355,88 @@ function resultWord(step, style) {
425
355
  return step.action === 'prune-worktrees' ? style.prune(word) : style.delete(word);
426
356
  }
427
357
  /**
428
- * What `apply` is about to do, for the person asked to approve it: per
429
- * repository, each row and the command that deletes it, the rows it will
430
- * skip and why, then the counts. Terminal controls are stripped from what it
431
- * shows, so a path or name cannot hide part of what is approved.
358
+ * A row id for a preview line, its name shortened from the left to fit; the
359
+ * kind in front always stays, so a long path still says what kind of row it is.
432
360
  */
433
- export function renderPreview(receipt, style = PLAIN) {
434
- const repos = [
435
- ...new Set([...receipt.steps.map((s) => s.repo), ...receipt.dropped.map((d) => d.repo)]),
436
- ];
437
- const rows = [
438
- ...receipt.steps.flatMap((s) => (s.row === null ? [] : [s.row])),
439
- ...receipt.dropped,
440
- ];
441
- const categoryWidth = Math.max(0, ...rows.map((r) => r.category.length));
442
- const row = (r) => r === null ? '' : `${pad(r.category, categoryWidth)} ${ellipsize(stripControls(r.id), 60)}`;
361
+ function shownRow(id) {
362
+ const parsed = parseRowId(id);
363
+ if (parsed === null)
364
+ return ellipsize(stripControls(id), 70);
365
+ return `${parsed.kind}:${ellipsize(stripControls(parsed.name), 70 - parsed.kind.length - 1)}`;
366
+ }
367
+ /**
368
+ * What a run is about to do, for the person asked to approve it: per
369
+ * repository, each row and the command that deletes it, the rows it will skip
370
+ * and why, the allows no row needed, then the counts. Terminal controls are
371
+ * stripped from what it shows, so a path or name cannot hide part of what is
372
+ * approved.
373
+ */
374
+ export function renderPreview(p, style = PLAIN) {
375
+ const repos = [...new Set([...p.steps.map((s) => s.repo), ...p.dropped.map((d) => d.repo)])];
443
376
  const blocks = [];
444
377
  for (const repo of repos) {
445
378
  const lines = [style.strong(stripControls(repo))];
446
- for (const s of receipt.steps.filter((s) => s.repo === repo)) {
379
+ for (const s of p.steps.filter((s) => s.repo === repo)) {
447
380
  const verb = STEP_VERB[s.action];
448
- const color = actionStyle(style, verb === 'unlock' ? 'review' : verb);
449
- lines.push(s.row === null
450
- ? ` ${color(pad(verb, 6))} worktree records`
451
- : ` ${color(pad(verb, 6))} ${row(s.row)}`);
381
+ const color = wordStyle(style, verb === 'unlock' || verb === 'verify' ? 'review' : verb);
382
+ lines.push(` ${color(pad(verb, 6))} ${shownRow(s.row)}`);
452
383
  lines.push(` ${style.muted(stripControls(s.command))}`);
453
384
  }
454
- for (const d of receipt.dropped.filter((d) => d.repo === repo)) {
455
- lines.push(` ${style.review(pad('skip', 6))} ${row(d)}`);
385
+ for (const d of p.dropped.filter((d) => d.repo === repo)) {
386
+ lines.push(` ${style.review(pad('skip', 6))} ${shownRow(d.row)}`);
456
387
  lines.push(` ${d.reason}: ${stripControls(d.detail)}`);
457
388
  }
458
389
  blocks.push(lines.join('\n'));
459
390
  }
460
- const [counts = '', ...rest] = renderApplySummary(receipt);
461
- blocks.push([style.strong(counts), ...rest.map(style.review)].join('\n'));
391
+ const [counts = '', ...rest] = renderRunSummary(p, { preview: true });
392
+ const unused = (p.unusedAllows ?? []).map((a) => `unused: --allow ${a.risk}${a.row === null ? '' : `=${stripControls(a.row)}`} (no row that runs carries it)`);
393
+ blocks.push([style.strong(counts), ...rest.map(style.review), ...unused.map(style.muted)].join('\n'));
462
394
  return blocks.join('\n\n') + '\n';
463
395
  }
464
- /** Rows dropped by a live run's own re-check, after the preview: each with why. */
465
- export function renderPreviewDrops(dropped, style = PLAIN) {
396
+ /** Rows dropped by a run's own re-check after the preview, each with why. */
397
+ export function renderLateDrops(dropped, style = PLAIN) {
466
398
  return dropped
467
- .map((d) => ` ${style.review('skip')} ${stripControls(`${d.category} ${ellipsize(d.id, 60)}`)}\n ${style.review(stripControls(`${d.reason}: ${d.detail}`))}\n`)
399
+ .map((d) => ` ${style.review('skip')} ${shownRow(d.row)}\n ${style.review(stripControls(`${d.reason}: ${d.detail}`))}\n`)
468
400
  .join('');
469
401
  }
470
402
  /**
471
403
  * A failed step for a run shown live: `✗`, the row, and the error in full,
472
- * which the step's row on the board is too narrow to hold.
404
+ * which the step's line on the board is too narrow to hold.
473
405
  */
474
406
  export function renderStepFailure(step, style = PLAIN) {
475
407
  const verb = STEP_VERB[step.action];
476
- const row = step.row === null ? '' : `${step.row.category} ${ellipsize(step.row.id, 60)}`;
477
- const line = stripControls(`${pad(verb, 6)} ${row}`.trimEnd());
408
+ const line = stripControls(`${pad(verb, 6)} ${shownRow(step.row)}`);
478
409
  return ` ${style.delete('✗')} ${line}\n ${style.delete(stripControls(step.error ?? 'failed'))}`;
479
410
  }
480
- /** An apply run as text: dropped rows, each step, a summary, then where the receipt is. */
411
+ /** A run as text: dropped rows, each step, a summary, then where the receipt is. */
481
412
  export function renderReceipt(receipt, receiptPath, style = PLAIN, opts = {}) {
482
413
  const lines = [];
483
414
  // A run shown live has printed its dropped rows and steps already.
484
415
  for (const d of opts.steps === false ? [] : receipt.dropped) {
485
- const shown = stripControls(`${d.category}:${d.id} — ${d.reason} (${d.detail})`);
486
- lines.push(`${style.review('skipped ')}${shown}`);
416
+ lines.push(`${style.review('skipped ')}${stripControls(`${d.row} — ${d.reason} (${d.detail})`)}`);
487
417
  }
488
418
  for (const s of opts.steps === false ? [] : receipt.steps) {
489
- const what = s.row === null ? s.command : `${s.row.category}:${s.row.id} — ${s.command}`;
419
+ const what = `${s.row} — ${s.command}`;
490
420
  const tail = s.error ?? s.verification;
491
421
  lines.push(`${resultWord(s, style)} ${stripControls(`${what}${tail ? ` (${tail})` : ''}`)}`);
492
422
  }
493
423
  if (lines.length > 0)
494
424
  lines.push('');
495
- const [counts = '', ...rest] = renderApplySummary(receipt);
425
+ const [counts = '', ...rest] = renderRunSummary(receipt, {
426
+ preview: receipt.status === 'dry-run',
427
+ });
496
428
  lines.push(style.strong(counts), ...rest.map(style.review));
497
429
  lines.push(style.muted(receiptPath === null
498
430
  ? `status: ${receipt.status}`
499
431
  : `receipt: ${stripControls(receiptPath)} (${receipt.status})`));
500
432
  return lines.join('\n') + '\n';
501
433
  }
434
+ /** The renderers a library user needs: the CLI's own formats. */
435
+ export const render = {
436
+ text: renderText,
437
+ json: renderJson,
438
+ summary: renderSummary,
439
+ commands: renderCommands,
440
+ preview: renderPreview,
441
+ receipt: renderReceipt,
442
+ };