@osdlc/repo-cleaner 0.10.1 → 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.
@@ -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/types.d.ts CHANGED
@@ -155,12 +155,16 @@ export interface SummaryBucket {
155
155
  /** Rows whose work is on the default branch (`facts.merged`). Rows kept for structure are not judged. */
156
156
  merged: number;
157
157
  'safe-to-delete': number;
158
+ /** The `safe-to-delete` rows that only prune a stale worktree link (`action: 'prune'`); the directory, if any, stays. */
159
+ prune: number;
158
160
  'safe-to-keep': number;
159
161
  'needs-investigation': number;
160
162
  }
161
163
  export declare function emptyBucket(): SummaryBucket;
162
164
  /** Per-category counts over `items`. */
163
165
  export declare function summarize(items: readonly SweepItem[]): Record<string, SummaryBucket>;
166
+ /** A row whose deletion only prunes a stale worktree link. */
167
+ export declare function isPrune(item: Pick<SweepItem, 'classification' | 'action'>): boolean;
164
168
  type Defaulted = 'facts' | 'commands' | 'investigationParts' | 'requires' | 'deletable';
165
169
  /** Build an item, defaulting the collection fields and `deletable: true`. */
166
170
  export declare function makeItem(partial: Omit<SweepItem, Defaulted> & Partial<Pick<SweepItem, Defaulted>>): SweepItem;
package/dist/types.js CHANGED
@@ -48,7 +48,14 @@ export function emptyScan() {
48
48
  return { containers: 0, directoriesScanned: 0, depthLimited: 0, checkoutsFound: 0 };
49
49
  }
50
50
  export function emptyBucket() {
51
- return { total: 0, merged: 0, 'safe-to-delete': 0, 'safe-to-keep': 0, 'needs-investigation': 0 };
51
+ return {
52
+ total: 0,
53
+ merged: 0,
54
+ 'safe-to-delete': 0,
55
+ prune: 0,
56
+ 'safe-to-keep': 0,
57
+ 'needs-investigation': 0,
58
+ };
52
59
  }
53
60
  /** Per-category counts over `items`. */
54
61
  export function summarize(items) {
@@ -58,10 +65,16 @@ export function summarize(items) {
58
65
  bucket.total += 1;
59
66
  if (item.facts['merged'] === true)
60
67
  bucket.merged += 1;
68
+ if (isPrune(item))
69
+ bucket.prune += 1;
61
70
  bucket[item.classification] += 1;
62
71
  }
63
72
  return out;
64
73
  }
74
+ /** A row whose deletion only prunes a stale worktree link. */
75
+ export function isPrune(item) {
76
+ return item.classification === 'safe-to-delete' && item.action === 'prune';
77
+ }
65
78
  /** Build an item, defaulting the collection fields and `deletable: true`. */
66
79
  export function makeItem(partial) {
67
80
  return {
@@ -1,2 +1,2 @@
1
1
  /** This package's version, from its package.json when it was built. */
2
- export declare const VERSION = "0.10.1";
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.10.1';
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
@@ -293,7 +293,7 @@ export function classifyWorktree(opts) {
293
293
  category: 'worktree',
294
294
  id: path,
295
295
  classification: 'safe-to-delete',
296
- action: 'remove',
296
+ action: 'prune',
297
297
  reason: dirPresent
298
298
  ? 'directory is no longer a checkout (no .git); prune the stale link, the directory stays'
299
299
  : 'worktree directory no longer on disk; prune the stale link',
@@ -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.10.1",
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.12.0",
53
- "@osdlc/path-guard": "^0.3.1",
51
+ "zod": "^4.6.6",
52
+ "@osdlc/cli-tool": "^0.14.0",
54
53
  "@osdlc/command-seam": "^0.14.0",
55
- "@osdlc/typed-git": "^0.12.0",
54
+ "@osdlc/path-guard": "^0.3.1",
56
55
  "@osdlc/typed-gh": "^0.14.1",
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",
@@ -63,8 +63,8 @@
63
63
  "prettier": "3.9.6",
64
64
  "typescript": "~6.0.3",
65
65
  "vitest": "^5.0.5",
66
- "@osdlc/tsconfig": "^0.4.1",
67
- "@osdlc/prettier-config": "^0.3.1"
66
+ "@osdlc/prettier-config": "^0.3.1",
67
+ "@osdlc/tsconfig": "^0.4.1"
68
68
  },
69
69
  "scripts": {
70
70
  "build": "cli-tool-stamp-version && tsc -p tsconfig.build.json",