@osdlc/repo-cleaner 0.10.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +135 -19
- package/dist/apply.d.ts +30 -1
- package/dist/apply.js +118 -17
- package/dist/cli.d.ts +6 -1
- package/dist/cli.js +192 -30
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/plan.d.ts +3 -0
- package/dist/plan.js +14 -1
- package/dist/receipt.d.ts +4 -0
- package/dist/receipt.js +2 -0
- package/dist/remote-mirror.d.ts +55 -0
- package/dist/remote-mirror.js +123 -0
- package/dist/render.d.ts +48 -5
- package/dist/render.js +151 -29
- package/dist/status-line.d.ts +4 -2
- package/dist/status-line.js +11 -4
- package/dist/step-board.d.ts +34 -0
- package/dist/step-board.js +101 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.js +14 -1
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worktrees.d.ts +12 -0
- package/dist/worktrees.js +58 -1
- package/package.json +8 -8
|
@@ -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
|
|
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 {
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
'
|
|
142
|
-
'
|
|
143
|
-
'
|
|
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)} ${
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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
|
|
464
|
+
lines.push(`${resultWord(s, style)} ${stripControls(`${what}${tail ? ` (${tail})` : ''}`)}`);
|
|
344
465
|
}
|
|
345
466
|
if (lines.length > 0)
|
|
346
467
|
lines.push('');
|
|
347
|
-
|
|
348
|
-
lines.push(
|
|
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
|
}
|
package/dist/status-line.d.ts
CHANGED
|
@@ -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`. */
|
package/dist/status-line.js
CHANGED
|
@@ -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
|
|
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;
|