@osdlc/repo-cleaner 0.18.0 → 0.19.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 +13 -0
- package/README.md +238 -20
- package/dist/branches.d.ts +29 -1
- package/dist/branches.js +111 -28
- package/dist/clean.d.ts +22 -5
- package/dist/clean.js +88 -19
- package/dist/cli.js +32 -4
- package/dist/index.d.ts +1 -1
- package/dist/providers/github.d.ts +2 -1
- package/dist/providers/github.js +2 -1
- package/dist/providers/gitlab.d.ts +3 -2
- package/dist/providers/gitlab.js +14 -8
- package/dist/providers/index.d.ts +5 -4
- package/dist/providers/index.js +8 -5
- package/dist/providers/types.d.ts +8 -2
- package/dist/prs.d.ts +35 -7
- package/dist/prs.js +45 -12
- package/dist/receipt.d.ts +2 -0
- package/dist/receipt.js +6 -0
- package/dist/reference.js +12 -8
- package/dist/remote-mirror.d.ts +1 -1
- package/dist/remote-mirror.js +1 -1
- package/dist/render.d.ts +6 -1
- package/dist/render.js +12 -2
- package/dist/resolve.d.ts +3 -3
- package/dist/resolve.js +3 -3
- package/dist/sweep.d.ts +14 -1
- package/dist/sweep.js +67 -7
- package/dist/types.d.ts +20 -2
- package/dist/types.js +8 -2
- package/dist/util.d.ts +7 -1
- package/dist/util.js +18 -2
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +4 -4
package/dist/prs.js
CHANGED
|
@@ -6,37 +6,70 @@
|
|
|
6
6
|
* one listing covers. Which forge answers is the provider's business; see
|
|
7
7
|
* `providers/`.
|
|
8
8
|
*/
|
|
9
|
+
import { errorText } from "./util.js";
|
|
10
|
+
/**
|
|
11
|
+
* Why open pull requests cannot be ruled out for this repository, or null
|
|
12
|
+
* when the provider answered. A missing or signed-out CLI was never asked, and
|
|
13
|
+
* a failed listing answered nothing; either way a remote branch with no known
|
|
14
|
+
* PR may still have an open one. Every sentence about a lookup that did not
|
|
15
|
+
* answer is built from this one answer.
|
|
16
|
+
*/
|
|
17
|
+
export function prsUnchecked(status) {
|
|
18
|
+
if (!status.available)
|
|
19
|
+
return { kind: 'not-installed', why: `\`${status.tool}\` is not installed` };
|
|
20
|
+
if (!status.authenticated) {
|
|
21
|
+
return { kind: 'not-signed-in', why: `\`${status.tool}\` is not signed in` };
|
|
22
|
+
}
|
|
23
|
+
if (status.listError !== undefined) {
|
|
24
|
+
return { kind: 'list-failed', why: `\`${status.listing}\` failed` };
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
9
28
|
/** Open outranks merged outranks closed, and a newer PR outranks an older. */
|
|
10
29
|
function priority(state) {
|
|
11
30
|
return { OPEN: 3, MERGED: 2, CLOSED: 1 }[state] ?? 0;
|
|
12
31
|
}
|
|
13
32
|
/**
|
|
14
|
-
* Every same-repository pull request, keyed by head branch
|
|
15
|
-
*
|
|
16
|
-
* about ours.
|
|
33
|
+
* Every same-repository pull request, keyed by head branch, and why there are
|
|
34
|
+
* none when the listing failed. Fork PRs are dropped by the provider: a fork
|
|
35
|
+
* branch that shares a name proves nothing about ours.
|
|
17
36
|
*
|
|
18
37
|
* A branch can carry several PRs over its life. The one that decides a
|
|
19
38
|
* verdict is the most live: an open PR means keep whatever else happened, and
|
|
20
39
|
* a merged one means the work landed. Ties go to the higher number, so two
|
|
21
40
|
* runs over unchanged state agree.
|
|
22
41
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
42
|
+
* The map is empty when the provider's CLI is missing or unauthenticated, when
|
|
43
|
+
* there is no provider, and when the listing fails. A provider that answers
|
|
44
|
+
* null or throws has failed (a repository the signed-in account cannot see, a
|
|
45
|
+
* forge that is down, output that cannot be read), and `error` says why.
|
|
46
|
+
* Classification then degrades to git evidence, and the sweep notes it rather
|
|
47
|
+
* than losing every row to one listing.
|
|
26
48
|
*
|
|
27
49
|
* @param provider The provider to ask. It is asked only when it is the one
|
|
28
50
|
* `repo.provider` names and that probe found it signed in.
|
|
29
51
|
* @param branch Ask only for PRs whose head is exactly this branch; rows
|
|
30
52
|
* for any other head are dropped here too.
|
|
31
53
|
*/
|
|
32
|
-
export function
|
|
54
|
+
export function lookupPrs(repo, provider, branch) {
|
|
33
55
|
const byHead = new Map();
|
|
34
56
|
const status = repo.provider;
|
|
35
|
-
if (status === null || status.name !== provider.name || !status.authenticated)
|
|
36
|
-
return byHead;
|
|
37
|
-
|
|
57
|
+
if (status === null || status.name !== provider.name || !status.authenticated) {
|
|
58
|
+
return { prs: byHead, error: null };
|
|
59
|
+
}
|
|
60
|
+
let rows;
|
|
61
|
+
try {
|
|
62
|
+
rows = provider.listPrs(repo.root, status, branch);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
// A bug in the provider is not a listing that failed: let it fail loudly,
|
|
66
|
+
// as an error on the repository, rather than gate every remote branch.
|
|
67
|
+
if (err instanceof TypeError || err instanceof ReferenceError)
|
|
68
|
+
throw err;
|
|
69
|
+
return { prs: byHead, error: errorText(err) };
|
|
70
|
+
}
|
|
38
71
|
if (rows === null)
|
|
39
|
-
return byHead;
|
|
72
|
+
return { prs: byHead, error: 'the provider gave no reason' };
|
|
40
73
|
for (const info of rows) {
|
|
41
74
|
const head = info.headRef;
|
|
42
75
|
if (!head)
|
|
@@ -54,5 +87,5 @@ export function fetchPrs(repo, provider, branch) {
|
|
|
54
87
|
byHead.set(head, info);
|
|
55
88
|
}
|
|
56
89
|
}
|
|
57
|
-
return byHead;
|
|
90
|
+
return { prs: byHead, error: null };
|
|
58
91
|
}
|
package/dist/receipt.d.ts
CHANGED
|
@@ -56,6 +56,7 @@ export declare const StepSchema: z.ZodObject<{
|
|
|
56
56
|
}>;
|
|
57
57
|
error: z.ZodNullable<z.ZodString>;
|
|
58
58
|
verification: z.ZodNullable<z.ZodString>;
|
|
59
|
+
gone: z.ZodOptional<z.ZodLiteral<true>>;
|
|
59
60
|
}, z.core.$strip>;
|
|
60
61
|
export type Step = z.infer<typeof StepSchema>;
|
|
61
62
|
export declare const ReceiptSchema: z.ZodObject<{
|
|
@@ -106,6 +107,7 @@ export declare const ReceiptSchema: z.ZodObject<{
|
|
|
106
107
|
}>;
|
|
107
108
|
error: z.ZodNullable<z.ZodString>;
|
|
108
109
|
verification: z.ZodNullable<z.ZodString>;
|
|
110
|
+
gone: z.ZodOptional<z.ZodLiteral<true>>;
|
|
109
111
|
}, z.core.$strip>>;
|
|
110
112
|
}, z.core.$strip>;
|
|
111
113
|
export type Receipt = z.infer<typeof ReceiptSchema>;
|
package/dist/receipt.js
CHANGED
|
@@ -51,6 +51,12 @@ export const StepSchema = z.object({
|
|
|
51
51
|
result: z.enum(['pending', 'would-run', 'ok', 'failed']),
|
|
52
52
|
error: z.string().nullable(),
|
|
53
53
|
verification: z.string().nullable(),
|
|
54
|
+
/**
|
|
55
|
+
* The remote no longer listed the branch when the step ran: someone deleted
|
|
56
|
+
* it after the run's re-check. A `delete-remote` step so marked deleted
|
|
57
|
+
* nothing, and the run summary counts it apart. Absent otherwise.
|
|
58
|
+
*/
|
|
59
|
+
gone: z.literal(true).optional(),
|
|
54
60
|
});
|
|
55
61
|
export const ReceiptSchema = z.object({
|
|
56
62
|
version: z.literal(RECEIPT_VERSION),
|
package/dist/reference.js
CHANGED
|
@@ -9,14 +9,17 @@ const TARGETS = `Targets
|
|
|
9
9
|
a directory every checkout found under it, three levels deep at most
|
|
10
10
|
a remote URL its remote branches only, through a clone kept in the cache
|
|
11
11
|
directory (https://, ssh://, file://, or user@host:path)
|
|
12
|
-
No target sweeps the
|
|
13
|
-
it says.`;
|
|
12
|
+
No target sweeps the directory you are in, read the same way.`;
|
|
14
13
|
const SWEEP = `Sweep options (the report, plan and clean)
|
|
15
14
|
--remote Also judge remote branches. Turns on the network: fetches
|
|
16
15
|
each remote first and asks GitHub or GitLab about pull
|
|
17
16
|
requests. Slower.
|
|
18
17
|
--offline With --remote: do not fetch; judge against the
|
|
19
|
-
remote-tracking refs already on disk
|
|
18
|
+
remote-tracking refs already on disk, remote branches
|
|
19
|
+
included, so judging does not contact the remote. Tips can
|
|
20
|
+
be stale; clean asks the remote before deleting on it.
|
|
21
|
+
Pull requests are still looked up; add --provider none
|
|
22
|
+
to judge with no network at all.
|
|
20
23
|
--only <kinds> Only these kinds of row: worktree, branch, remote,
|
|
21
24
|
comma-separated. Default: worktree,branch, plus remote with
|
|
22
25
|
--remote. Naming remote implies --remote.
|
|
@@ -54,8 +57,8 @@ const CLEAN = `repo-cleaner clean [target...]
|
|
|
54
57
|
Repeatable.
|
|
55
58
|
--spare <row> Leave this row alone, and make sure it is what you saw. A
|
|
56
59
|
spared row is not deleted, even if it is judged delete. A
|
|
57
|
-
spared remote branch is still checked: if
|
|
58
|
-
|
|
60
|
+
spared remote branch is still checked: if its tip moved or
|
|
61
|
+
it is gone since the plan, the run stops before deleting
|
|
59
62
|
anything. Sparing a worktree also spares its branch.
|
|
60
63
|
Repeatable.
|
|
61
64
|
--allow <risk> Accept this risk for every row that carries it.
|
|
@@ -98,8 +101,9 @@ Risks (approve each by name with --allow)
|
|
|
98
101
|
(or its submodules') commits and stashes that no remote has.
|
|
99
102
|
discard-changes Uncommitted or untracked files in the worktree or any of its
|
|
100
103
|
submodules.
|
|
101
|
-
confirm-open-pr The branch has an open pull request
|
|
102
|
-
|
|
104
|
+
confirm-open-pr The branch has an open pull request, or open pull requests could
|
|
105
|
+
not be checked (the CLI is missing or signed out, or the listing
|
|
106
|
+
failed); deleting the remote branch closes it.
|
|
103
107
|
A worktree's submodules are part of it: one --allow on the worktree row covers
|
|
104
108
|
the same risk in every submodule.`;
|
|
105
109
|
const CONFLICTS = `Refused before anything is deleted (exit 2)
|
|
@@ -125,7 +129,7 @@ const CONFLICTS = `Refused before anything is deleted (exit 2)
|
|
|
125
129
|
Refused after the sweep, before deleting (exit 1)
|
|
126
130
|
a picked, spared or allowed row the plan does not have
|
|
127
131
|
a row name that matches rows in more than one repository
|
|
128
|
-
a spared remote branch that
|
|
132
|
+
a spared remote branch that moved since the plan, or that the re-check no longer finds
|
|
129
133
|
|
|
130
134
|
Allowed, because nothing contradicts
|
|
131
135
|
the same row picked twice: it runs once
|
package/dist/remote-mirror.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* fetches them over the network one comparison at a time, which made a sweep
|
|
9
9
|
* of a few hundred branches take minutes instead of seconds. It is kept
|
|
10
10
|
* rather than thrown away so a plan written from it can be applied later: the
|
|
11
|
-
* plan names the clone's directory, and `
|
|
11
|
+
* plan names the clone's directory, and `clean` re-sweeps and pushes deletes
|
|
12
12
|
* from there. A later run reuses it, and the sweep's fetch brings it up to
|
|
13
13
|
* date.
|
|
14
14
|
*/
|
package/dist/remote-mirror.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* fetches them over the network one comparison at a time, which made a sweep
|
|
9
9
|
* of a few hundred branches take minutes instead of seconds. It is kept
|
|
10
10
|
* rather than thrown away so a plan written from it can be applied later: the
|
|
11
|
-
* plan names the clone's directory, and `
|
|
11
|
+
* plan names the clone's directory, and `clean` re-sweeps and pushes deletes
|
|
12
12
|
* from there. A later run reuses it, and the sweep's fetch brings it up to
|
|
13
13
|
* date.
|
|
14
14
|
*/
|
package/dist/render.d.ts
CHANGED
|
@@ -61,8 +61,13 @@ export declare function renderJson(sweep: Sweep, extra?: Record<string, unknown>
|
|
|
61
61
|
export declare function renderCommands(sweep: Sweep): string[];
|
|
62
62
|
/** What a run did, or would do, counted by kind. */
|
|
63
63
|
export interface RunSummary {
|
|
64
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* Rows deleted (or, in a preview, that would be), by kind. Pruned worktrees,
|
|
66
|
+
* and remote branches a delete step found already gone, are counted apart.
|
|
67
|
+
*/
|
|
65
68
|
deleted: Record<Kind, number>;
|
|
69
|
+
/** Remote branches a delete step found the remote had already deleted. */
|
|
70
|
+
gone: number;
|
|
66
71
|
/** Worktrees whose stale record was pruned; their directories were gone or left alone. */
|
|
67
72
|
pruned: number;
|
|
68
73
|
/** Dropped rows by reason. */
|
package/dist/render.js
CHANGED
|
@@ -71,6 +71,10 @@ function repoHeader(sweep) {
|
|
|
71
71
|
];
|
|
72
72
|
return `${name} (${bits.filter((b) => b !== null).join(', ')})`;
|
|
73
73
|
}
|
|
74
|
+
/** A repository's notes on what the sweep judged without, one line each under its header. */
|
|
75
|
+
function noteLines(sweep, style) {
|
|
76
|
+
return sweep.notes.map((note) => ` ${style.review('note')}: ${stripControls(note)}`);
|
|
77
|
+
}
|
|
74
78
|
/** The word a report shows for a row: its verdict, or `prune` for a `delete` that only prunes. */
|
|
75
79
|
function verdictWord(row) {
|
|
76
80
|
return isPrune(row) ? 'prune' : row.verdict;
|
|
@@ -88,7 +92,7 @@ function wordStyle(style, word) {
|
|
|
88
92
|
/** One repository's rows, indented under its header. `all` shows `keep` rows too. */
|
|
89
93
|
export function renderRepo(sweep, opts = {}) {
|
|
90
94
|
const style = opts.style ?? PLAIN;
|
|
91
|
-
const lines = [style.strong(repoHeader(sweep))];
|
|
95
|
+
const lines = [style.strong(repoHeader(sweep)), ...noteLines(sweep, style)];
|
|
92
96
|
if (sweep.error !== null) {
|
|
93
97
|
lines.push(` ${style.delete('error')}: ${sweep.error}`);
|
|
94
98
|
return lines.join('\n');
|
|
@@ -190,7 +194,7 @@ function report(blocks, sweep, style) {
|
|
|
190
194
|
/** The sweep as counts (`--format summary`): a table per repository, then the totals line. */
|
|
191
195
|
export function renderSummary(sweep, style = PLAIN) {
|
|
192
196
|
const blocks = sweep.repos.map((r) => {
|
|
193
|
-
const lines = [repoHeader(r)];
|
|
197
|
+
const lines = [repoHeader(r), ...noteLines(r, style)];
|
|
194
198
|
if (r.error !== null) {
|
|
195
199
|
lines.push(` error: ${r.error}`);
|
|
196
200
|
return lines.join('\n');
|
|
@@ -256,6 +260,7 @@ export function renderCommands(sweep) {
|
|
|
256
260
|
export function summarizeRun(r) {
|
|
257
261
|
const out = {
|
|
258
262
|
deleted: { worktree: 0, branch: 0, remote: 0 },
|
|
263
|
+
gone: 0,
|
|
259
264
|
pruned: 0,
|
|
260
265
|
dropped: {},
|
|
261
266
|
failed: 0,
|
|
@@ -275,6 +280,10 @@ export function summarizeRun(r) {
|
|
|
275
280
|
}
|
|
276
281
|
if (s.result !== 'ok' && s.result !== 'would-run')
|
|
277
282
|
continue;
|
|
283
|
+
if (s.action === 'delete-remote' && s.gone === true) {
|
|
284
|
+
out.gone += 1;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
278
287
|
if (s.action === 'remove-worktree')
|
|
279
288
|
out.deleted.worktree += 1;
|
|
280
289
|
else if (s.action === 'prune-worktrees')
|
|
@@ -316,6 +325,7 @@ export function renderRunSummary(r, opts = {}) {
|
|
|
316
325
|
s.deleted.branch > 0 ? plural(s.deleted.branch, 'local branch', 'local branches') : null,
|
|
317
326
|
s.deleted.remote > 0 ? plural(s.deleted.remote, 'remote branch', 'remote branches') : null,
|
|
318
327
|
s.pruned > 0 ? `${plural(s.pruned, 'stale worktree', 'stale worktrees')} pruned` : null,
|
|
328
|
+
s.gone > 0 ? `${plural(s.gone, 'remote branch', 'remote branches')} already gone` : null,
|
|
319
329
|
].filter((p) => p !== null);
|
|
320
330
|
const lines = [
|
|
321
331
|
`${opts.preview === true ? 'would delete' : 'deleted'}: ${parts.length > 0 ? parts.join(', ') : 'nothing'}`,
|
package/dist/resolve.d.ts
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* covers everything under it — including a per-owner or per-client
|
|
9
9
|
* directory — without listing any of them.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* A caller that passes no entries gets no repositories. The CLI's bare
|
|
12
|
+
* invocation passes the directory it stands in, which resolves like any
|
|
13
|
+
* other entry.
|
|
14
14
|
*/
|
|
15
15
|
import { type Git } from '@osdlc/typed-git';
|
|
16
16
|
import { type ScanStats, type UnresolvedEntry } from './types.ts';
|
package/dist/resolve.js
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* covers everything under it — including a per-owner or per-client
|
|
9
9
|
* directory — without listing any of them.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* A caller that passes no entries gets no repositories. The CLI's bare
|
|
12
|
+
* invocation passes the directory it stands in, which resolves like any
|
|
13
|
+
* other entry.
|
|
14
14
|
*/
|
|
15
15
|
import { homedir } from 'node:os';
|
|
16
16
|
import { isAbsolute, join, resolve as resolvePath } from 'node:path';
|
package/dist/sweep.d.ts
CHANGED
|
@@ -67,7 +67,7 @@ export declare function sweptWith(targets: readonly string[], opts: Omit<SweepOp
|
|
|
67
67
|
/**
|
|
68
68
|
* Sweep `targets`: checkouts, directories to search for checkouts, or remote
|
|
69
69
|
* URLs (swept through a clone kept in `cacheDir`, remote branches only). No
|
|
70
|
-
*
|
|
70
|
+
* targets means `[cwd]`, resolved the same way.
|
|
71
71
|
*
|
|
72
72
|
* Never throws for a repository it cannot read: that one comes back with
|
|
73
73
|
* `error` set, because a fan-out must not end on its first bad entry. Throws a
|
|
@@ -81,6 +81,13 @@ interface RepoSweepOptions extends Runners {
|
|
|
81
81
|
against?: string | undefined;
|
|
82
82
|
provider: ProviderChoice;
|
|
83
83
|
fetch: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Read remote tips with `ls-remote` even without a fetch: `clean`'s
|
|
86
|
+
* re-check, which must not delete on the strength of a stale tracking ref.
|
|
87
|
+
* Without a fetch, a failed `ls-remote` then fails the repository rather
|
|
88
|
+
* than fall back to those refs.
|
|
89
|
+
*/
|
|
90
|
+
liveRemoteTips?: boolean | undefined;
|
|
84
91
|
branch?: string | undefined;
|
|
85
92
|
/** Only the worktree at this path; for {@link inspectWorktree}. */
|
|
86
93
|
worktree?: string | undefined;
|
|
@@ -97,6 +104,12 @@ interface RepoSweepOptions extends Runners {
|
|
|
97
104
|
* {@link sweep} does for each. The engine `check` and `run` re-sweep with.
|
|
98
105
|
* Never throws for a repository it cannot read; throws a `conflict` for a
|
|
99
106
|
* `branch` that is the default branch.
|
|
107
|
+
*
|
|
108
|
+
* A failed fetch fails the repository. A lookup that only adds evidence does
|
|
109
|
+
* not: a remote whose branches `ls-remote` cannot list is read from the
|
|
110
|
+
* remote-tracking refs the fetch just wrote, and a provider whose listing
|
|
111
|
+
* fails leaves no pull request evidence. Each is a sentence in `notes`, and
|
|
112
|
+
* every row is judged on the evidence that is left.
|
|
100
113
|
*/
|
|
101
114
|
export declare function sweepRepo(path: string, opts: RepoSweepOptions): RepoSweep;
|
|
102
115
|
/**
|
package/dist/sweep.js
CHANGED
|
@@ -13,11 +13,12 @@ import { Gh } from '@osdlc/typed-gh';
|
|
|
13
13
|
import { HELD_BY_WORKTREE, gatherLocalBranches, gatherRemoteBranches, resolveBranch, } from "./branches.js";
|
|
14
14
|
import { discoverRepo } from "./discover.js";
|
|
15
15
|
import { defaultProcessProbe } from "./processes.js";
|
|
16
|
-
import {
|
|
16
|
+
import { lookupPrs, prsUnchecked } from "./prs.js";
|
|
17
17
|
import { PROVIDER_CHOICES, providers } from "./providers/index.js";
|
|
18
18
|
import { defaultCacheDir, ensureMirror, isRemoteUrl, redactUrl } from "./remote-mirror.js";
|
|
19
19
|
import { expandTilde, resolveEntries } from "./resolve.js";
|
|
20
20
|
import { KINDS, RepoCleanerError, countRows, emptyScan, findDuplicateCheckouts, } from "./types.js";
|
|
21
|
+
import { errorText } from "./util.js";
|
|
21
22
|
import { gatherWorktrees } from "./worktrees.js";
|
|
22
23
|
const LOCAL = ['worktree', 'branch'];
|
|
23
24
|
/**
|
|
@@ -105,7 +106,7 @@ export function sweptWith(targets, opts) {
|
|
|
105
106
|
/**
|
|
106
107
|
* Sweep `targets`: checkouts, directories to search for checkouts, or remote
|
|
107
108
|
* URLs (swept through a clone kept in `cacheDir`, remote branches only). No
|
|
108
|
-
*
|
|
109
|
+
* targets means `[cwd]`, resolved the same way.
|
|
109
110
|
*
|
|
110
111
|
* Never throws for a repository it cannot read: that one comes back with
|
|
111
112
|
* `error` set, because a fan-out must not end on its first bad entry. Throws a
|
|
@@ -163,12 +164,43 @@ export function sweep(targets, opts = {}) {
|
|
|
163
164
|
options,
|
|
164
165
|
};
|
|
165
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* The repository's note about its pull request lookup, or null when the
|
|
169
|
+
* provider answered or nothing hangs on it. A failed listing is always noted;
|
|
170
|
+
* a CLI that is missing or signed out only when a remote branch could be
|
|
171
|
+
* deleted on its account, which then needs confirm-open-pr.
|
|
172
|
+
*/
|
|
173
|
+
function providerNote(repo, kinds, choice) {
|
|
174
|
+
const status = repo.provider;
|
|
175
|
+
const unchecked = status === null ? null : prsUnchecked(status);
|
|
176
|
+
// With no remote there is no remote branch to gate and no forge to point
|
|
177
|
+
// at; the local rows already say the lookup failed.
|
|
178
|
+
if (status === null || unchecked === null || repo.remote === null)
|
|
179
|
+
return null;
|
|
180
|
+
// A local remote names no forge, and `auto` asks GitHub for any host that
|
|
181
|
+
// is not GitLab's, so a host that is not a forge lands here too. github.com
|
|
182
|
+
// is a forge: there the cause is access, not the provider.
|
|
183
|
+
const hint = status.host === null ||
|
|
184
|
+
(choice === 'auto' && status.name === 'github' && status.host !== 'github.com')
|
|
185
|
+
? ' Pass `--provider none` if this remote has no forge.'
|
|
186
|
+
: '';
|
|
187
|
+
if (unchecked.kind === 'list-failed') {
|
|
188
|
+
return `Listing ${status.noun}s with \`${status.listing}\` failed (${status.listError}); judged on git evidence alone.${hint}`;
|
|
189
|
+
}
|
|
190
|
+
if (!kinds.has('remote'))
|
|
191
|
+
return null;
|
|
192
|
+
const fix = unchecked.kind === 'not-installed'
|
|
193
|
+
? `Install \`${status.tool}\``
|
|
194
|
+
: `Sign in to \`${status.tool}\``;
|
|
195
|
+
return `Could not check for open ${status.noun}s: ${unchecked.why}, so deleting a remote branch needs confirm-open-pr. ${fix}, or pass \`--allow confirm-open-pr\` once you have checked.${hint}`;
|
|
196
|
+
}
|
|
166
197
|
function errorSweep(path, err) {
|
|
167
198
|
return {
|
|
168
199
|
path,
|
|
169
200
|
repo: null,
|
|
170
201
|
rows: [],
|
|
171
202
|
counts: countRows([]),
|
|
203
|
+
notes: [],
|
|
172
204
|
error: err instanceof Error ? err.message : String(err),
|
|
173
205
|
};
|
|
174
206
|
}
|
|
@@ -177,6 +209,12 @@ function errorSweep(path, err) {
|
|
|
177
209
|
* {@link sweep} does for each. The engine `check` and `run` re-sweep with.
|
|
178
210
|
* Never throws for a repository it cannot read; throws a `conflict` for a
|
|
179
211
|
* `branch` that is the default branch.
|
|
212
|
+
*
|
|
213
|
+
* A failed fetch fails the repository. A lookup that only adds evidence does
|
|
214
|
+
* not: a remote whose branches `ls-remote` cannot list is read from the
|
|
215
|
+
* remote-tracking refs the fetch just wrote, and a provider whose listing
|
|
216
|
+
* fails leaves no pull request evidence. Each is a sentence in `notes`, and
|
|
217
|
+
* every row is judged on the evidence that is left.
|
|
180
218
|
*/
|
|
181
219
|
export function sweepRepo(path, opts) {
|
|
182
220
|
const kinds = new Set(opts.only);
|
|
@@ -219,19 +257,33 @@ export function sweepRepo(path, opts) {
|
|
|
219
257
|
const fetched = git
|
|
220
258
|
.at(repo.root)
|
|
221
259
|
.try.fetch(repo.remote, undefined, { prune: true, quiet: true, noWriteFetchHead: true });
|
|
260
|
+
// Unlike the lookups below, a failed fetch fails the repository: every
|
|
261
|
+
// row, local ones included, would be judged against a default branch
|
|
262
|
+
// older than the one asked for.
|
|
222
263
|
if (!fetched.ok) {
|
|
223
264
|
return {
|
|
224
|
-
...errorSweep(path, new Error(`fetch failed: ${fetched.error
|
|
265
|
+
...errorSweep(path, new Error(`fetch failed: ${errorText(fetched.error)}`)),
|
|
225
266
|
repo,
|
|
226
267
|
};
|
|
227
268
|
}
|
|
228
269
|
repo = { ...repo, fetched: true };
|
|
229
270
|
}
|
|
271
|
+
const notes = [];
|
|
230
272
|
try {
|
|
231
273
|
const status = repo.provider ?? null;
|
|
232
274
|
if (status?.authenticated === true)
|
|
233
275
|
report('prs', undefined, undefined, status.tool);
|
|
234
|
-
|
|
276
|
+
let prs = new Map();
|
|
277
|
+
if (status !== null) {
|
|
278
|
+
const looked = lookupPrs(repo, known[status.name], only);
|
|
279
|
+
prs = looked.prs;
|
|
280
|
+
if (looked.error !== null) {
|
|
281
|
+
repo = { ...repo, provider: { ...status, listError: looked.error } };
|
|
282
|
+
}
|
|
283
|
+
const note = providerNote(repo, kinds, opts.provider);
|
|
284
|
+
if (note !== null)
|
|
285
|
+
notes.push(note);
|
|
286
|
+
}
|
|
235
287
|
const rows = [];
|
|
236
288
|
// Worktrees are gathered whenever local branches are, even if they are not
|
|
237
289
|
// reported: a branch checked out in one must not be judged on its own.
|
|
@@ -268,17 +320,25 @@ export function sweepRepo(path, opts) {
|
|
|
268
320
|
rows.push(...localRows);
|
|
269
321
|
}
|
|
270
322
|
if (kinds.has('remote')) {
|
|
271
|
-
|
|
323
|
+
const remote = gatherRemoteBranches(repo, prs, localBranchNames, git, {
|
|
324
|
+
live: repo.fetched || opts.liveRemoteTips === true,
|
|
325
|
+
strict: opts.liveRemoteTips === true && !repo.fetched,
|
|
326
|
+
only,
|
|
327
|
+
tick: ticker('remote'),
|
|
328
|
+
});
|
|
329
|
+
rows.push(...remote.rows);
|
|
330
|
+
if (remote.note !== null)
|
|
331
|
+
notes.push(remote.note);
|
|
272
332
|
}
|
|
273
333
|
const annotated = opts.annotate === undefined
|
|
274
334
|
? rows
|
|
275
335
|
: holdFreedBranches(opts.annotate(repo, rows).map(blockedHasNoAction), only !== undefined);
|
|
276
|
-
return { path, repo, rows: annotated, counts: countRows(annotated), error: null };
|
|
336
|
+
return { path, repo, rows: annotated, counts: countRows(annotated), notes, error: null };
|
|
277
337
|
}
|
|
278
338
|
catch (err) {
|
|
279
339
|
if (err instanceof RepoCleanerError)
|
|
280
340
|
throw err;
|
|
281
|
-
return { ...errorSweep(path, err), repo };
|
|
341
|
+
return { ...errorSweep(path, err), repo, notes };
|
|
282
342
|
}
|
|
283
343
|
}
|
|
284
344
|
/**
|
package/dist/types.d.ts
CHANGED
|
@@ -55,8 +55,10 @@ export type Action = (typeof ACTIONS)[number];
|
|
|
55
55
|
* (or its submodules') commits and stashes no remote has
|
|
56
56
|
* `discard-changes` drops modified, staged or untracked files in a worktree
|
|
57
57
|
* or any of its submodules
|
|
58
|
-
* `confirm-open-pr` the branch has an open pull request
|
|
59
|
-
*
|
|
58
|
+
* `confirm-open-pr` the branch has an open pull request, or open pull
|
|
59
|
+
* requests could not be checked (the CLI is missing or
|
|
60
|
+
* signed out, or the listing failed) so one cannot be
|
|
61
|
+
* ruled out; deleting the remote branch closes it
|
|
60
62
|
*/
|
|
61
63
|
export declare const RISKS: readonly ["abandon-unmerged", "discard-changes", "confirm-open-pr"];
|
|
62
64
|
export type Risk = (typeof RISKS)[number];
|
|
@@ -265,9 +267,25 @@ export interface RepoSweep {
|
|
|
265
267
|
repo: RepoInfo | null;
|
|
266
268
|
rows: Row[];
|
|
267
269
|
counts: Counts;
|
|
270
|
+
/**
|
|
271
|
+
* What the sweep could not look up for this repository and judged without,
|
|
272
|
+
* one sentence each: a remote branch listing or a pull request listing that
|
|
273
|
+
* failed, a pull request lookup the provider's CLI could not make (missing
|
|
274
|
+
* or signed out), or, offline, a remote with no tracking refs on disk. The
|
|
275
|
+
* rows stand on the evidence that was left.
|
|
276
|
+
* Empty when nothing was missing.
|
|
277
|
+
*/
|
|
278
|
+
notes: string[];
|
|
268
279
|
/** Why this repository produced no rows. Null on success. */
|
|
269
280
|
error: string | null;
|
|
270
281
|
}
|
|
282
|
+
/** One of a repository's `notes`, with the repository's root. */
|
|
283
|
+
export interface RepoNote {
|
|
284
|
+
repo: string;
|
|
285
|
+
note: string;
|
|
286
|
+
}
|
|
287
|
+
/** Every repository's `notes` in `sweep`, each with its repository. */
|
|
288
|
+
export declare function sweepNotes(sweep: Pick<Sweep, 'repos'>): RepoNote[];
|
|
271
289
|
/** A target that named no repository. */
|
|
272
290
|
export interface UnresolvedEntry {
|
|
273
291
|
entry: string;
|
package/dist/types.js
CHANGED
|
@@ -49,8 +49,10 @@ export const ACTIONS = ['remove', 'prune', 'delete'];
|
|
|
49
49
|
* (or its submodules') commits and stashes no remote has
|
|
50
50
|
* `discard-changes` drops modified, staged or untracked files in a worktree
|
|
51
51
|
* or any of its submodules
|
|
52
|
-
* `confirm-open-pr` the branch has an open pull request
|
|
53
|
-
*
|
|
52
|
+
* `confirm-open-pr` the branch has an open pull request, or open pull
|
|
53
|
+
* requests could not be checked (the CLI is missing or
|
|
54
|
+
* signed out, or the listing failed) so one cannot be
|
|
55
|
+
* ruled out; deleting the remote branch closes it
|
|
54
56
|
*/
|
|
55
57
|
export const RISKS = ['abandon-unmerged', 'discard-changes', 'confirm-open-pr'];
|
|
56
58
|
/** The risks whose flag is set, in canonical order. */
|
|
@@ -107,6 +109,10 @@ export function totalOf(counts) {
|
|
|
107
109
|
}
|
|
108
110
|
return out;
|
|
109
111
|
}
|
|
112
|
+
/** Every repository's `notes` in `sweep`, each with its repository. */
|
|
113
|
+
export function sweepNotes(sweep) {
|
|
114
|
+
return sweep.repos.flatMap((r) => r.notes.map((note) => ({ repo: r.repo?.root ?? r.path, note })));
|
|
115
|
+
}
|
|
110
116
|
/**
|
|
111
117
|
* Slugs shared by two or more of `repos`, roots sorted. A null entry or a
|
|
112
118
|
* repository without a slug is left out.
|
package/dist/util.d.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/** Small helpers every module of the package shares. */
|
|
2
2
|
/** True when `path` is a directory; false for anything else, or when it cannot be read. */
|
|
3
3
|
export declare function isDir(path: string): boolean;
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* An error as one line for a person: a failed git call's own output, or the
|
|
6
|
+
* message. Takes a thrown error or a `try` door's failure, which is a
|
|
7
|
+
* `Failure` rather than an `Error`; a failure's classified message reads
|
|
8
|
+
* better than the raw output it was classified from. The text goes into
|
|
9
|
+
* notes and report lines, so line breaks are folded into spaces.
|
|
10
|
+
*/
|
|
5
11
|
export declare function errorText(err: unknown): string;
|
|
6
12
|
/** `1 commit`, `3 commits`. */
|
|
7
13
|
export declare const plural: (n: number, one: string, many: string) => string;
|
package/dist/util.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Small helpers every module of the package shares. */
|
|
2
2
|
import { statSync } from 'node:fs';
|
|
3
|
+
import { failureOf } from '@osdlc/cli-tool';
|
|
3
4
|
import { CommandFailed } from '@osdlc/typed-git';
|
|
4
5
|
/** True when `path` is a directory; false for anything else, or when it cannot be read. */
|
|
5
6
|
export function isDir(path) {
|
|
@@ -10,11 +11,26 @@ export function isDir(path) {
|
|
|
10
11
|
return false;
|
|
11
12
|
}
|
|
12
13
|
}
|
|
13
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* An error as one line for a person: a failed git call's own output, or the
|
|
16
|
+
* message. Takes a thrown error or a `try` door's failure, which is a
|
|
17
|
+
* `Failure` rather than an `Error`; a failure's classified message reads
|
|
18
|
+
* better than the raw output it was classified from. The text goes into
|
|
19
|
+
* notes and report lines, so line breaks are folded into spaces.
|
|
20
|
+
*/
|
|
14
21
|
export function errorText(err) {
|
|
22
|
+
return oneLine(rawErrorText(err));
|
|
23
|
+
}
|
|
24
|
+
function rawErrorText(err) {
|
|
15
25
|
if (err instanceof CommandFailed)
|
|
16
26
|
return err.detail.trim() || err.message;
|
|
17
|
-
|
|
27
|
+
if (err instanceof Error)
|
|
28
|
+
return err.message;
|
|
29
|
+
const failure = failureOf(err);
|
|
30
|
+
if (failure !== null)
|
|
31
|
+
return failure.message || failure.detail?.trim() || '';
|
|
32
|
+
return String(err);
|
|
18
33
|
}
|
|
34
|
+
const oneLine = (text) => text.replace(/\s*\n\s*/g, ' ').trim();
|
|
19
35
|
/** `1 commit`, `3 commits`. */
|
|
20
36
|
export const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
@@ -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.19.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@osdlc/repo-cleaner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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,12 +49,12 @@
|
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"zod": "^4.6.6",
|
|
52
|
-
"@osdlc/command-seam": "^0.14.0",
|
|
53
52
|
"@osdlc/cli-tool": "^0.17.0",
|
|
54
53
|
"@osdlc/path-guard": "^0.3.1",
|
|
55
54
|
"@osdlc/typed-gh": "^0.16.1",
|
|
56
|
-
"@osdlc/
|
|
57
|
-
"@osdlc/ux": "^0.1.1"
|
|
55
|
+
"@osdlc/command-seam": "^0.14.0",
|
|
56
|
+
"@osdlc/ux": "^0.1.1",
|
|
57
|
+
"@osdlc/typed-git": "^0.13.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^26.1.0",
|