@osdlc/repo-cleaner 0.11.0 → 0.14.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,29 @@ 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}`;
27
- return `${where}${STEP_TEXT[p.step]}${count} · ${tilde(p.repo, home)}`;
31
+ const count = p.done === undefined || p.total === undefined
32
+ ? ''
33
+ : ` ${progressBar(p.done, p.total)} ${p.done + 1}/${p.total}`;
34
+ const text = p.step === 'prs' && p.tool !== undefined
35
+ ? `listing pull requests (${p.tool})`
36
+ : STEP_TEXT[p.step];
37
+ return `${where}${text}${count} · ${tilde(p.repo, home)}`;
28
38
  }
29
- /** An apply step as one line, e.g. `deleting 3/7: delete-remote origin/feat/x`. */
39
+ /** An apply step as one line, e.g. `deleting ██████░░░░ 3/7: delete-remote origin/feat/x`. */
30
40
  export function describeStep(index, total, step) {
31
41
  const what = step.row === null ? step.repo : step.row.id;
32
- return `deleting ${index + 1}/${total}: ${step.action} ${what}`;
42
+ return `deleting ${progressBar(index, total)} ${index + 1}/${total}: ${step.action} ${what}`;
33
43
  }
34
44
  /** A line drawn with `\r` and erase-line, controls stripped, cut to `columns() - 1` display columns so it never wraps. */
35
45
  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
+ }
package/dist/sweep.d.ts CHANGED
@@ -7,14 +7,20 @@
7
7
  */
8
8
  import type { CommandRunner } from '@osdlc/command-seam';
9
9
  import { type ProcessProbe } from './processes.ts';
10
+ import { type ProviderChoice } from './providers/index.ts';
10
11
  import { type RepoSweep, type Scope, type SweepProgress, type SweepResult } from './types.ts';
11
12
  export interface SweepOptions {
12
13
  /** Which categories to gather. Defaults to all of them. */
13
14
  scope?: Iterable<Scope | string>;
14
15
  /** Remote to measure against. Defaults to `origin`, then the first one. */
15
16
  remote?: string | null;
16
- /** Skip `gh` entirely and classify on git evidence alone. */
17
- noGh?: boolean;
17
+ /** Ask no provider and classify on git evidence alone. Same as `provider: 'none'`, and wins over it. */
18
+ noProvider?: boolean;
19
+ /**
20
+ * Which provider to ask for pull requests: `github` (`gh`), `gitlab`
21
+ * (`glab`), `none`, or `auto`, which reads the remote's host. Defaults to `auto`.
22
+ */
23
+ provider?: ProviderChoice | undefined;
18
24
  /**
19
25
  * Run `git fetch --prune <remote>` before classifying, so ancestry is read
20
26
  * against fresh remote-tracking refs. A failed fetch makes the repository
@@ -34,9 +40,10 @@ export interface SweepOptions {
34
40
  * when that worktree is blocked or cannot be removed.
35
41
  */
36
42
  branch?: string | undefined;
37
- /** Injected runners. Defaults spawn the real `git` and `gh`. */
43
+ /** Injected runners. Defaults spawn the real `git`, `gh` and `glab`. */
38
44
  git?: CommandRunner | undefined;
39
45
  gh?: CommandRunner | undefined;
46
+ glab?: CommandRunner | undefined;
40
47
  /**
41
48
  * Answers whether a worktree lock's owner is alive and which processes work
42
49
  * inside a worktree. Defaults to {@link defaultProcessProbe}, one per
package/dist/sweep.js CHANGED
@@ -11,6 +11,7 @@ import { gatherLocalBranches, gatherRemoteBranches } from "./branches.js";
11
11
  import { DiscoveryFailed, discoverRepo } from "./discover.js";
12
12
  import { defaultProcessProbe } from "./processes.js";
13
13
  import { fetchPrs } from "./prs.js";
14
+ import { providers } from "./providers/index.js";
14
15
  import { resolveEntries } from "./resolve.js";
15
16
  import { ALL_SCOPES, findDuplicateCheckouts, } from "./types.js";
16
17
  import { gatherWorktrees } from "./worktrees.js";
@@ -33,13 +34,15 @@ function clients(path, opts) {
33
34
  export function sweepRepo(path, opts = {}) {
34
35
  const scope = scopeSet(opts.scope);
35
36
  const { git, gh } = clients(path, opts);
37
+ const known = providers({ gh, glab: opts.glab });
36
38
  const only = opts.branch;
37
39
  const at = opts.position ?? { index: 1, of: 1 };
38
- const report = (step, done, total) => opts.onProgress?.({
40
+ const report = (step, done, total, tool) => opts.onProgress?.({
39
41
  repo: path,
40
42
  ...at,
41
43
  step,
42
44
  ...(done === undefined || total === undefined ? {} : { done, total }),
45
+ ...(tool === undefined ? {} : { tool }),
43
46
  });
44
47
  const ticker = (step) => (done, total) => report(step, done, total);
45
48
  report('discover');
@@ -47,9 +50,11 @@ export function sweepRepo(path, opts = {}) {
47
50
  try {
48
51
  repo = discoverRepo(path, {
49
52
  remote: opts.remote ?? null,
50
- allowGh: opts.noGh !== true,
53
+ allowProvider: opts.noProvider !== true,
54
+ provider: opts.provider,
51
55
  git,
52
56
  gh,
57
+ providers: known,
53
58
  });
54
59
  }
55
60
  catch (err) {
@@ -77,9 +82,10 @@ export function sweepRepo(path, opts = {}) {
77
82
  repo = { ...repo, fetched: true };
78
83
  }
79
84
  try {
80
- if (repo.hasGh && repo.ghAuthenticated)
81
- report('prs');
82
- const prs = fetchPrs(repo, gh, only);
85
+ const status = repo.provider ?? null;
86
+ if (status?.authenticated === true)
87
+ report('prs', undefined, undefined, status.tool);
88
+ const prs = status === null ? new Map() : fetchPrs(repo, known[status.name], only);
83
89
  const items = [];
84
90
  // Worktrees are gathered whenever local branches are, even if they are not
85
91
  // reported: a branch checked out in one must not be judged on its own.
package/dist/types.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * by `category` rather than reading a different type, and a host that adds its
6
6
  * own knowledge (an issue tracker's linkage, say) rewrites rows in place.
7
7
  */
8
+ import type { ProviderStatus } from './providers/types.ts';
8
9
  /** What the sweep concluded about an item. */
9
10
  export type Classification = 'safe-to-delete' | 'safe-to-keep' | 'needs-investigation';
10
11
  /** The kinds of thing a sweep inventories. */
@@ -21,6 +22,8 @@ export interface SweepProgress {
21
22
  step: 'discover' | 'fetch' | 'prs' | 'worktrees' | 'local-branches' | 'remote-branches';
22
23
  done?: number;
23
24
  total?: number;
25
+ /** The CLI asked during the `prs` step: `gh`, `glab`. */
26
+ tool?: string;
24
27
  }
25
28
  /** Which categories to gather. */
26
29
  export declare const ALL_SCOPES: readonly ["local-branches", "worktrees", "remote-branches"];
@@ -47,8 +50,12 @@ export interface RepoInfo {
47
50
  mainBranch: string;
48
51
  remote: string | null;
49
52
  currentBranch: string;
50
- hasGh: boolean;
51
- ghAuthenticated: boolean;
53
+ /**
54
+ * The provider asked for pull requests and what its probe found: whether
55
+ * its CLI is installed and signed in. Null when none was asked
56
+ * (`--provider none`, `--no-gh`, or a sweep without `--remotes`).
57
+ */
58
+ provider: ProviderStatus | null;
52
59
  /** True when the sweep fetched the remote before classifying. */
53
60
  fetched: boolean;
54
61
  }
@@ -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.14.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.14.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
+ }