@osdlc/repo-cleaner 0.10.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Sweep a remote with no checkout of your own: a clone of it, kept in
3
+ * repo-cleaner's cache directory, stands in for the checkout.
4
+ *
5
+ * The clone is `--no-checkout`, so no files are written outside `.git`. It is
6
+ * not a partial (`--filter=blob:none`) clone: the patch comparison that
7
+ * finds squash-merged branches reads file contents, and a partial clone
8
+ * fetches them over the network one comparison at a time, which made a sweep
9
+ * of a few hundred branches take minutes instead of seconds. It is kept
10
+ * rather than thrown away so a plan written from it can be applied later: the
11
+ * plan names the clone's directory, and `apply` re-sweeps and pushes deletes
12
+ * from there. A later run reuses it, and the sweep's fetch brings it up to
13
+ * date.
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+ import { Git, parseRemoteRepo } from '@osdlc/typed-git';
19
+ /** A remote URL that cannot be mirrored, or a mirror that cannot be made or reused. */
20
+ export class RemoteMirrorError extends Error {
21
+ constructor(message) {
22
+ super(message);
23
+ this.name = 'RemoteMirrorError';
24
+ }
25
+ }
26
+ /** Characters kept in a directory name; anything else becomes `-`. */
27
+ const UNSAFE = /[^A-Za-z0-9._-]+/g;
28
+ /**
29
+ * `text` with every URL's credentials removed: the user-info before `@` in a
30
+ * `scheme://` URL, and any query string or fragment. For a message or a name
31
+ * that must not carry a token.
32
+ */
33
+ export function redactUrl(text) {
34
+ return text
35
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, '$1')
36
+ .replace(/([a-z][a-z0-9+.-]*:\/\/[^\s?#]*)[?#]\S*/gi, '$1');
37
+ }
38
+ /**
39
+ * The directory name for `url`'s mirror: readable `host-owner-repo`, then a
40
+ * hash of the whole URL so two URLs never share a mirror. The readable part
41
+ * is read from the URL with its credentials, query and fragment removed, so a
42
+ * token never reaches the name.
43
+ */
44
+ export function mirrorName(url) {
45
+ const hash = createHash('sha256').update(url).digest('hex').slice(0, 12);
46
+ const parsed = parseRemoteRepo(redactUrl(url));
47
+ const readable = parsed === null
48
+ ? 'remote'
49
+ : `${parsed.host}-${parsed.owner}-${parsed.repo}`.replace(UNSAFE, '-');
50
+ return `${readable.replace(/^[.-]+/, '')}-${hash}`;
51
+ }
52
+ /**
53
+ * Why `dir` is not a mirror of `url` — or of whatever it was cloned from, when
54
+ * `url` is omitted — or null when it is. A mirror's `origin` has one fetch URL
55
+ * and pushes to that URL alone, so a delete cannot be sent elsewhere.
56
+ */
57
+ export function mirrorProblem(dir, url, opts = {}) {
58
+ const git = new Git(dir, { runner: opts.git });
59
+ const fetch = git.try.remoteGetUrls('origin');
60
+ const push = git.try.remoteGetUrls('origin', { push: true });
61
+ if (!fetch.ok || !push.ok)
62
+ return `${dir} has no readable origin remote`;
63
+ const [from] = fetch.value;
64
+ if (fetch.value.length !== 1 || from === undefined) {
65
+ return `${dir} has ${fetch.value.length} origin URLs`;
66
+ }
67
+ if (url !== undefined && from !== url)
68
+ return `${dir} is a mirror of another URL`;
69
+ if (push.value.length !== 1 || push.value[0] !== from) {
70
+ return `${dir} pushes somewhere other than the URL it was cloned from`;
71
+ }
72
+ return null;
73
+ }
74
+ /**
75
+ * The directory of `url`'s mirror under `cacheDir`, cloned on first use. An
76
+ * existing mirror is reused as it is, once {@link mirrorProblem} finds
77
+ * nothing wrong with it; the sweep's own fetch brings it up to date. A first
78
+ * clone goes to a sibling directory and is renamed into place, so a clone
79
+ * that fails leaves nothing behind, and a run that loses a race to clone the
80
+ * same URL uses the winner's clone. Messages carry the URL with its
81
+ * credentials removed.
82
+ *
83
+ * @throws RemoteMirrorError when `url` is empty or looks like an option, when
84
+ * the clone fails, or when the directory is not a mirror of `url`.
85
+ */
86
+ export function ensureMirror(url, cacheDir, opts = {}) {
87
+ if (url === '' || url.startsWith('-')) {
88
+ throw new RemoteMirrorError(`not a remote URL: ${JSON.stringify(redactUrl(url))}`);
89
+ }
90
+ const shown = redactUrl(url);
91
+ const dir = join(cacheDir, mirrorName(url));
92
+ if (existsSync(dir)) {
93
+ const problem = mirrorProblem(dir, url, opts);
94
+ if (problem !== null) {
95
+ throw new RemoteMirrorError(`${problem}, not a mirror of ${shown}; remove the directory and run again`);
96
+ }
97
+ return dir;
98
+ }
99
+ mkdirSync(dirname(dir), { recursive: true });
100
+ const staging = `${dir}.partial-${process.pid}`;
101
+ rmSync(staging, { recursive: true, force: true });
102
+ try {
103
+ new Git(dirname(dir), { runner: opts.git }).rawChecked([
104
+ 'clone',
105
+ '--quiet',
106
+ '--no-checkout',
107
+ '--',
108
+ url,
109
+ staging,
110
+ ]);
111
+ renameSync(staging, dir);
112
+ }
113
+ catch (err) {
114
+ rmSync(staging, { recursive: true, force: true });
115
+ // Another run cloned the same URL first and won the rename: use its clone.
116
+ if (existsSync(dir) && mirrorProblem(dir, url, opts) === null)
117
+ return dir;
118
+ const detail = err.failure?.detail ??
119
+ (err instanceof Error ? err.message : String(err));
120
+ throw new RemoteMirrorError(`cannot clone ${shown}: ${redactUrl(detail.split(url).join(shown)).trim()}`);
121
+ }
122
+ return dir;
123
+ }
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
- * merged, and how many the sweep would delete, review or keep.
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;
package/dist/render.js CHANGED
@@ -6,7 +6,8 @@
6
6
  * find. JSON carries everything the text report elides, including the facts
7
7
  * behind every verdict.
8
8
  */
9
- import { emptyBucket, isUnfetched, summarize, } from "./types.js";
9
+ import { ansiColors, paint, stripControls } from '@osdlc/ux';
10
+ import { emptyBucket, isPrune, isUnfetched, summarize, } from "./types.js";
10
11
  import { hasBlockers } from "./worktrees.js";
11
12
  /** Order categories consistently, whatever order they were gathered in. */
12
13
  const CATEGORY_ORDER = ['local_branch', 'worktree', 'remote_branch'];
@@ -17,6 +18,27 @@ const ACTION_LABEL = {
17
18
  'needs-investigation': 'review',
18
19
  'safe-to-keep': 'keep',
19
20
  };
21
+ const same = (text) => text;
22
+ /** No color. */
23
+ export const PLAIN = {
24
+ delete: same,
25
+ prune: same,
26
+ review: same,
27
+ keep: same,
28
+ muted: same,
29
+ strong: same,
30
+ };
31
+ // The 16 basic colors, so the terminal's own theme decides the shades.
32
+ const ANSI = ansiColors('ansi16', 'dark');
33
+ /** The basic terminal colors: red to delete, blue to prune, yellow to review, green to keep. */
34
+ export const TERMINAL_STYLE = {
35
+ delete: (t) => paint(t, ANSI['status-error']),
36
+ prune: (t) => paint(t, ANSI.accent),
37
+ review: (t) => paint(t, ANSI['status-warning']),
38
+ keep: (t) => paint(t, ANSI['status-success']),
39
+ muted: (t) => paint(t, ANSI['text-muted']),
40
+ strong: (t) => `\x1b[1m${t}\x1b[22m`,
41
+ };
20
42
  function pad(s, width) {
21
43
  return s.length >= width ? s : s + ' '.repeat(width - s.length);
22
44
  }
@@ -54,11 +76,22 @@ function repoHeader(sweep) {
54
76
  ];
55
77
  return `${name} (${bits.filter((b) => b !== null).join(', ')})`;
56
78
  }
79
+ /** The color of a row's action word. */
80
+ function actionStyle(style, action) {
81
+ if (action === 'delete')
82
+ return style.delete;
83
+ if (action === 'prune')
84
+ return style.prune;
85
+ if (action === 'review')
86
+ return style.review;
87
+ return style.keep;
88
+ }
57
89
  /** One repository's rows, indented under its header. */
58
90
  export function renderRepo(sweep, opts = {}) {
59
- const lines = [repoHeader(sweep)];
91
+ const style = opts.style ?? PLAIN;
92
+ const lines = [style.strong(repoHeader(sweep))];
60
93
  if (sweep.error !== null) {
61
- lines.push(` error: ${sweep.error}`);
94
+ lines.push(` ${style.delete('error')}: ${sweep.error}`);
62
95
  return lines.join('\n');
63
96
  }
64
97
  const shown = sortItems(sweep.items).filter((item) => opts.keep === true || item.classification !== 'safe-to-keep');
@@ -69,13 +102,15 @@ export function renderRepo(sweep, opts = {}) {
69
102
  const idWidth = Math.min(38, Math.max(...shown.map((item) => ellipsize(item.id, 38).length)));
70
103
  const categoryWidth = Math.max(...shown.map((item) => item.category.length));
71
104
  for (const item of shown) {
72
- const action = ACTION_LABEL[item.classification] ?? item.classification;
105
+ const action = isPrune(item)
106
+ ? 'prune'
107
+ : (ACTION_LABEL[item.classification] ?? item.classification);
73
108
  const note = requiresNote(item);
74
- lines.push(` ${pad(item.category, categoryWidth)} ${pad(ellipsize(item.id, 38), idWidth)} ${pad(action, 6)} ${item.reason}${note ? `; ${note}` : ''}`);
109
+ lines.push(` ${pad(item.category, categoryWidth)} ${pad(ellipsize(item.id, 38), idWidth)} ${actionStyle(style, action)(pad(action, 6))} ${item.reason}${note ? `; ${note}` : ''}`);
75
110
  // A blocked row has no commands; what to do about each blocker goes there instead.
76
111
  const detail = hasBlockers(item) ? item.investigationParts : item.commands;
77
112
  for (const line of detail)
78
- lines.push(` ${' '.repeat(categoryWidth)} ${line}`);
113
+ lines.push(` ${' '.repeat(categoryWidth)} ${style.muted(line)}`);
79
114
  }
80
115
  return lines.join('\n');
81
116
  }
@@ -86,6 +121,7 @@ function totals(result) {
86
121
  out.total += bucket.total;
87
122
  out.merged += bucket.merged;
88
123
  out['safe-to-delete'] += bucket['safe-to-delete'];
124
+ out.prune += bucket.prune;
89
125
  out['safe-to-keep'] += bucket['safe-to-keep'];
90
126
  out['needs-investigation'] += bucket['needs-investigation'];
91
127
  }
@@ -116,8 +152,12 @@ export function renderScan(result) {
116
152
  return line;
117
153
  return `${line} ${count(scan.depthLimited)} left unopened at the depth limit.`;
118
154
  }
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
+ }
119
159
  /** The one-line verdict a person reads first, and often only. */
120
- export function renderSummary(result) {
160
+ export function renderSummary(result, style = PLAIN) {
121
161
  const counts = totals(result);
122
162
  const repos = `${result.repos.length} ${result.repos.length === 1 ? 'repository' : 'repositories'}`;
123
163
  const errors = result.repos.filter((sweep) => sweep.error !== null).length;
@@ -128,24 +168,28 @@ export function renderSummary(result) {
128
168
  tail.push(`${result.unresolved.length} ${result.unresolved.length === 1 ? 'entry' : 'entries'} unresolved`);
129
169
  }
130
170
  const suffix = tail.length > 0 ? ` (${tail.join(', ')})` : '';
131
- return `${counts['safe-to-delete']} to delete, ${counts['needs-investigation']} to review, across ${repos}${suffix}.`;
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}.`;
132
175
  }
133
176
  const COUNT_LABEL = {
134
177
  local_branch: 'local branches',
135
178
  worktree: 'worktrees',
136
179
  remote_branch: 'remote branches',
137
180
  };
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. */
138
182
  const COUNT_COLUMNS = [
139
- 'total',
140
- 'merged',
141
- 'safe-to-delete',
142
- 'needs-investigation',
143
- 'safe-to-keep',
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'] },
144
189
  ];
145
- const COUNT_HEADINGS = ['total', 'merged', 'delete', 'review', 'keep'];
146
190
  /**
147
191
  * One repository as counts: a row per category with its total, how many are
148
- * merged, and how many the sweep would delete, review or keep.
192
+ * merged, and how many the sweep would delete, prune, review or keep.
149
193
  */
150
194
  export function renderCounts(sweep) {
151
195
  const lines = [repoHeader(sweep)];
@@ -156,16 +200,16 @@ export function renderCounts(sweep) {
156
200
  const buckets = summarize(sweep.items);
157
201
  const labelWidth = Math.max(...Object.values(COUNT_LABEL).map((l) => l.length));
158
202
  const cell = (text, heading) => text.padStart(Math.max(heading.length, 5));
159
- lines.push(` ${' '.repeat(labelWidth)} ${COUNT_HEADINGS.map((h) => cell(h, h)).join(' ')}`);
203
+ lines.push(` ${' '.repeat(labelWidth)} ${COUNT_COLUMNS.map(({ heading }) => cell(heading, heading)).join(' ')}`);
160
204
  for (const category of CATEGORY_ORDER) {
161
205
  const bucket = buckets[category] ?? emptyBucket();
162
- const cells = COUNT_COLUMNS.map((col, i) => cell(count(bucket[col]), COUNT_HEADINGS[i]));
206
+ const cells = COUNT_COLUMNS.map(({ heading, of }) => cell(count(of(bucket)), heading));
163
207
  lines.push(` ${pad(COUNT_LABEL[category], labelWidth)} ${cells.join(' ')}`);
164
208
  }
165
209
  return lines.join('\n');
166
210
  }
167
211
  /** The whole run as counts: a table per repository, then the one-line summary. */
168
- export function renderCountsText(result) {
212
+ export function renderCountsText(result, style = PLAIN) {
169
213
  const blocks = result.repos.map(renderCounts);
170
214
  if (result.unresolved.length > 0) {
171
215
  const lines = ['unresolved'];
@@ -175,7 +219,8 @@ export function renderCountsText(result) {
175
219
  blocks.push(lines.join('\n'));
176
220
  }
177
221
  const scan = renderScan(result);
178
- blocks.push(scan === null ? renderSummary(result) : `${scan}\n${renderSummary(result)}`);
222
+ const summary = renderSummary(result, style);
223
+ blocks.push(scan === null ? summary : `${scan}\n${summary}`);
179
224
  return blocks.join('\n\n') + '\n';
180
225
  }
181
226
  /** The whole run's counts as JSON: no rows, no facts. */
@@ -219,7 +264,8 @@ export function renderText(result, opts = {}) {
219
264
  blocks.push(lines.join('\n'));
220
265
  }
221
266
  const scan = renderScan(result);
222
- blocks.push(scan === null ? renderSummary(result) : `${scan}\n${renderSummary(result)}`);
267
+ const summary = renderSummary(result, opts.style);
268
+ blocks.push(scan === null ? summary : `${scan}\n${summary}`);
223
269
  return blocks.join('\n\n') + '\n';
224
270
  }
225
271
  /**
@@ -281,7 +327,9 @@ export function summarizeReceipt(receipt) {
281
327
  if (s.row === null)
282
328
  continue;
283
329
  if (s.result === 'pending') {
284
- if (s.action !== 'unlock-worktree')
330
+ // Rows not reached are rows a run would have deleted; an unlock or a
331
+ // tip check deletes nothing.
332
+ if (s.action !== 'unlock-worktree' && s.action !== 'verify-remote')
285
333
  out.notRun += 1;
286
334
  continue;
287
335
  }
@@ -331,22 +379,96 @@ export function renderApplySummary(receipt) {
331
379
  }
332
380
  return lines;
333
381
  }
382
+ /** The word a preview shows for a step: what it does to the row. */
383
+ export const STEP_VERB = {
384
+ 'verify-remote': 'verify',
385
+ 'unlock-worktree': 'unlock',
386
+ 'remove-worktree': 'delete',
387
+ 'delete-local': 'delete',
388
+ 'delete-remote': 'delete',
389
+ 'prune-worktrees': 'prune',
390
+ };
391
+ /** A step's result word, colored by what it says about the run. */
392
+ function resultWord(step, style) {
393
+ const word = step.result.padEnd(8);
394
+ if (step.result === 'failed')
395
+ return style.delete(word);
396
+ if (step.result === 'ok')
397
+ return style.keep(word);
398
+ if (step.result === 'pending')
399
+ return style.muted(word);
400
+ return step.action === 'prune-worktrees' ? style.prune(word) : style.delete(word);
401
+ }
402
+ /**
403
+ * What `apply` is about to do, for the person asked to approve it: per
404
+ * repository, each row and the command that deletes it, the rows it will
405
+ * skip and why, then the counts. Terminal controls are stripped from what it
406
+ * shows, so a path or name cannot hide part of what is approved.
407
+ */
408
+ export function renderPreview(receipt, style = PLAIN) {
409
+ const repos = [
410
+ ...new Set([...receipt.steps.map((s) => s.repo), ...receipt.dropped.map((d) => d.repo)]),
411
+ ];
412
+ const rows = [
413
+ ...receipt.steps.flatMap((s) => (s.row === null ? [] : [s.row])),
414
+ ...receipt.dropped,
415
+ ];
416
+ const categoryWidth = Math.max(0, ...rows.map((r) => r.category.length));
417
+ const row = (r) => r === null ? '' : `${pad(r.category, categoryWidth)} ${ellipsize(stripControls(r.id), 60)}`;
418
+ const blocks = [];
419
+ for (const repo of repos) {
420
+ const lines = [style.strong(stripControls(repo))];
421
+ for (const s of receipt.steps.filter((s) => s.repo === repo)) {
422
+ const verb = STEP_VERB[s.action];
423
+ const color = actionStyle(style, verb === 'unlock' ? 'review' : verb);
424
+ lines.push(s.row === null ? ` ${color(verb)}` : ` ${color(pad(verb, 6))} ${row(s.row)}`);
425
+ lines.push(` ${style.muted(stripControls(s.command))}`);
426
+ }
427
+ for (const d of receipt.dropped.filter((d) => d.repo === repo)) {
428
+ lines.push(` ${style.review(pad('skip', 6))} ${row(d)}`);
429
+ lines.push(` ${d.reason}: ${stripControls(d.detail)}`);
430
+ }
431
+ blocks.push(lines.join('\n'));
432
+ }
433
+ const [counts = '', ...rest] = renderApplySummary(receipt);
434
+ blocks.push([style.strong(counts), ...rest.map(style.review)].join('\n'));
435
+ return blocks.join('\n\n') + '\n';
436
+ }
437
+ /** Rows dropped by a live run's own re-check, after the preview: each with why. */
438
+ export function renderPreviewDrops(dropped, style = PLAIN) {
439
+ return dropped
440
+ .map((d) => ` ${style.review('skip')} ${stripControls(`${d.category} ${ellipsize(d.id, 60)}`)}\n ${style.review(stripControls(`${d.reason}: ${d.detail}`))}\n`)
441
+ .join('');
442
+ }
443
+ /**
444
+ * A failed step for a run shown live: `✗`, the row, and the error in full,
445
+ * which the step's row on the board is too narrow to hold.
446
+ */
447
+ export function renderStepFailure(step, style = PLAIN) {
448
+ const verb = STEP_VERB[step.action];
449
+ const row = step.row === null ? '' : `${step.row.category} ${ellipsize(step.row.id, 60)}`;
450
+ const line = stripControls(`${pad(verb, 6)} ${row}`.trimEnd());
451
+ return ` ${style.delete('✗')} ${line}\n ${style.delete(stripControls(step.error ?? 'failed'))}`;
452
+ }
334
453
  /** An apply run as text: dropped rows, each step, a summary, then where the receipt is. */
335
- export function renderReceipt(receipt, receiptPath) {
454
+ export function renderReceipt(receipt, receiptPath, style = PLAIN, opts = {}) {
336
455
  const lines = [];
337
- for (const d of receipt.dropped) {
338
- lines.push(`dropped ${d.category}:${d.id} — ${d.reason} (${d.detail})`);
456
+ // A run shown live has printed its dropped rows and steps already.
457
+ for (const d of opts.steps === false ? [] : receipt.dropped) {
458
+ const shown = stripControls(`${d.category}:${d.id} — ${d.reason} (${d.detail})`);
459
+ lines.push(`${style.review('dropped ')}${shown}`);
339
460
  }
340
- for (const s of receipt.steps) {
461
+ for (const s of opts.steps === false ? [] : receipt.steps) {
341
462
  const what = s.row === null ? s.command : `${s.row.category}:${s.row.id} — ${s.command}`;
342
463
  const tail = s.error ?? s.verification;
343
- lines.push(`${s.result.padEnd(8)} ${what}${tail ? ` (${tail})` : ''}`);
464
+ lines.push(`${resultWord(s, style)} ${stripControls(`${what}${tail ? ` (${tail})` : ''}`)}`);
344
465
  }
345
466
  if (lines.length > 0)
346
467
  lines.push('');
347
- lines.push(...renderApplySummary(receipt));
348
- lines.push(receiptPath === null
468
+ const [counts = '', ...rest] = renderApplySummary(receipt);
469
+ lines.push(style.strong(counts), ...rest.map(style.review));
470
+ lines.push(style.muted(receiptPath === null
349
471
  ? `status: ${receipt.status}`
350
- : `receipt: ${receiptPath} (${receipt.status})`);
472
+ : `receipt: ${stripControls(receiptPath)} (${receipt.status})`));
351
473
  return lines.join('\n') + '\n';
352
474
  }
@@ -5,12 +5,14 @@
5
5
  */
6
6
  import type { Step } from './receipt.ts';
7
7
  import type { SweepProgress } from './types.ts';
8
+ /** A bar `width` cells wide, filled for `done` of `total`, e.g. `█████░░░░░`. */
9
+ export declare function progressBar(done: number, total: number, width?: number): string;
8
10
  /**
9
- * A sweep step as one line, e.g. `[2/5] checking remote branches 37/427 · ~/work/api`.
11
+ * A sweep step as one line, e.g. `[2/5] checking remote branches ████░░░░ 37/427 · ~/work/api`.
10
12
  * The path goes last, so a line cut to the terminal's width loses its tail first.
11
13
  */
12
14
  export declare function describeSweep(p: SweepProgress, home?: string): string;
13
- /** An apply step as one line, e.g. `deleting 3/7: delete-remote origin/feat/x`. */
15
+ /** An apply step as one line, e.g. `deleting ██████░░░░ 3/7: delete-remote origin/feat/x`. */
14
16
  export declare function describeStep(index: number, total: number, step: Step): string;
15
17
  export interface StatusLine {
16
18
  /** Replace the line with `text`. */
@@ -17,19 +17,26 @@ const STEP_TEXT = {
17
17
  function tilde(path, home) {
18
18
  return path === home ? '~' : path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
19
19
  }
20
+ /** A bar `width` cells wide, filled for `done` of `total`, e.g. `█████░░░░░`. */
21
+ export function progressBar(done, total, width = 20) {
22
+ const filled = total <= 0 ? width : Math.min(width, Math.round((done / total) * width));
23
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
24
+ }
20
25
  /**
21
- * A sweep step as one line, e.g. `[2/5] checking remote branches 37/427 · ~/work/api`.
26
+ * A sweep step as one line, e.g. `[2/5] checking remote branches ████░░░░ 37/427 · ~/work/api`.
22
27
  * The path goes last, so a line cut to the terminal's width loses its tail first.
23
28
  */
24
29
  export function describeSweep(p, home = homedir()) {
25
30
  const where = p.of > 1 ? `[${p.index}/${p.of}] ` : '';
26
- const count = p.done === undefined ? '' : ` ${p.done + 1}/${p.total}`;
31
+ const count = p.done === undefined || p.total === undefined
32
+ ? ''
33
+ : ` ${progressBar(p.done, p.total)} ${p.done + 1}/${p.total}`;
27
34
  return `${where}${STEP_TEXT[p.step]}${count} · ${tilde(p.repo, home)}`;
28
35
  }
29
- /** An apply step as one line, e.g. `deleting 3/7: delete-remote origin/feat/x`. */
36
+ /** An apply step as one line, e.g. `deleting ██████░░░░ 3/7: delete-remote origin/feat/x`. */
30
37
  export function describeStep(index, total, step) {
31
38
  const what = step.row === null ? step.repo : step.row.id;
32
- return `deleting ${index + 1}/${total}: ${step.action} ${what}`;
39
+ return `deleting ${progressBar(index, total)} ${index + 1}/${total}: ${step.action} ${what}`;
33
40
  }
34
41
  /** A line drawn with `\r` and erase-line, controls stripped, cut to `columns() - 1` display columns so it never wraps. */
35
42
  export function statusLine(write, columns) {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `apply`'s steps as a vertical list redrawn in place on a terminal: one row
3
+ * per step with its own bar, empty while it waits, half full while it runs,
4
+ * full and ticked when it is done, and a bar for the whole run under the
5
+ * list. The run is synchronous, so the board moves between steps, not on a
6
+ * timer.
7
+ */
8
+ import type { Step } from './receipt.ts';
9
+ import { type Style } from './render.ts';
10
+ /**
11
+ * The board's lines, each at most `width` columns, and at most `height - 1`
12
+ * of them: the cursor sits on the row under the last line, which must stay
13
+ * on screen. That is the run's bar under the rows that fit; a list longer
14
+ * than that shows a window that keeps the running step in view, with a line
15
+ * for the rows above and below it when there is room for one.
16
+ */
17
+ export declare function boardLines(steps: readonly Step[], running: number | null, size: {
18
+ width: number;
19
+ height: number;
20
+ }, style?: Style): string[];
21
+ export interface StepBoard {
22
+ /** Draw with `running` as the step under way, or null between steps. */
23
+ draw(running: number | null): void;
24
+ }
25
+ /**
26
+ * A board on `write`. The first draw prints its lines; each later one moves
27
+ * the cursor back to the board's first line and rewrites them, erasing any
28
+ * line an earlier, taller frame left below. Lines are cut to one column
29
+ * short of the width, so none wraps and the line count stays right.
30
+ */
31
+ export declare function stepBoard(write: (text: string) => void, steps: readonly Step[], size: () => {
32
+ width: number;
33
+ height: number;
34
+ }, style?: Style): StepBoard;