@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.
- package/CHANGELOG.md +31 -0
- package/README.md +691 -305
- package/dist/branches.d.ts +37 -36
- package/dist/branches.js +199 -196
- package/dist/clean.d.ts +179 -0
- package/dist/clean.js +824 -0
- package/dist/cli.d.ts +12 -16
- package/dist/cli.js +367 -478
- package/dist/hooks.d.ts +8 -12
- package/dist/hooks.js +2 -2
- package/dist/index.d.ts +18 -25
- package/dist/index.js +16 -23
- package/dist/landing.d.ts +3 -3
- package/dist/landing.js +7 -7
- package/dist/plan.d.ts +76 -70
- package/dist/plan.js +82 -77
- package/dist/providers/github.d.ts +2 -1
- package/dist/providers/github.js +2 -1
- package/dist/providers/gitlab.d.ts +3 -2
- package/dist/providers/gitlab.js +14 -8
- package/dist/providers/index.d.ts +5 -4
- package/dist/providers/index.js +8 -5
- package/dist/providers/types.d.ts +8 -2
- package/dist/prs.d.ts +35 -7
- package/dist/prs.js +45 -12
- package/dist/receipt.d.ts +46 -57
- package/dist/receipt.js +52 -28
- package/dist/reference.d.ts +12 -0
- package/dist/reference.js +152 -0
- package/dist/remote-mirror.d.ts +9 -1
- package/dist/remote-mirror.js +19 -1
- package/dist/render.d.ts +66 -61
- package/dist/render.js +206 -255
- package/dist/resolve.d.ts +3 -3
- package/dist/resolve.js +4 -12
- package/dist/status-line.d.ts +1 -1
- package/dist/status-line.js +5 -5
- package/dist/step-board.d.ts +1 -1
- package/dist/step-board.js +2 -5
- package/dist/submodules.d.ts +120 -0
- package/dist/submodules.js +292 -0
- package/dist/sweep.d.ts +107 -64
- package/dist/sweep.js +333 -119
- package/dist/types.d.ts +294 -105
- package/dist/types.js +106 -54
- package/dist/util.d.ts +13 -0
- package/dist/util.js +36 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worktrees.d.ts +21 -37
- package/dist/worktrees.js +198 -165
- package/package.json +5 -5
- package/dist/apply.d.ts +0 -131
- package/dist/apply.js +0 -541
package/dist/render.js
CHANGED
|
@@ -1,23 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Rendering a sweep for a terminal
|
|
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-
|
|
6
|
-
* find. JSON carries everything the text report
|
|
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 {
|
|
11
|
-
import {
|
|
12
|
-
/**
|
|
13
|
-
const
|
|
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
|
|
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
|
|
51
|
-
* commits), confirm-open-pr`. Empty when the row
|
|
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
|
-
|
|
54
|
-
if (
|
|
48
|
+
function risksNote(row) {
|
|
49
|
+
if (row.risks.length === 0)
|
|
55
50
|
return '';
|
|
56
|
-
const count =
|
|
57
|
-
const names =
|
|
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 `
|
|
55
|
+
return `needs --allow ${names.join(', ')}`;
|
|
61
56
|
}
|
|
62
|
-
function
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
return [...
|
|
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
|
-
/**
|
|
80
|
-
function
|
|
81
|
-
|
|
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 (
|
|
86
|
+
if (word === 'prune')
|
|
84
87
|
return style.prune;
|
|
85
|
-
if (
|
|
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 =
|
|
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
|
|
103
|
-
const
|
|
104
|
-
for (const
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
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 =
|
|
112
|
+
const detail = row.verdict === 'blocked' ? row.notes : row.commands;
|
|
112
113
|
for (const line of detail)
|
|
113
|
-
lines.push(` ${' '.repeat(
|
|
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
|
|
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
|
|
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
|
|
130
|
+
* repositories says the target is a home directory, not a workspace.
|
|
142
131
|
*/
|
|
143
|
-
export function renderScan(
|
|
144
|
-
const { scan } =
|
|
132
|
+
export function renderScan(sweep) {
|
|
133
|
+
const { scan } = sweep;
|
|
145
134
|
if (scan.containers === 0)
|
|
146
135
|
return null;
|
|
147
|
-
const entries =
|
|
148
|
-
const dirs =
|
|
149
|
-
const found =
|
|
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
|
|
161
|
-
const
|
|
162
|
-
const repos =
|
|
163
|
-
const errors =
|
|
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 (
|
|
168
|
-
tail.push(`${
|
|
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 =
|
|
172
|
-
const
|
|
173
|
-
|
|
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
|
|
177
|
-
|
|
160
|
+
const KIND_LABEL = {
|
|
161
|
+
branch: 'local branches',
|
|
178
162
|
worktree: 'worktrees',
|
|
179
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
166
|
+
'total',
|
|
167
|
+
'merged',
|
|
168
|
+
'delete',
|
|
169
|
+
'prune',
|
|
170
|
+
'review',
|
|
171
|
+
'blocked',
|
|
172
|
+
'keep',
|
|
189
173
|
];
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
|
|
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
|
-
/**
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
227
|
-
export function
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
error:
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
253
|
-
export function renderText(
|
|
254
|
-
const duplicates = renderDuplicates(
|
|
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
|
-
...
|
|
231
|
+
...sweep.repos.map((r) => renderRepo(r, opts)),
|
|
258
232
|
];
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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 `
|
|
303
|
-
export function
|
|
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
|
|
306
|
-
for (const
|
|
307
|
-
if (
|
|
308
|
-
out.push(...
|
|
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
|
|
314
|
-
export function
|
|
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,
|
|
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
|
|
269
|
+
for (const d of r.dropped)
|
|
323
270
|
out.dropped[d.reason] = (out.dropped[d.reason] ?? 0) + 1;
|
|
324
|
-
for (const s of
|
|
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-
|
|
343
|
-
out.deleted.
|
|
291
|
+
else if (s.action === 'delete-branch')
|
|
292
|
+
out.deleted.branch += 1;
|
|
344
293
|
else if (s.action === 'delete-remote')
|
|
345
|
-
out.deleted.
|
|
294
|
+
out.deleted.remote += 1;
|
|
346
295
|
}
|
|
347
296
|
return out;
|
|
348
297
|
}
|
|
349
|
-
/** Rows a
|
|
298
|
+
/** Rows a run deletes or prunes, which is what a person is asked to approve. */
|
|
350
299
|
export function rowsActedOn(summary) {
|
|
351
|
-
|
|
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
|
|
357
|
-
* on. A `not-
|
|
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(
|
|
306
|
+
function dropHints(dropped) {
|
|
360
307
|
const risks = new Set();
|
|
361
|
-
for (const d of
|
|
362
|
-
if (d.reason !== 'not-
|
|
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-
|
|
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
|
|
376
|
-
export function
|
|
377
|
-
const s =
|
|
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.
|
|
382
|
-
|
|
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
|
-
`${
|
|
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(
|
|
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-
|
|
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
|
-
*
|
|
429
|
-
*
|
|
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
|
-
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
|
389
|
+
for (const s of p.steps.filter((s) => s.repo === repo)) {
|
|
447
390
|
const verb = STEP_VERB[s.action];
|
|
448
|
-
const color =
|
|
449
|
-
lines.push(s.row
|
|
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
|
|
455
|
-
lines.push(` ${style.review(pad('skip', 6))} ${
|
|
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] =
|
|
461
|
-
|
|
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
|
|
465
|
-
export function
|
|
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')} ${
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 =
|
|
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] =
|
|
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
|
+
};
|