@osdlc/repo-cleaner 0.10.0 → 0.11.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 +31 -0
- package/README.md +56 -6
- package/dist/apply.d.ts +23 -2
- package/dist/apply.js +75 -2
- package/dist/branches.d.ts +10 -0
- package/dist/branches.js +15 -5
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +218 -109
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/receipt.d.ts +4 -0
- package/dist/receipt.js +11 -2
- package/dist/remote-mirror.d.ts +55 -0
- package/dist/remote-mirror.js +123 -0
- package/dist/render.d.ts +25 -2
- package/dist/render.js +92 -13
- 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.js +8 -6
- package/package.json +2 -2
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sweep a remote with no checkout of your own: a clone of it, kept in
|
|
3
|
+
* repo-cleaner's cache directory, stands in for the checkout.
|
|
4
|
+
*
|
|
5
|
+
* The clone is `--no-checkout`, so no files are written outside `.git`. It is
|
|
6
|
+
* not a partial (`--filter=blob:none`) clone: the patch comparison that
|
|
7
|
+
* finds squash-merged branches reads file contents, and a partial clone
|
|
8
|
+
* fetches them over the network one comparison at a time, which made a sweep
|
|
9
|
+
* of a few hundred branches take minutes instead of seconds. It is kept
|
|
10
|
+
* rather than thrown away so a plan written from it can be applied later: the
|
|
11
|
+
* plan names the clone's directory, and `apply` re-sweeps and pushes deletes
|
|
12
|
+
* from there. A later run reuses it, and the sweep's fetch brings it up to
|
|
13
|
+
* date.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { Git, parseRemoteRepo } from '@osdlc/typed-git';
|
|
19
|
+
/** A remote URL that cannot be mirrored, or a mirror that cannot be made or reused. */
|
|
20
|
+
export class RemoteMirrorError extends Error {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'RemoteMirrorError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Characters kept in a directory name; anything else becomes `-`. */
|
|
27
|
+
const UNSAFE = /[^A-Za-z0-9._-]+/g;
|
|
28
|
+
/**
|
|
29
|
+
* `text` with every URL's credentials removed: the user-info before `@` in a
|
|
30
|
+
* `scheme://` URL, and any query string or fragment. For a message or a name
|
|
31
|
+
* that must not carry a token.
|
|
32
|
+
*/
|
|
33
|
+
export function redactUrl(text) {
|
|
34
|
+
return text
|
|
35
|
+
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, '$1')
|
|
36
|
+
.replace(/([a-z][a-z0-9+.-]*:\/\/[^\s?#]*)[?#]\S*/gi, '$1');
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The directory name for `url`'s mirror: readable `host-owner-repo`, then a
|
|
40
|
+
* hash of the whole URL so two URLs never share a mirror. The readable part
|
|
41
|
+
* is read from the URL with its credentials, query and fragment removed, so a
|
|
42
|
+
* token never reaches the name.
|
|
43
|
+
*/
|
|
44
|
+
export function mirrorName(url) {
|
|
45
|
+
const hash = createHash('sha256').update(url).digest('hex').slice(0, 12);
|
|
46
|
+
const parsed = parseRemoteRepo(redactUrl(url));
|
|
47
|
+
const readable = parsed === null
|
|
48
|
+
? 'remote'
|
|
49
|
+
: `${parsed.host}-${parsed.owner}-${parsed.repo}`.replace(UNSAFE, '-');
|
|
50
|
+
return `${readable.replace(/^[.-]+/, '')}-${hash}`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Why `dir` is not a mirror of `url` — or of whatever it was cloned from, when
|
|
54
|
+
* `url` is omitted — or null when it is. A mirror's `origin` has one fetch URL
|
|
55
|
+
* and pushes to that URL alone, so a delete cannot be sent elsewhere.
|
|
56
|
+
*/
|
|
57
|
+
export function mirrorProblem(dir, url, opts = {}) {
|
|
58
|
+
const git = new Git(dir, { runner: opts.git });
|
|
59
|
+
const fetch = git.try.remoteGetUrls('origin');
|
|
60
|
+
const push = git.try.remoteGetUrls('origin', { push: true });
|
|
61
|
+
if (!fetch.ok || !push.ok)
|
|
62
|
+
return `${dir} has no readable origin remote`;
|
|
63
|
+
const [from] = fetch.value;
|
|
64
|
+
if (fetch.value.length !== 1 || from === undefined) {
|
|
65
|
+
return `${dir} has ${fetch.value.length} origin URLs`;
|
|
66
|
+
}
|
|
67
|
+
if (url !== undefined && from !== url)
|
|
68
|
+
return `${dir} is a mirror of another URL`;
|
|
69
|
+
if (push.value.length !== 1 || push.value[0] !== from) {
|
|
70
|
+
return `${dir} pushes somewhere other than the URL it was cloned from`;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The directory of `url`'s mirror under `cacheDir`, cloned on first use. An
|
|
76
|
+
* existing mirror is reused as it is, once {@link mirrorProblem} finds
|
|
77
|
+
* nothing wrong with it; the sweep's own fetch brings it up to date. A first
|
|
78
|
+
* clone goes to a sibling directory and is renamed into place, so a clone
|
|
79
|
+
* that fails leaves nothing behind, and a run that loses a race to clone the
|
|
80
|
+
* same URL uses the winner's clone. Messages carry the URL with its
|
|
81
|
+
* credentials removed.
|
|
82
|
+
*
|
|
83
|
+
* @throws RemoteMirrorError when `url` is empty or looks like an option, when
|
|
84
|
+
* the clone fails, or when the directory is not a mirror of `url`.
|
|
85
|
+
*/
|
|
86
|
+
export function ensureMirror(url, cacheDir, opts = {}) {
|
|
87
|
+
if (url === '' || url.startsWith('-')) {
|
|
88
|
+
throw new RemoteMirrorError(`not a remote URL: ${JSON.stringify(redactUrl(url))}`);
|
|
89
|
+
}
|
|
90
|
+
const shown = redactUrl(url);
|
|
91
|
+
const dir = join(cacheDir, mirrorName(url));
|
|
92
|
+
if (existsSync(dir)) {
|
|
93
|
+
const problem = mirrorProblem(dir, url, opts);
|
|
94
|
+
if (problem !== null) {
|
|
95
|
+
throw new RemoteMirrorError(`${problem}, not a mirror of ${shown}; remove the directory and run again`);
|
|
96
|
+
}
|
|
97
|
+
return dir;
|
|
98
|
+
}
|
|
99
|
+
mkdirSync(dirname(dir), { recursive: true });
|
|
100
|
+
const staging = `${dir}.partial-${process.pid}`;
|
|
101
|
+
rmSync(staging, { recursive: true, force: true });
|
|
102
|
+
try {
|
|
103
|
+
new Git(dirname(dir), { runner: opts.git }).rawChecked([
|
|
104
|
+
'clone',
|
|
105
|
+
'--quiet',
|
|
106
|
+
'--no-checkout',
|
|
107
|
+
'--',
|
|
108
|
+
url,
|
|
109
|
+
staging,
|
|
110
|
+
]);
|
|
111
|
+
renameSync(staging, dir);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
rmSync(staging, { recursive: true, force: true });
|
|
115
|
+
// Another run cloned the same URL first and won the rename: use its clone.
|
|
116
|
+
if (existsSync(dir) && mirrorProblem(dir, url, opts) === null)
|
|
117
|
+
return dir;
|
|
118
|
+
const detail = err.failure?.detail ??
|
|
119
|
+
(err instanceof Error ? err.message : String(err));
|
|
120
|
+
throw new RemoteMirrorError(`cannot clone ${shown}: ${redactUrl(detail.split(url).join(shown)).trim()}`);
|
|
121
|
+
}
|
|
122
|
+
return dir;
|
|
123
|
+
}
|
package/dist/render.d.ts
CHANGED
|
@@ -30,7 +30,7 @@ export declare function renderScan(result: SweepResult): string | null;
|
|
|
30
30
|
export declare function renderSummary(result: SweepResult): string;
|
|
31
31
|
/**
|
|
32
32
|
* One repository as counts: a row per category with its total, how many are
|
|
33
|
-
* merged, and how many the sweep would delete, review or keep.
|
|
33
|
+
* merged, and how many the sweep would delete, prune, review or keep.
|
|
34
34
|
*/
|
|
35
35
|
export declare function renderCounts(sweep: RepoSweep): string;
|
|
36
36
|
/** The whole run as counts: a table per repository, then the one-line summary. */
|
|
@@ -48,5 +48,28 @@ export declare function renderText(result: SweepResult, opts?: {
|
|
|
48
48
|
export declare function renderJson(result: SweepResult, extra?: Record<string, unknown>): string;
|
|
49
49
|
/** Every command a `safe-to-delete` row that needs no authorization proposes, in report order. */
|
|
50
50
|
export declare function proposedCommands(result: SweepResult): string[];
|
|
51
|
-
/**
|
|
51
|
+
/** What an apply run did, or would do, counted by kind. */
|
|
52
|
+
export interface ApplySummary {
|
|
53
|
+
/** Rows deleted (or, in a dry run, that would be), by category. Pruned worktrees are not counted here. */
|
|
54
|
+
deleted: {
|
|
55
|
+
worktree: number;
|
|
56
|
+
local_branch: number;
|
|
57
|
+
remote_branch: number;
|
|
58
|
+
};
|
|
59
|
+
/** Worktrees whose stale registration was pruned; their directories were gone or left alone. */
|
|
60
|
+
pruned: number;
|
|
61
|
+
/** Dropped rows by reason. */
|
|
62
|
+
dropped: Record<string, number>;
|
|
63
|
+
/** Steps that failed: 0 or 1, since the first failure stops the run. */
|
|
64
|
+
failed: number;
|
|
65
|
+
/** Row steps never reached because a step failed. */
|
|
66
|
+
notRun: number;
|
|
67
|
+
}
|
|
68
|
+
/** Count a receipt's outcome. A dry run's `would-run` steps count as done. */
|
|
69
|
+
export declare function summarizeReceipt(receipt: Receipt): ApplySummary;
|
|
70
|
+
/** Rows a receipt deletes or prunes, which is what a person is asked to approve. */
|
|
71
|
+
export declare function rowsActedOn(summary: ApplySummary): number;
|
|
72
|
+
/** The closing lines of an apply run: what it deleted, dropped and could not finish. */
|
|
73
|
+
export declare function renderApplySummary(receipt: Receipt): string[];
|
|
74
|
+
/** An apply run as text: dropped rows, each step, a summary, then where the receipt is. */
|
|
52
75
|
export declare function renderReceipt(receipt: Receipt, receiptPath: string | null): string;
|
package/dist/render.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* find. JSON carries everything the text report elides, including the facts
|
|
7
7
|
* behind every verdict.
|
|
8
8
|
*/
|
|
9
|
-
import { emptyBucket, isUnfetched, summarize, } from "./types.js";
|
|
9
|
+
import { emptyBucket, isPrune, isUnfetched, summarize, } from "./types.js";
|
|
10
10
|
import { hasBlockers } from "./worktrees.js";
|
|
11
11
|
/** Order categories consistently, whatever order they were gathered in. */
|
|
12
12
|
const CATEGORY_ORDER = ['local_branch', 'worktree', 'remote_branch'];
|
|
@@ -69,7 +69,9 @@ export function renderRepo(sweep, opts = {}) {
|
|
|
69
69
|
const idWidth = Math.min(38, Math.max(...shown.map((item) => ellipsize(item.id, 38).length)));
|
|
70
70
|
const categoryWidth = Math.max(...shown.map((item) => item.category.length));
|
|
71
71
|
for (const item of shown) {
|
|
72
|
-
const action =
|
|
72
|
+
const action = isPrune(item)
|
|
73
|
+
? 'prune'
|
|
74
|
+
: (ACTION_LABEL[item.classification] ?? item.classification);
|
|
73
75
|
const note = requiresNote(item);
|
|
74
76
|
lines.push(` ${pad(item.category, categoryWidth)} ${pad(ellipsize(item.id, 38), idWidth)} ${pad(action, 6)} ${item.reason}${note ? `; ${note}` : ''}`);
|
|
75
77
|
// A blocked row has no commands; what to do about each blocker goes there instead.
|
|
@@ -86,6 +88,7 @@ function totals(result) {
|
|
|
86
88
|
out.total += bucket.total;
|
|
87
89
|
out.merged += bucket.merged;
|
|
88
90
|
out['safe-to-delete'] += bucket['safe-to-delete'];
|
|
91
|
+
out.prune += bucket.prune;
|
|
89
92
|
out['safe-to-keep'] += bucket['safe-to-keep'];
|
|
90
93
|
out['needs-investigation'] += bucket['needs-investigation'];
|
|
91
94
|
}
|
|
@@ -116,6 +119,10 @@ export function renderScan(result) {
|
|
|
116
119
|
return line;
|
|
117
120
|
return `${line} ${count(scan.depthLimited)} left unopened at the depth limit.`;
|
|
118
121
|
}
|
|
122
|
+
/** The `safe-to-delete` rows that delete something, not the ones that only prune a stale link. */
|
|
123
|
+
function deleteOnly(bucket) {
|
|
124
|
+
return bucket['safe-to-delete'] - bucket.prune;
|
|
125
|
+
}
|
|
119
126
|
/** The one-line verdict a person reads first, and often only. */
|
|
120
127
|
export function renderSummary(result) {
|
|
121
128
|
const counts = totals(result);
|
|
@@ -128,24 +135,26 @@ export function renderSummary(result) {
|
|
|
128
135
|
tail.push(`${result.unresolved.length} ${result.unresolved.length === 1 ? 'entry' : 'entries'} unresolved`);
|
|
129
136
|
}
|
|
130
137
|
const suffix = tail.length > 0 ? ` (${tail.join(', ')})` : '';
|
|
131
|
-
|
|
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}.`;
|
|
132
140
|
}
|
|
133
141
|
const COUNT_LABEL = {
|
|
134
142
|
local_branch: 'local branches',
|
|
135
143
|
worktree: 'worktrees',
|
|
136
144
|
remote_branch: 'remote branches',
|
|
137
145
|
};
|
|
146
|
+
/** A count table's columns. `delete` leaves out the rows `prune` counts, so each row is in one of delete, prune, review and keep. */
|
|
138
147
|
const COUNT_COLUMNS = [
|
|
139
|
-
'total',
|
|
140
|
-
'merged',
|
|
141
|
-
'
|
|
142
|
-
'
|
|
143
|
-
'
|
|
148
|
+
{ heading: 'total', of: (b) => b.total },
|
|
149
|
+
{ heading: 'merged', of: (b) => b.merged },
|
|
150
|
+
{ heading: 'delete', of: deleteOnly },
|
|
151
|
+
{ heading: 'prune', of: (b) => b.prune },
|
|
152
|
+
{ heading: 'review', of: (b) => b['needs-investigation'] },
|
|
153
|
+
{ heading: 'keep', of: (b) => b['safe-to-keep'] },
|
|
144
154
|
];
|
|
145
|
-
const COUNT_HEADINGS = ['total', 'merged', 'delete', 'review', 'keep'];
|
|
146
155
|
/**
|
|
147
156
|
* One repository as counts: a row per category with its total, how many are
|
|
148
|
-
* merged, and how many the sweep would delete, review or keep.
|
|
157
|
+
* merged, and how many the sweep would delete, prune, review or keep.
|
|
149
158
|
*/
|
|
150
159
|
export function renderCounts(sweep) {
|
|
151
160
|
const lines = [repoHeader(sweep)];
|
|
@@ -156,10 +165,10 @@ export function renderCounts(sweep) {
|
|
|
156
165
|
const buckets = summarize(sweep.items);
|
|
157
166
|
const labelWidth = Math.max(...Object.values(COUNT_LABEL).map((l) => l.length));
|
|
158
167
|
const cell = (text, heading) => text.padStart(Math.max(heading.length, 5));
|
|
159
|
-
lines.push(` ${' '.repeat(labelWidth)} ${
|
|
168
|
+
lines.push(` ${' '.repeat(labelWidth)} ${COUNT_COLUMNS.map(({ heading }) => cell(heading, heading)).join(' ')}`);
|
|
160
169
|
for (const category of CATEGORY_ORDER) {
|
|
161
170
|
const bucket = buckets[category] ?? emptyBucket();
|
|
162
|
-
const cells = COUNT_COLUMNS.map((
|
|
171
|
+
const cells = COUNT_COLUMNS.map(({ heading, of }) => cell(count(of(bucket)), heading));
|
|
163
172
|
lines.push(` ${pad(COUNT_LABEL[category], labelWidth)} ${cells.join(' ')}`);
|
|
164
173
|
}
|
|
165
174
|
return lines.join('\n');
|
|
@@ -264,7 +273,74 @@ export function proposedCommands(result) {
|
|
|
264
273
|
}
|
|
265
274
|
return out;
|
|
266
275
|
}
|
|
267
|
-
/**
|
|
276
|
+
/** Count a receipt's outcome. A dry run's `would-run` steps count as done. */
|
|
277
|
+
export function summarizeReceipt(receipt) {
|
|
278
|
+
const out = {
|
|
279
|
+
deleted: { worktree: 0, local_branch: 0, remote_branch: 0 },
|
|
280
|
+
pruned: 0,
|
|
281
|
+
dropped: {},
|
|
282
|
+
failed: 0,
|
|
283
|
+
notRun: 0,
|
|
284
|
+
};
|
|
285
|
+
for (const d of receipt.dropped)
|
|
286
|
+
out.dropped[d.reason] = (out.dropped[d.reason] ?? 0) + 1;
|
|
287
|
+
for (const s of receipt.steps) {
|
|
288
|
+
if (s.result === 'failed')
|
|
289
|
+
out.failed += 1;
|
|
290
|
+
if (s.row === null)
|
|
291
|
+
continue;
|
|
292
|
+
if (s.result === 'pending') {
|
|
293
|
+
if (s.action !== 'unlock-worktree')
|
|
294
|
+
out.notRun += 1;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (s.result !== 'ok' && s.result !== 'would-run')
|
|
298
|
+
continue;
|
|
299
|
+
if (s.action === 'remove-worktree')
|
|
300
|
+
out.deleted.worktree += 1;
|
|
301
|
+
else if (s.action === 'prune-worktrees')
|
|
302
|
+
out.pruned += 1;
|
|
303
|
+
else if (s.action === 'delete-local')
|
|
304
|
+
out.deleted.local_branch += 1;
|
|
305
|
+
else if (s.action === 'delete-remote')
|
|
306
|
+
out.deleted.remote_branch += 1;
|
|
307
|
+
}
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
/** Rows a receipt deletes or prunes, which is what a person is asked to approve. */
|
|
311
|
+
export function rowsActedOn(summary) {
|
|
312
|
+
const d = summary.deleted;
|
|
313
|
+
return d.worktree + d.local_branch + d.remote_branch + summary.pruned;
|
|
314
|
+
}
|
|
315
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
316
|
+
/** The closing lines of an apply run: what it deleted, dropped and could not finish. */
|
|
317
|
+
export function renderApplySummary(receipt) {
|
|
318
|
+
const s = summarizeReceipt(receipt);
|
|
319
|
+
const dry = receipt.status === 'dry-run';
|
|
320
|
+
const parts = [
|
|
321
|
+
s.deleted.worktree > 0 ? plural(s.deleted.worktree, 'worktree', 'worktrees') : null,
|
|
322
|
+
s.deleted.local_branch > 0
|
|
323
|
+
? plural(s.deleted.local_branch, 'local branch', 'local branches')
|
|
324
|
+
: null,
|
|
325
|
+
s.deleted.remote_branch > 0
|
|
326
|
+
? plural(s.deleted.remote_branch, 'remote branch', 'remote branches')
|
|
327
|
+
: null,
|
|
328
|
+
s.pruned > 0 ? `${plural(s.pruned, 'stale worktree', 'stale worktrees')} pruned` : null,
|
|
329
|
+
].filter((p) => p !== null);
|
|
330
|
+
const lines = [
|
|
331
|
+
`${dry ? 'would delete' : 'deleted'}: ${parts.length > 0 ? parts.join(', ') : 'nothing'}`,
|
|
332
|
+
];
|
|
333
|
+
const dropped = Object.entries(s.dropped);
|
|
334
|
+
if (dropped.length > 0) {
|
|
335
|
+
const total = dropped.reduce((n, [, c]) => n + c, 0);
|
|
336
|
+
lines.push(`dropped ${total}: ${dropped.map(([reason, c]) => `${c} ${reason}`).join(', ')}`);
|
|
337
|
+
}
|
|
338
|
+
if (s.failed > 0) {
|
|
339
|
+
lines.push(`stopped at a failed step; ${plural(s.notRun, 'row', 'rows')} not run`);
|
|
340
|
+
}
|
|
341
|
+
return lines;
|
|
342
|
+
}
|
|
343
|
+
/** An apply run as text: dropped rows, each step, a summary, then where the receipt is. */
|
|
268
344
|
export function renderReceipt(receipt, receiptPath) {
|
|
269
345
|
const lines = [];
|
|
270
346
|
for (const d of receipt.dropped) {
|
|
@@ -275,6 +351,9 @@ export function renderReceipt(receipt, receiptPath) {
|
|
|
275
351
|
const tail = s.error ?? s.verification;
|
|
276
352
|
lines.push(`${s.result.padEnd(8)} ${what}${tail ? ` (${tail})` : ''}`);
|
|
277
353
|
}
|
|
354
|
+
if (lines.length > 0)
|
|
355
|
+
lines.push('');
|
|
356
|
+
lines.push(...renderApplySummary(receipt));
|
|
278
357
|
lines.push(receiptPath === null
|
|
279
358
|
? `status: ${receipt.status}`
|
|
280
359
|
: `receipt: ${receiptPath} (${receipt.status})`);
|
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.11.0";
|
package/dist/worktrees.js
CHANGED
|
@@ -9,7 +9,7 @@ import { existsSync, statSync } from 'node:fs';
|
|
|
9
9
|
import { isAbsolute, join, sep } from 'node:path';
|
|
10
10
|
import { shellJoin } from '@osdlc/command-seam';
|
|
11
11
|
import { nodeGuard } from '@osdlc/path-guard/node';
|
|
12
|
-
import { gapSentence, recordPr } from "./branches.js";
|
|
12
|
+
import { gapSentence, mergedFact, recordPr } from "./branches.js";
|
|
13
13
|
import { confirmationNote, judgeLanding, landingConfirmed, recordLanding, unmatchedSentence, } from "./landing.js";
|
|
14
14
|
import { busyHolders, isIdleHolder, parseLockOwner } from "./processes.js";
|
|
15
15
|
import { authorizationsFor, landedRef, makeItem, } from "./types.js";
|
|
@@ -251,10 +251,12 @@ export function classifyWorktree(opts) {
|
|
|
251
251
|
const patchLanded = patch !== null && patch.check !== null;
|
|
252
252
|
const unlanded = unpushed.length > 0 && !landed && !landingConfirmed(patch);
|
|
253
253
|
if (onDisk && head !== null) {
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
254
|
+
// `soft`: a missing default ref reads as not merged.
|
|
255
|
+
facts['merged'] = mergedFact({
|
|
256
|
+
landed,
|
|
257
|
+
patchLanded,
|
|
258
|
+
ancestor: git.at(repo.root).soft.isAncestor(head, landedRef(repo)),
|
|
259
|
+
});
|
|
258
260
|
}
|
|
259
261
|
const requires = authorizationsFor({
|
|
260
262
|
'abandon-unmerged': unlanded,
|
|
@@ -291,7 +293,7 @@ export function classifyWorktree(opts) {
|
|
|
291
293
|
category: 'worktree',
|
|
292
294
|
id: path,
|
|
293
295
|
classification: 'safe-to-delete',
|
|
294
|
-
action: '
|
|
296
|
+
action: 'prune',
|
|
295
297
|
reason: dirPresent
|
|
296
298
|
? 'directory is no longer a checkout (no .git); prune the stale link, the directory stays'
|
|
297
299
|
: 'worktree directory no longer on disk; prune the stale link',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@osdlc/repo-cleaner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"zod": "4.4.3",
|
|
52
|
-
"@osdlc/cli-tool": "^0.
|
|
52
|
+
"@osdlc/cli-tool": "^0.13.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",
|