@osdlc/repo-cleaner 0.11.0 → 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 +29 -0
- package/README.md +93 -13
- package/dist/apply.d.ts +30 -1
- package/dist/apply.js +118 -17
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +125 -24
- 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/render.d.ts +47 -4
- package/dist/render.js +132 -19
- 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/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worktrees.d.ts +12 -0
- package/dist/worktrees.js +57 -0
- package/package.json +5 -5
package/dist/render.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* find. JSON carries everything the text report elides, including the facts
|
|
7
7
|
* behind every verdict.
|
|
8
8
|
*/
|
|
9
|
+
import { ansiColors, paint, stripControls } from '@osdlc/ux';
|
|
9
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. */
|
|
@@ -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');
|
|
@@ -73,11 +106,11 @@ export function renderRepo(sweep, opts = {}) {
|
|
|
73
106
|
? 'prune'
|
|
74
107
|
: (ACTION_LABEL[item.classification] ?? item.classification);
|
|
75
108
|
const note = requiresNote(item);
|
|
76
|
-
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}` : ''}`);
|
|
77
110
|
// A blocked row has no commands; what to do about each blocker goes there instead.
|
|
78
111
|
const detail = hasBlockers(item) ? item.investigationParts : item.commands;
|
|
79
112
|
for (const line of detail)
|
|
80
|
-
lines.push(` ${' '.repeat(categoryWidth)} ${line}`);
|
|
113
|
+
lines.push(` ${' '.repeat(categoryWidth)} ${style.muted(line)}`);
|
|
81
114
|
}
|
|
82
115
|
return lines.join('\n');
|
|
83
116
|
}
|
|
@@ -124,7 +157,7 @@ function deleteOnly(bucket) {
|
|
|
124
157
|
return bucket['safe-to-delete'] - bucket.prune;
|
|
125
158
|
}
|
|
126
159
|
/** The one-line verdict a person reads first, and often only. */
|
|
127
|
-
export function renderSummary(result) {
|
|
160
|
+
export function renderSummary(result, style = PLAIN) {
|
|
128
161
|
const counts = totals(result);
|
|
129
162
|
const repos = `${result.repos.length} ${result.repos.length === 1 ? 'repository' : 'repositories'}`;
|
|
130
163
|
const errors = result.repos.filter((sweep) => sweep.error !== null).length;
|
|
@@ -135,8 +168,10 @@ export function renderSummary(result) {
|
|
|
135
168
|
tail.push(`${result.unresolved.length} ${result.unresolved.length === 1 ? 'entry' : 'entries'} unresolved`);
|
|
136
169
|
}
|
|
137
170
|
const suffix = tail.length > 0 ? ` (${tail.join(', ')})` : '';
|
|
138
|
-
const prune = counts.prune > 0 ? `${counts.prune} to prune, ` : '';
|
|
139
|
-
|
|
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}.`;
|
|
140
175
|
}
|
|
141
176
|
const COUNT_LABEL = {
|
|
142
177
|
local_branch: 'local branches',
|
|
@@ -174,7 +209,7 @@ export function renderCounts(sweep) {
|
|
|
174
209
|
return lines.join('\n');
|
|
175
210
|
}
|
|
176
211
|
/** The whole run as counts: a table per repository, then the one-line summary. */
|
|
177
|
-
export function renderCountsText(result) {
|
|
212
|
+
export function renderCountsText(result, style = PLAIN) {
|
|
178
213
|
const blocks = result.repos.map(renderCounts);
|
|
179
214
|
if (result.unresolved.length > 0) {
|
|
180
215
|
const lines = ['unresolved'];
|
|
@@ -184,7 +219,8 @@ export function renderCountsText(result) {
|
|
|
184
219
|
blocks.push(lines.join('\n'));
|
|
185
220
|
}
|
|
186
221
|
const scan = renderScan(result);
|
|
187
|
-
|
|
222
|
+
const summary = renderSummary(result, style);
|
|
223
|
+
blocks.push(scan === null ? summary : `${scan}\n${summary}`);
|
|
188
224
|
return blocks.join('\n\n') + '\n';
|
|
189
225
|
}
|
|
190
226
|
/** The whole run's counts as JSON: no rows, no facts. */
|
|
@@ -228,7 +264,8 @@ export function renderText(result, opts = {}) {
|
|
|
228
264
|
blocks.push(lines.join('\n'));
|
|
229
265
|
}
|
|
230
266
|
const scan = renderScan(result);
|
|
231
|
-
|
|
267
|
+
const summary = renderSummary(result, opts.style);
|
|
268
|
+
blocks.push(scan === null ? summary : `${scan}\n${summary}`);
|
|
232
269
|
return blocks.join('\n\n') + '\n';
|
|
233
270
|
}
|
|
234
271
|
/**
|
|
@@ -290,7 +327,9 @@ export function summarizeReceipt(receipt) {
|
|
|
290
327
|
if (s.row === null)
|
|
291
328
|
continue;
|
|
292
329
|
if (s.result === 'pending') {
|
|
293
|
-
|
|
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')
|
|
294
333
|
out.notRun += 1;
|
|
295
334
|
continue;
|
|
296
335
|
}
|
|
@@ -340,22 +379,96 @@ export function renderApplySummary(receipt) {
|
|
|
340
379
|
}
|
|
341
380
|
return lines;
|
|
342
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
|
+
}
|
|
343
453
|
/** An apply run as text: dropped rows, each step, a summary, then where the receipt is. */
|
|
344
|
-
export function renderReceipt(receipt, receiptPath) {
|
|
454
|
+
export function renderReceipt(receipt, receiptPath, style = PLAIN, opts = {}) {
|
|
345
455
|
const lines = [];
|
|
346
|
-
|
|
347
|
-
|
|
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}`);
|
|
348
460
|
}
|
|
349
|
-
for (const s of receipt.steps) {
|
|
461
|
+
for (const s of opts.steps === false ? [] : receipt.steps) {
|
|
350
462
|
const what = s.row === null ? s.command : `${s.row.category}:${s.row.id} — ${s.command}`;
|
|
351
463
|
const tail = s.error ?? s.verification;
|
|
352
|
-
lines.push(`${s
|
|
464
|
+
lines.push(`${resultWord(s, style)} ${stripControls(`${what}${tail ? ` (${tail})` : ''}`)}`);
|
|
353
465
|
}
|
|
354
466
|
if (lines.length > 0)
|
|
355
467
|
lines.push('');
|
|
356
|
-
|
|
357
|
-
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
|
|
358
471
|
? `status: ${receipt.status}`
|
|
359
|
-
: `receipt: ${receiptPath} (${receipt.status})`);
|
|
472
|
+
: `receipt: ${stripControls(receiptPath)} (${receipt.status})`));
|
|
360
473
|
return lines.join('\n') + '\n';
|
|
361
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;
|
|
@@ -0,0 +1,101 @@
|
|
|
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 { fitWidth, stripControls } from '@osdlc/ux';
|
|
9
|
+
import { PLAIN, STEP_VERB } from "./render.js";
|
|
10
|
+
import { progressBar } from "./status-line.js";
|
|
11
|
+
/** A step bar's cells, and the run bar's at most. */
|
|
12
|
+
const BAR = 10;
|
|
13
|
+
const TOTAL_BAR = 30;
|
|
14
|
+
function label(step) {
|
|
15
|
+
const verb = STEP_VERB[step.action];
|
|
16
|
+
if (step.row === null)
|
|
17
|
+
return verb;
|
|
18
|
+
return stripControls(`${verb.padEnd(6)} ${step.row.category} ${step.row.id}`);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* One step's row, cut to `width` columns before it is colored, so the color
|
|
22
|
+
* codes never count against the width and the row never wraps.
|
|
23
|
+
*/
|
|
24
|
+
function stepRow(step, running, width, style) {
|
|
25
|
+
const [bar, mark, color, text] = step.result === 'ok'
|
|
26
|
+
? ['█'.repeat(BAR), '✓', style.keep, (t) => t]
|
|
27
|
+
: step.result === 'failed'
|
|
28
|
+
? ['█'.repeat(BAR), '✗', style.delete, (t) => t]
|
|
29
|
+
: running
|
|
30
|
+
? ['█'.repeat(BAR / 2) + '░'.repeat(BAR / 2), '›', style.review, style.strong]
|
|
31
|
+
: ['░'.repeat(BAR), ' ', style.muted, style.muted];
|
|
32
|
+
const head = ` ${bar} ${mark} `;
|
|
33
|
+
if (width <= head.length)
|
|
34
|
+
return color(fitWidth(head, width));
|
|
35
|
+
return ` ${color(bar)} ${color(mark)} ${text(fitWidth(label(step), width - head.length))}`;
|
|
36
|
+
}
|
|
37
|
+
/** The run's bar and count, shrunk to fit `width` columns. */
|
|
38
|
+
function totalRow(done, total, failed, width, style) {
|
|
39
|
+
const count = ` ${done}/${total}`;
|
|
40
|
+
const cells = Math.min(TOTAL_BAR, width - 2 - count.length);
|
|
41
|
+
const line = cells > 0
|
|
42
|
+
? ` ${progressBar(done, total, cells)}${count}`
|
|
43
|
+
: fitWidth(` ${count.trim()}`, width);
|
|
44
|
+
return failed ? style.delete(line) : style.strong(line);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The board's lines, each at most `width` columns, and at most `height - 1`
|
|
48
|
+
* of them: the cursor sits on the row under the last line, which must stay
|
|
49
|
+
* on screen. That is the run's bar under the rows that fit; a list longer
|
|
50
|
+
* than that shows a window that keeps the running step in view, with a line
|
|
51
|
+
* for the rows above and below it when there is room for one.
|
|
52
|
+
*/
|
|
53
|
+
export function boardLines(steps, running, size, style = PLAIN) {
|
|
54
|
+
const width = Math.max(1, size.width);
|
|
55
|
+
const room = Math.max(1, size.height - 2);
|
|
56
|
+
let first = 0;
|
|
57
|
+
let last = steps.length;
|
|
58
|
+
const windowed = steps.length > room;
|
|
59
|
+
// The above and below notes need a row each, and a row for a step besides.
|
|
60
|
+
const notes = windowed && room >= 3;
|
|
61
|
+
if (windowed) {
|
|
62
|
+
const at = running ?? steps.findIndex((s) => s.result === 'pending');
|
|
63
|
+
const focus = at < 0 ? steps.length - 1 : at;
|
|
64
|
+
const shown = notes ? room - 2 : room;
|
|
65
|
+
first = Math.min(Math.max(0, focus - Math.floor(shown / 3)), steps.length - shown);
|
|
66
|
+
last = first + shown;
|
|
67
|
+
}
|
|
68
|
+
const note = (text) => style.muted(fitWidth(text, width));
|
|
69
|
+
const lines = [];
|
|
70
|
+
if (notes)
|
|
71
|
+
lines.push(note(` … ${first} above`));
|
|
72
|
+
for (let i = first; i < last; i++)
|
|
73
|
+
lines.push(stepRow(steps[i], i === running, width, style));
|
|
74
|
+
if (notes)
|
|
75
|
+
lines.push(note(` … ${steps.length - last} below`));
|
|
76
|
+
const done = steps.filter((s) => s.result === 'ok' || s.result === 'failed').length;
|
|
77
|
+
const failed = steps.some((s) => s.result === 'failed');
|
|
78
|
+
lines.push(totalRow(done, steps.length, failed, width, style));
|
|
79
|
+
return lines;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A board on `write`. The first draw prints its lines; each later one moves
|
|
83
|
+
* the cursor back to the board's first line and rewrites them, erasing any
|
|
84
|
+
* line an earlier, taller frame left below. Lines are cut to one column
|
|
85
|
+
* short of the width, so none wraps and the line count stays right.
|
|
86
|
+
*/
|
|
87
|
+
export function stepBoard(write, steps, size, style = PLAIN) {
|
|
88
|
+
let drawn = 0;
|
|
89
|
+
return {
|
|
90
|
+
draw(running) {
|
|
91
|
+
const { width, height } = size();
|
|
92
|
+
const lines = boardLines(steps, running, { width: width - 1, height }, style);
|
|
93
|
+
// A frame shorter than the last one blanks the rows the last one used.
|
|
94
|
+
while (lines.length < drawn)
|
|
95
|
+
lines.push('');
|
|
96
|
+
const up = drawn > 0 ? `\x1b[${drawn}A` : '';
|
|
97
|
+
write(up + lines.map((l) => `\r\x1b[2K${l}\n`).join(''));
|
|
98
|
+
drawn = lines.length;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** This package's version, from its package.json when it was built. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.13.0";
|
package/dist/worktrees.d.ts
CHANGED
|
@@ -46,3 +46,15 @@ export declare function classifyWorktree(opts: {
|
|
|
46
46
|
/** Answers lock-owner and working-directory questions. One per sweep: it caches the process table. */
|
|
47
47
|
probe: ProcessProbe;
|
|
48
48
|
}): SweepItem;
|
|
49
|
+
/** How a branch shows it was worked on, for a worktree merged by ancestry alone. */
|
|
50
|
+
export type BranchUse = 'commits' | 'upstream';
|
|
51
|
+
/**
|
|
52
|
+
* The sign that `branch` was worked on, or null for a branch that looks
|
|
53
|
+
* freshly made. `commits`: its reflog records work (see {@link isWorkEntry}). `upstream`: it tracks a branch on a remote, and not
|
|
54
|
+
* the default branch (`mainBranch`) of any remote, so someone pushed it as
|
|
55
|
+
* its own branch. A branch with neither, such as one `git worktree add -b`
|
|
56
|
+
* just made from the default branch, with or without `--track`, reads as
|
|
57
|
+
* null: removing its worktree could lose ignored files from work about to
|
|
58
|
+
* start.
|
|
59
|
+
*/
|
|
60
|
+
export declare function branchUse(git: Git, branch: string, mainBranch: string): BranchUse | null;
|
package/dist/worktrees.js
CHANGED
|
@@ -360,6 +360,24 @@ export function classifyWorktree(opts) {
|
|
|
360
360
|
}
|
|
361
361
|
if (pr && pr.state === 'OPEN')
|
|
362
362
|
return keep(`open PR #${pr.number}`, { requires });
|
|
363
|
+
// Merged by ancestry alone: a worktree just made from the default branch is
|
|
364
|
+
// merged that way too, so ancestry needs a sign the branch was worked on.
|
|
365
|
+
if (facts['merged'] === true && branch && head !== null) {
|
|
366
|
+
const used = branchUse(git.at(repo.root), branch, repo.mainBranch);
|
|
367
|
+
facts['branch_used'] = used;
|
|
368
|
+
if (used !== null) {
|
|
369
|
+
return makeItem({
|
|
370
|
+
category: 'worktree',
|
|
371
|
+
id: path,
|
|
372
|
+
classification: 'safe-to-delete',
|
|
373
|
+
action: 'remove',
|
|
374
|
+
reason: `clean; merged into ${landedRef(repo)}, and ${USE_REASON[used]}`,
|
|
375
|
+
facts,
|
|
376
|
+
commands: safeRemove,
|
|
377
|
+
requires,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
363
381
|
const parts = [`Worktree \`${path}\` is clean.`];
|
|
364
382
|
if (branch)
|
|
365
383
|
parts.push(`Branch: \`${branch}\`.`);
|
|
@@ -380,3 +398,42 @@ export function classifyWorktree(opts) {
|
|
|
380
398
|
requires,
|
|
381
399
|
});
|
|
382
400
|
}
|
|
401
|
+
const USE_REASON = {
|
|
402
|
+
commits: 'the branch has commits made on it',
|
|
403
|
+
upstream: 'the branch was pushed to its own upstream',
|
|
404
|
+
};
|
|
405
|
+
/**
|
|
406
|
+
* Reflog entries that put work on a branch: a commit, a cherry-pick, an `am`,
|
|
407
|
+
* or a merge or pull that made a merge commit. A fast-forward merge or pull,
|
|
408
|
+
* a rebase, a reset or a rename only moves a branch, which a fresh branch
|
|
409
|
+
* tracking the default branch does on its first `git pull`.
|
|
410
|
+
*/
|
|
411
|
+
function isWorkEntry(entry) {
|
|
412
|
+
if (/^(commit|cherry-pick|am)\b/.test(entry))
|
|
413
|
+
return true;
|
|
414
|
+
return /^(merge|pull)\b/.test(entry) && !/Fast-forward/.test(entry);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* The sign that `branch` was worked on, or null for a branch that looks
|
|
418
|
+
* freshly made. `commits`: its reflog records work (see {@link isWorkEntry}). `upstream`: it tracks a branch on a remote, and not
|
|
419
|
+
* the default branch (`mainBranch`) of any remote, so someone pushed it as
|
|
420
|
+
* its own branch. A branch with neither, such as one `git worktree add -b`
|
|
421
|
+
* just made from the default branch, with or without `--track`, reads as
|
|
422
|
+
* null: removing its worktree could lose ignored files from work about to
|
|
423
|
+
* start.
|
|
424
|
+
*/
|
|
425
|
+
export function branchUse(git, branch, mainBranch) {
|
|
426
|
+
// `raw`: a branch with no reflog (core.logAllRefUpdates off) exits non-zero.
|
|
427
|
+
const reflog = git.raw(['reflog', 'show', '--format=%gs', `refs/heads/${branch}`, '--']);
|
|
428
|
+
if (reflog.exitCode === 0 && reflog.stdout.split('\n').some(isWorkEntry)) {
|
|
429
|
+
return 'commits';
|
|
430
|
+
}
|
|
431
|
+
// From the branch's own config, not the resolved name: a branch tracking a
|
|
432
|
+
// local branch has remote `.`, and one tracking `upstream/main` merges the
|
|
433
|
+
// default branch however its remote is spelled.
|
|
434
|
+
const remote = git.configGet(`branch.${branch}.remote`);
|
|
435
|
+
const merge = git.configGet(`branch.${branch}.merge`);
|
|
436
|
+
if (remote && remote !== '.' && merge && merge !== `refs/heads/${mainBranch}`)
|
|
437
|
+
return 'upstream';
|
|
438
|
+
return null;
|
|
439
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@osdlc/repo-cleaner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "What has been merged into the default branch, and what can go away. Inventories local branches, worktrees and remote branches across one repository or many, classifying each with the evidence behind the verdict and the command that would act on it. Plans and applies approved deletes, compare-and-swap.",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"author": "sksizer",
|
|
@@ -48,13 +48,13 @@
|
|
|
48
48
|
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"zod": "4.
|
|
52
|
-
"@osdlc/cli-tool": "^0.
|
|
51
|
+
"zod": "^4.6.6",
|
|
52
|
+
"@osdlc/cli-tool": "^0.14.0",
|
|
53
53
|
"@osdlc/command-seam": "^0.14.0",
|
|
54
54
|
"@osdlc/path-guard": "^0.3.1",
|
|
55
55
|
"@osdlc/typed-gh": "^0.14.1",
|
|
56
|
-
"@osdlc/
|
|
57
|
-
"@osdlc/
|
|
56
|
+
"@osdlc/ux": "^0.1.1",
|
|
57
|
+
"@osdlc/typed-git": "^0.12.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^26.1.0",
|