@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.
- package/CHANGELOG.md +43 -0
- package/README.md +135 -19
- package/dist/apply.d.ts +30 -1
- package/dist/apply.js +118 -17
- package/dist/cli.d.ts +6 -1
- package/dist/cli.js +192 -30
- 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/remote-mirror.d.ts +55 -0
- package/dist/remote-mirror.js +123 -0
- package/dist/render.d.ts +48 -5
- package/dist/render.js +151 -29
- 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/types.d.ts +4 -0
- package/dist/types.js +14 -1
- 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 +58 -1
- package/package.json +8 -8
|
@@ -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 {
|
|
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.
|
|
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
|
@@ -293,7 +293,7 @@ export function classifyWorktree(opts) {
|
|
|
293
293
|
category: 'worktree',
|
|
294
294
|
id: path,
|
|
295
295
|
classification: 'safe-to-delete',
|
|
296
|
-
action: '
|
|
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.
|
|
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.
|
|
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/
|
|
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/
|
|
67
|
-
"@osdlc/
|
|
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",
|