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