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