@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/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 lines = [repoHeader(sweep)];
91
+ const style = opts.style ?? PLAIN;
92
+ const lines = [style.strong(repoHeader(sweep))];
60
93
  if (sweep.error !== null) {
61
- lines.push(` error: ${sweep.error}`);
94
+ lines.push(` ${style.delete('error')}: ${sweep.error}`);
62
95
  return lines.join('\n');
63
96
  }
64
97
  const shown = sortItems(sweep.items).filter((item) => opts.keep === true || item.classification !== 'safe-to-keep');
@@ -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
- return `${deleteOnly(counts)} to delete, ${prune}${counts['needs-investigation']} to review, across ${repos}${suffix}.`;
171
+ const prune = counts.prune > 0 ? `${style.prune(`${counts.prune} to prune`)}, ` : '';
172
+ const del = style.delete(`${deleteOnly(counts)} to delete`);
173
+ const review = style.review(`${counts['needs-investigation']} to review`);
174
+ return `${del}, ${prune}${review}, across ${repos}${suffix}.`;
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
- blocks.push(scan === null ? renderSummary(result) : `${scan}\n${renderSummary(result)}`);
222
+ const summary = renderSummary(result, style);
223
+ blocks.push(scan === null ? summary : `${scan}\n${summary}`);
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
- blocks.push(scan === null ? renderSummary(result) : `${scan}\n${renderSummary(result)}`);
267
+ const summary = renderSummary(result, opts.style);
268
+ blocks.push(scan === null ? summary : `${scan}\n${summary}`);
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
- if (s.action !== 'unlock-worktree')
330
+ // Rows not reached are rows a run would have deleted; an unlock or a
331
+ // tip check deletes nothing.
332
+ if (s.action !== 'unlock-worktree' && s.action !== 'verify-remote')
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
- for (const d of receipt.dropped) {
347
- lines.push(`dropped ${d.category}:${d.id} — ${d.reason} (${d.detail})`);
456
+ // A run shown live has printed its dropped rows and steps already.
457
+ for (const d of opts.steps === false ? [] : receipt.dropped) {
458
+ const shown = stripControls(`${d.category}:${d.id} — ${d.reason} (${d.detail})`);
459
+ lines.push(`${style.review('dropped ')}${shown}`);
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.result.padEnd(8)} ${what}${tail ? ` (${tail})` : ''}`);
464
+ lines.push(`${resultWord(s, style)} ${stripControls(`${what}${tail ? ` (${tail})` : ''}`)}`);
353
465
  }
354
466
  if (lines.length > 0)
355
467
  lines.push('');
356
- lines.push(...renderApplySummary(receipt));
357
- lines.push(receiptPath === null
468
+ const [counts = '', ...rest] = renderApplySummary(receipt);
469
+ lines.push(style.strong(counts), ...rest.map(style.review));
470
+ lines.push(style.muted(receiptPath === null
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
  }
@@ -5,12 +5,14 @@
5
5
  */
6
6
  import type { Step } from './receipt.ts';
7
7
  import type { SweepProgress } from './types.ts';
8
+ /** A bar `width` cells wide, filled for `done` of `total`, e.g. `█████░░░░░`. */
9
+ export declare function progressBar(done: number, total: number, width?: number): string;
8
10
  /**
9
- * A sweep step as one line, e.g. `[2/5] checking remote branches 37/427 · ~/work/api`.
11
+ * A sweep step as one line, e.g. `[2/5] checking remote branches ████░░░░ 37/427 · ~/work/api`.
10
12
  * The path goes last, so a line cut to the terminal's width loses its tail first.
11
13
  */
12
14
  export declare function describeSweep(p: SweepProgress, home?: string): string;
13
- /** An apply step as one line, e.g. `deleting 3/7: delete-remote origin/feat/x`. */
15
+ /** An apply step as one line, e.g. `deleting ██████░░░░ 3/7: delete-remote origin/feat/x`. */
14
16
  export declare function describeStep(index: number, total: number, step: Step): string;
15
17
  export interface StatusLine {
16
18
  /** Replace the line with `text`. */
@@ -17,19 +17,26 @@ const STEP_TEXT = {
17
17
  function tilde(path, home) {
18
18
  return path === home ? '~' : path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
19
19
  }
20
+ /** A bar `width` cells wide, filled for `done` of `total`, e.g. `█████░░░░░`. */
21
+ export function progressBar(done, total, width = 20) {
22
+ const filled = total <= 0 ? width : Math.min(width, Math.round((done / total) * width));
23
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
24
+ }
20
25
  /**
21
- * A sweep step as one line, e.g. `[2/5] checking remote branches 37/427 · ~/work/api`.
26
+ * A sweep step as one line, e.g. `[2/5] checking remote branches ████░░░░ 37/427 · ~/work/api`.
22
27
  * The path goes last, so a line cut to the terminal's width loses its tail first.
23
28
  */
24
29
  export function describeSweep(p, home = homedir()) {
25
30
  const where = p.of > 1 ? `[${p.index}/${p.of}] ` : '';
26
- const count = p.done === undefined ? '' : ` ${p.done + 1}/${p.total}`;
31
+ const count = p.done === undefined || p.total === undefined
32
+ ? ''
33
+ : ` ${progressBar(p.done, p.total)} ${p.done + 1}/${p.total}`;
27
34
  return `${where}${STEP_TEXT[p.step]}${count} · ${tilde(p.repo, home)}`;
28
35
  }
29
- /** An apply step as one line, e.g. `deleting 3/7: delete-remote origin/feat/x`. */
36
+ /** An apply step as one line, e.g. `deleting ██████░░░░ 3/7: delete-remote origin/feat/x`. */
30
37
  export function describeStep(index, total, step) {
31
38
  const what = step.row === null ? step.repo : step.row.id;
32
- return `deleting ${index + 1}/${total}: ${step.action} ${what}`;
39
+ return `deleting ${progressBar(index, total)} ${index + 1}/${total}: ${step.action} ${what}`;
33
40
  }
34
41
  /** A line drawn with `\r` and erase-line, controls stripped, cut to `columns() - 1` display columns so it never wraps. */
35
42
  export function statusLine(write, columns) {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `apply`'s steps as a vertical list redrawn in place on a terminal: one row
3
+ * per step with its own bar, empty while it waits, half full while it runs,
4
+ * full and ticked when it is done, and a bar for the whole run under the
5
+ * list. The run is synchronous, so the board moves between steps, not on a
6
+ * timer.
7
+ */
8
+ import type { Step } from './receipt.ts';
9
+ import { type Style } from './render.ts';
10
+ /**
11
+ * The board's lines, each at most `width` columns, and at most `height - 1`
12
+ * of them: the cursor sits on the row under the last line, which must stay
13
+ * on screen. That is the run's bar under the rows that fit; a list longer
14
+ * than that shows a window that keeps the running step in view, with a line
15
+ * for the rows above and below it when there is room for one.
16
+ */
17
+ export declare function boardLines(steps: readonly Step[], running: number | null, size: {
18
+ width: number;
19
+ height: number;
20
+ }, style?: Style): string[];
21
+ export interface StepBoard {
22
+ /** Draw with `running` as the step under way, or null between steps. */
23
+ draw(running: number | null): void;
24
+ }
25
+ /**
26
+ * A board on `write`. The first draw prints its lines; each later one moves
27
+ * the cursor back to the board's first line and rewrites them, erasing any
28
+ * line an earlier, taller frame left below. Lines are cut to one column
29
+ * short of the width, so none wraps and the line count stays right.
30
+ */
31
+ export declare function stepBoard(write: (text: string) => void, steps: readonly Step[], size: () => {
32
+ width: number;
33
+ height: number;
34
+ }, style?: Style): StepBoard;
@@ -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.11.0";
2
+ export declare const VERSION = "0.13.0";
@@ -1,3 +1,3 @@
1
1
  // Generated by cli-tool-stamp-version from package.json. Do not edit.
2
2
  /** This package's version, from its package.json when it was built. */
3
- export const VERSION = '0.11.0';
3
+ export const VERSION = '0.13.0';
@@ -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.11.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.4.3",
52
- "@osdlc/cli-tool": "^0.13.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/typed-git": "^0.12.0",
57
- "@osdlc/ux": "^0.1.1"
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",