@osdlc/repo-cleaner 0.17.2 → 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 +31 -0
- package/README.md +691 -305
- package/dist/branches.d.ts +37 -36
- package/dist/branches.js +199 -196
- package/dist/clean.d.ts +179 -0
- package/dist/clean.js +824 -0
- package/dist/cli.d.ts +12 -16
- package/dist/cli.js +367 -478
- package/dist/hooks.d.ts +8 -12
- package/dist/hooks.js +2 -2
- package/dist/index.d.ts +18 -25
- package/dist/index.js +16 -23
- package/dist/landing.d.ts +3 -3
- package/dist/landing.js +7 -7
- package/dist/plan.d.ts +76 -70
- package/dist/plan.js +82 -77
- 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 +46 -57
- package/dist/receipt.js +52 -28
- package/dist/reference.d.ts +12 -0
- package/dist/reference.js +152 -0
- package/dist/remote-mirror.d.ts +9 -1
- package/dist/remote-mirror.js +19 -1
- package/dist/render.d.ts +66 -61
- package/dist/render.js +206 -255
- package/dist/resolve.d.ts +3 -3
- package/dist/resolve.js +4 -12
- package/dist/status-line.d.ts +1 -1
- package/dist/status-line.js +5 -5
- package/dist/step-board.d.ts +1 -1
- package/dist/step-board.js +2 -5
- package/dist/submodules.d.ts +120 -0
- package/dist/submodules.js +292 -0
- package/dist/sweep.d.ts +107 -64
- package/dist/sweep.js +333 -119
- package/dist/types.d.ts +294 -105
- package/dist/types.js +106 -54
- package/dist/util.d.ts +13 -0
- package/dist/util.js +36 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worktrees.d.ts +21 -37
- package/dist/worktrees.js +198 -165
- package/package.json +5 -5
- package/dist/apply.d.ts +0 -131
- package/dist/apply.js +0 -541
package/dist/sweep.js
CHANGED
|
@@ -1,39 +1,225 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The sweep
|
|
2
|
+
* The sweep: resolve the targets, then inventory every repository they name.
|
|
3
3
|
*
|
|
4
|
-
* Every
|
|
5
|
-
*
|
|
6
|
-
* which updates remote-tracking refs and nothing else
|
|
4
|
+
* Every row carries the commands that would act on it, and running them is
|
|
5
|
+
* the caller's decision. The only write is the fetch a sweep of remote
|
|
6
|
+
* branches makes, which updates remote-tracking refs and nothing else, and the
|
|
7
|
+
* clone a remote URL target is swept through.
|
|
7
8
|
*/
|
|
9
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
10
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
8
11
|
import { Git } from '@osdlc/typed-git';
|
|
9
12
|
import { Gh } from '@osdlc/typed-gh';
|
|
10
|
-
import { HELD_BY_WORKTREE, gatherLocalBranches, gatherRemoteBranches } from "./branches.js";
|
|
11
|
-
import {
|
|
13
|
+
import { HELD_BY_WORKTREE, gatherLocalBranches, gatherRemoteBranches, resolveBranch, } from "./branches.js";
|
|
14
|
+
import { discoverRepo } from "./discover.js";
|
|
12
15
|
import { defaultProcessProbe } from "./processes.js";
|
|
13
|
-
import {
|
|
14
|
-
import { providers } from "./providers/index.js";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
16
|
+
import { lookupPrs, prsUnchecked } from "./prs.js";
|
|
17
|
+
import { PROVIDER_CHOICES, providers } from "./providers/index.js";
|
|
18
|
+
import { defaultCacheDir, ensureMirror, isRemoteUrl, redactUrl } from "./remote-mirror.js";
|
|
19
|
+
import { expandTilde, resolveEntries } from "./resolve.js";
|
|
20
|
+
import { KINDS, RepoCleanerError, countRows, emptyScan, findDuplicateCheckouts, } from "./types.js";
|
|
21
|
+
import { errorText } from "./util.js";
|
|
17
22
|
import { gatherWorktrees } from "./worktrees.js";
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
const LOCAL = ['worktree', 'branch'];
|
|
24
|
+
/**
|
|
25
|
+
* The sweep `targets` and `opts` ask for, as a plan records it. Throws a
|
|
26
|
+
* {@link RepoCleanerError} for options that contradict each other or would
|
|
27
|
+
* have no effect, before anything is read.
|
|
28
|
+
*/
|
|
29
|
+
export function sweptWith(targets, opts) {
|
|
30
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
31
|
+
const conflict = (message) => {
|
|
32
|
+
throw new RepoCleanerError('conflict', message);
|
|
33
|
+
};
|
|
34
|
+
if (targets.some((t) => t.trim() === '')) {
|
|
35
|
+
throw new RepoCleanerError('bad-input', 'a target is empty');
|
|
36
|
+
}
|
|
37
|
+
const only = opts.only === undefined ? undefined : [...new Set(opts.only)];
|
|
38
|
+
const unknown = (only ?? []).filter((k) => !KINDS.includes(k));
|
|
39
|
+
if (unknown.length > 0) {
|
|
40
|
+
throw new RepoCleanerError('bad-input', `unknown kind(s) ${unknown.join(', ')}: expected ${KINDS.join(', ')}`);
|
|
41
|
+
}
|
|
42
|
+
if (only !== undefined && only.length === 0) {
|
|
43
|
+
throw new RepoCleanerError('bad-input', 'only names no kind');
|
|
44
|
+
}
|
|
45
|
+
if (opts.provider !== undefined &&
|
|
46
|
+
!PROVIDER_CHOICES.includes(opts.provider)) {
|
|
47
|
+
throw new RepoCleanerError('bad-input', `unknown provider ${JSON.stringify(opts.provider)}`);
|
|
48
|
+
}
|
|
49
|
+
const exists = (t) => existsSync(resolve(cwd, expandTilde(t)));
|
|
50
|
+
const isUrl = targets.map((t) => isRemoteUrl(t, exists));
|
|
51
|
+
const urls = isUrl.some(Boolean);
|
|
52
|
+
if (urls && !isUrl.every(Boolean)) {
|
|
53
|
+
conflict('a remote URL target sweeps remote branches only; sweep it apart from checkouts');
|
|
54
|
+
}
|
|
55
|
+
if (urls && only !== undefined && only.some((k) => k !== 'remote')) {
|
|
56
|
+
conflict(`a remote URL has only remote branches; drop --only ${only.join(',')}`);
|
|
57
|
+
}
|
|
58
|
+
if (urls && opts.branch !== undefined) {
|
|
59
|
+
conflict('--branch reads one branch of a checkout; it does not take a remote URL');
|
|
60
|
+
}
|
|
61
|
+
if (urls && opts.offline === true) {
|
|
62
|
+
conflict('a remote URL target is fetched to be swept; --offline would have no effect');
|
|
63
|
+
}
|
|
64
|
+
if (opts.branch !== undefined && targets.length > 1) {
|
|
65
|
+
conflict(`--branch sweeps one repository; ${targets.length} targets given`);
|
|
66
|
+
}
|
|
67
|
+
const remote = urls || opts.remote === true || (only ?? []).includes('remote');
|
|
68
|
+
if (opts.remote === true && only !== undefined && !only.includes('remote')) {
|
|
69
|
+
conflict(`--remote includes remote branches, but --only ${only.join(',')} leaves them out`);
|
|
70
|
+
}
|
|
71
|
+
if (opts.offline === true && !remote) {
|
|
72
|
+
conflict('--offline has no effect without --remote: nothing is fetched anyway');
|
|
73
|
+
}
|
|
74
|
+
if (opts.provider !== undefined && !remote) {
|
|
75
|
+
conflict('--provider has no effect without --remote: pull requests are looked up only for remote branches');
|
|
76
|
+
}
|
|
77
|
+
const kinds = urls
|
|
78
|
+
? ['remote']
|
|
79
|
+
: only !== undefined
|
|
80
|
+
? KINDS.filter((k) => only.includes(k))
|
|
81
|
+
: remote
|
|
82
|
+
? [...LOCAL, 'remote']
|
|
83
|
+
: [...LOCAL];
|
|
84
|
+
// `HEAD` is the branch checked out at the target, resolved here so the
|
|
85
|
+
// options checked before the sweep are the ones the plan records.
|
|
86
|
+
let branch = opts.branch ?? null;
|
|
87
|
+
if (branch === 'HEAD') {
|
|
88
|
+
const at = targets[0] === undefined ? cwd : resolve(cwd, expandTilde(targets[0]));
|
|
89
|
+
try {
|
|
90
|
+
branch = resolveBranch('HEAD', new Git(at, { runner: opts.git }));
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
throw new RepoCleanerError('conflict', err instanceof Error ? err.message : String(err));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
only: kinds,
|
|
98
|
+
remote,
|
|
99
|
+
offline: opts.offline === true,
|
|
100
|
+
against: opts.against ?? null,
|
|
101
|
+
provider: remote ? (opts.provider ?? 'auto') : 'none',
|
|
102
|
+
branch,
|
|
103
|
+
urls,
|
|
104
|
+
};
|
|
20
105
|
}
|
|
21
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Sweep `targets`: checkouts, directories to search for checkouts, or remote
|
|
108
|
+
* URLs (swept through a clone kept in `cacheDir`, remote branches only). No
|
|
109
|
+
* targets means `[cwd]`, resolved the same way.
|
|
110
|
+
*
|
|
111
|
+
* Never throws for a repository it cannot read: that one comes back with
|
|
112
|
+
* `error` set, because a fan-out must not end on its first bad entry. Throws a
|
|
113
|
+
* {@link RepoCleanerError} for options that contradict each other, before
|
|
114
|
+
* reading anything.
|
|
115
|
+
*/
|
|
116
|
+
export function sweep(targets, opts = {}) {
|
|
117
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
118
|
+
const { urls, ...options } = sweptWith(targets, opts);
|
|
119
|
+
const branch = options.branch ?? undefined;
|
|
120
|
+
const failed = [];
|
|
121
|
+
let entries = targets.length > 0 ? [...targets] : [cwd];
|
|
122
|
+
if (urls) {
|
|
123
|
+
const cache = opts.cacheDir ?? defaultCacheDir();
|
|
124
|
+
entries = [];
|
|
125
|
+
for (const url of targets) {
|
|
126
|
+
try {
|
|
127
|
+
entries.push(ensureMirror(url, cache, { git: opts.git }));
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
failed.push(errorSweep(redactUrl(url), err));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const resolution = entries.length === 0
|
|
135
|
+
? { repos: [], unresolved: [], scan: emptyScan() }
|
|
136
|
+
: resolveEntries(entries, {
|
|
137
|
+
git: new Git(cwd, { runner: opts.git }),
|
|
138
|
+
baseDir: cwd,
|
|
139
|
+
...(opts.onWarn === undefined ? {} : { onWarn: opts.onWarn }),
|
|
140
|
+
});
|
|
141
|
+
if (branch !== undefined && resolution.repos.length > 1) {
|
|
142
|
+
throw new RepoCleanerError('conflict', `--branch sweeps one repository; ${resolution.repos.length} found under ${entries[0]}`);
|
|
143
|
+
}
|
|
144
|
+
const of = resolution.repos.length;
|
|
145
|
+
const repos = [
|
|
146
|
+
...failed,
|
|
147
|
+
...resolution.repos.map((root, i) => sweepRepo(root, {
|
|
148
|
+
...opts,
|
|
149
|
+
only: options.only,
|
|
150
|
+
against: options.against ?? undefined,
|
|
151
|
+
provider: options.provider,
|
|
152
|
+
fetch: options.remote && !options.offline,
|
|
153
|
+
branch,
|
|
154
|
+
cwd,
|
|
155
|
+
position: { index: i + 1, of },
|
|
156
|
+
})),
|
|
157
|
+
];
|
|
22
158
|
return {
|
|
23
|
-
|
|
24
|
-
|
|
159
|
+
repos,
|
|
160
|
+
unresolved: resolution.unresolved,
|
|
161
|
+
scan: resolution.scan,
|
|
162
|
+
duplicates: findDuplicateCheckouts(repos.map((s) => s.repo)),
|
|
163
|
+
counts: countRows(repos.flatMap((s) => s.rows)),
|
|
164
|
+
options,
|
|
25
165
|
};
|
|
26
166
|
}
|
|
27
167
|
/**
|
|
28
|
-
*
|
|
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
|
+
}
|
|
197
|
+
function errorSweep(path, err) {
|
|
198
|
+
return {
|
|
199
|
+
path,
|
|
200
|
+
repo: null,
|
|
201
|
+
rows: [],
|
|
202
|
+
counts: countRows([]),
|
|
203
|
+
notes: [],
|
|
204
|
+
error: err instanceof Error ? err.message : String(err),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Inventory one repository, annotated and with freed branches held, as
|
|
209
|
+
* {@link sweep} does for each. The engine `check` and `run` re-sweep with.
|
|
210
|
+
* Never throws for a repository it cannot read; throws a `conflict` for a
|
|
211
|
+
* `branch` that is the default branch.
|
|
29
212
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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.
|
|
33
218
|
*/
|
|
34
|
-
export function sweepRepo(path, opts
|
|
35
|
-
const
|
|
36
|
-
const
|
|
219
|
+
export function sweepRepo(path, opts) {
|
|
220
|
+
const kinds = new Set(opts.only);
|
|
221
|
+
const git = new Git(path, { runner: opts.git });
|
|
222
|
+
const gh = new Gh(path, { runner: opts.gh });
|
|
37
223
|
const known = providers({ gh, glab: opts.glab });
|
|
38
224
|
const only = opts.branch;
|
|
39
225
|
const at = opts.position ?? { index: 1, of: 1 };
|
|
@@ -49,8 +235,8 @@ export function sweepRepo(path, opts = {}) {
|
|
|
49
235
|
let repo;
|
|
50
236
|
try {
|
|
51
237
|
repo = discoverRepo(path, {
|
|
52
|
-
remote: opts.
|
|
53
|
-
allowProvider: opts.
|
|
238
|
+
remote: opts.against ?? null,
|
|
239
|
+
allowProvider: opts.provider !== 'none',
|
|
54
240
|
provider: opts.provider,
|
|
55
241
|
git,
|
|
56
242
|
gh,
|
|
@@ -58,145 +244,173 @@ export function sweepRepo(path, opts = {}) {
|
|
|
58
244
|
});
|
|
59
245
|
}
|
|
60
246
|
catch (err) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return { repo: null, path, items: [], error: err instanceof Error ? err.message : String(err) };
|
|
247
|
+
// A `DiscoveryFailed`, or anything else that kept the repository from being read.
|
|
248
|
+
return errorSweep(path, err);
|
|
64
249
|
}
|
|
65
250
|
if (only === repo.mainBranch) {
|
|
66
|
-
|
|
251
|
+
throw new RepoCleanerError('conflict', `--branch ${only} is the default branch, which is never a candidate for deletion`);
|
|
67
252
|
}
|
|
68
|
-
|
|
69
|
-
|
|
253
|
+
// A branch sweep judges the worktree on its own evidence, wherever the caller stands.
|
|
254
|
+
const cwd = only === undefined ? opts.cwd : repo.root;
|
|
255
|
+
if (opts.fetch && repo.remote !== null) {
|
|
70
256
|
report('fetch');
|
|
71
257
|
const fetched = git
|
|
72
258
|
.at(repo.root)
|
|
73
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.
|
|
74
263
|
if (!fetched.ok) {
|
|
75
264
|
return {
|
|
265
|
+
...errorSweep(path, new Error(`fetch failed: ${errorText(fetched.error)}`)),
|
|
76
266
|
repo,
|
|
77
|
-
path,
|
|
78
|
-
items: [],
|
|
79
|
-
error: `fetch failed: ${fetched.error.detail || fetched.error.message}`,
|
|
80
267
|
};
|
|
81
268
|
}
|
|
82
269
|
repo = { ...repo, fetched: true };
|
|
83
270
|
}
|
|
271
|
+
const notes = [];
|
|
84
272
|
try {
|
|
85
273
|
const status = repo.provider ?? null;
|
|
86
274
|
if (status?.authenticated === true)
|
|
87
275
|
report('prs', undefined, undefined, status.tool);
|
|
88
|
-
|
|
89
|
-
|
|
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
|
+
}
|
|
287
|
+
const rows = [];
|
|
90
288
|
// Worktrees are gathered whenever local branches are, even if they are not
|
|
91
289
|
// reported: a branch checked out in one must not be judged on its own.
|
|
92
|
-
let
|
|
290
|
+
let worktreeRows = [];
|
|
93
291
|
let worktreeBranches = new Set();
|
|
94
|
-
if (
|
|
292
|
+
if (kinds.has('worktree') || kinds.has('branch')) {
|
|
95
293
|
;
|
|
96
|
-
[
|
|
294
|
+
[worktreeRows, worktreeBranches] = gatherWorktrees(repo, prs, cwd, git, { branch: only, path: opts.worktree }, opts.probe ?? defaultProcessProbe(), ticker('worktree'));
|
|
97
295
|
}
|
|
98
|
-
if (
|
|
99
|
-
|
|
296
|
+
if (kinds.has('worktree'))
|
|
297
|
+
rows.push(...worktreeRows);
|
|
100
298
|
let localBranchNames = new Set();
|
|
101
|
-
if (
|
|
102
|
-
// A branch whose worktree goes in the same
|
|
103
|
-
//
|
|
104
|
-
// the branch of a safe-to-delete worktree; a branch-scoped sweep frees
|
|
105
|
-
// the named branch when its worktree is deletable at all.
|
|
299
|
+
if (kinds.has('branch') || kinds.has('remote')) {
|
|
300
|
+
// A branch whose worktree goes in the same run is judged on its own:
|
|
301
|
+
// a run removes worktrees before it deletes branches.
|
|
106
302
|
const freed = new Map();
|
|
107
|
-
if (
|
|
108
|
-
for (const
|
|
109
|
-
const branch =
|
|
110
|
-
if (freesBranch(
|
|
111
|
-
freed.set(branch,
|
|
112
|
-
}
|
|
303
|
+
if (kinds.has('worktree')) {
|
|
304
|
+
for (const row of worktreeRows) {
|
|
305
|
+
const branch = row.evidence.branch;
|
|
306
|
+
if (freesBranch(row, only !== undefined) && branch !== null)
|
|
307
|
+
freed.set(branch, row.name);
|
|
113
308
|
}
|
|
114
309
|
}
|
|
115
310
|
const pinned = new Set([...worktreeBranches].filter((name) => !freed.has(name)));
|
|
116
|
-
const
|
|
117
|
-
//
|
|
118
|
-
for (const
|
|
119
|
-
const worktree = freed.get(
|
|
311
|
+
const localRows = gatherLocalBranches(repo, prs, pinned, git, only, ticker('branch'));
|
|
312
|
+
// A run drops a freed branch unless it also removes the worktree.
|
|
313
|
+
for (const row of localRows) {
|
|
314
|
+
const worktree = freed.get(row.name);
|
|
120
315
|
if (worktree !== undefined)
|
|
121
|
-
|
|
316
|
+
row.evidence.worktree = worktree;
|
|
122
317
|
}
|
|
123
|
-
localBranchNames = new Set(
|
|
124
|
-
if (
|
|
125
|
-
|
|
318
|
+
localBranchNames = new Set(localRows.map((row) => row.name));
|
|
319
|
+
if (kinds.has('branch'))
|
|
320
|
+
rows.push(...localRows);
|
|
126
321
|
}
|
|
127
|
-
if (
|
|
128
|
-
|
|
322
|
+
if (kinds.has('remote')) {
|
|
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);
|
|
129
332
|
}
|
|
130
|
-
|
|
333
|
+
const annotated = opts.annotate === undefined
|
|
334
|
+
? rows
|
|
335
|
+
: holdFreedBranches(opts.annotate(repo, rows).map(blockedHasNoAction), only !== undefined);
|
|
336
|
+
return { path, repo, rows: annotated, counts: countRows(annotated), notes, error: null };
|
|
131
337
|
}
|
|
132
338
|
catch (err) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
items: [],
|
|
137
|
-
error: err instanceof Error ? err.message : String(err),
|
|
138
|
-
};
|
|
339
|
+
if (err instanceof RepoCleanerError)
|
|
340
|
+
throw err;
|
|
341
|
+
return { ...errorSweep(path, err), repo, notes };
|
|
139
342
|
}
|
|
140
343
|
}
|
|
141
344
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* {@link resolveEntries}. A repository that fails discovery becomes an error
|
|
146
|
-
* row and the sweep continues.
|
|
345
|
+
* Whether worktree row `row` frees its branch to be judged on its own. A full
|
|
346
|
+
* sweep frees it only for a worktree judged `delete`; a branch sweep, where the
|
|
347
|
+
* caller named the branch, for any worktree that can be removed.
|
|
147
348
|
*/
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const resolution = resolveEntries(entries, {
|
|
151
|
-
git: probe,
|
|
152
|
-
...(opts.baseDir === undefined ? {} : { baseDir: opts.baseDir }),
|
|
153
|
-
...(opts.onWarn === undefined ? {} : { onWarn: opts.onWarn }),
|
|
154
|
-
});
|
|
155
|
-
const of = resolution.repos.length;
|
|
156
|
-
const repos = resolution.repos.map((root, i) => sweepRepo(root, { ...opts, position: { index: i + 1, of } }));
|
|
157
|
-
return {
|
|
158
|
-
repos,
|
|
159
|
-
unresolved: resolution.unresolved,
|
|
160
|
-
scan: resolution.scan,
|
|
161
|
-
duplicates: findDuplicateCheckouts(repos.map((sweep) => sweep.repo)),
|
|
162
|
-
};
|
|
349
|
+
function freesBranch(row, branchScoped) {
|
|
350
|
+
return row.action !== null && (branchScoped || row.verdict === 'delete');
|
|
163
351
|
}
|
|
164
|
-
/**
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
function freesBranch(item, branchScoped) {
|
|
170
|
-
return item.deletable && (branchScoped || item.classification === 'safe-to-delete');
|
|
352
|
+
/** A row a host marked `blocked` can never be deleted: it keeps no action and no command. */
|
|
353
|
+
function blockedHasNoAction(row) {
|
|
354
|
+
return row.verdict === 'blocked' && row.action !== null
|
|
355
|
+
? { ...row, action: null, commands: [] }
|
|
356
|
+
: row;
|
|
171
357
|
}
|
|
172
358
|
/**
|
|
173
|
-
* Keep again each branch
|
|
174
|
-
* worktree no longer frees it
|
|
175
|
-
*
|
|
176
|
-
* with `branchScoped` set for a `branch` sweep, so the counts and the plan
|
|
177
|
-
* match what `applyPlan` will do. Running it twice changes nothing.
|
|
359
|
+
* Keep again each branch the sweep freed for its worktree's removal when that
|
|
360
|
+
* worktree no longer frees it: a host's `annotate` held it back, demoted it,
|
|
361
|
+
* or dropped its row. Running it twice changes nothing.
|
|
178
362
|
*/
|
|
179
|
-
export function holdFreedBranches(
|
|
180
|
-
const going = new Set(
|
|
181
|
-
|
|
182
|
-
.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (going.has(worktree))
|
|
188
|
-
return item;
|
|
189
|
-
const { worktree: _, ...facts } = item.facts;
|
|
363
|
+
export function holdFreedBranches(rows, branchScoped) {
|
|
364
|
+
const going = new Set(rows.filter((r) => r.kind === 'worktree' && freesBranch(r, branchScoped)).map((r) => r.name));
|
|
365
|
+
return rows.map((row) => {
|
|
366
|
+
if (row.kind !== 'branch' || row.evidence.worktree === undefined)
|
|
367
|
+
return row;
|
|
368
|
+
if (going.has(row.evidence.worktree))
|
|
369
|
+
return row;
|
|
370
|
+
const { worktree: _, ...evidence } = row.evidence;
|
|
190
371
|
return {
|
|
191
|
-
...
|
|
192
|
-
|
|
193
|
-
action:
|
|
194
|
-
|
|
195
|
-
|
|
372
|
+
...row,
|
|
373
|
+
verdict: 'keep',
|
|
374
|
+
action: null,
|
|
375
|
+
why: HELD_BY_WORKTREE,
|
|
376
|
+
evidence,
|
|
196
377
|
commands: [],
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
deletable: false,
|
|
378
|
+
notes: [],
|
|
379
|
+
risks: [],
|
|
200
380
|
};
|
|
201
381
|
});
|
|
202
382
|
}
|
|
383
|
+
/**
|
|
384
|
+
* The worktree at `path`, judged as a sweep judges it: its verdict, risks,
|
|
385
|
+
* blockers and evidence, submodules included. Reads git and the process
|
|
386
|
+
* table; asks no provider.
|
|
387
|
+
*
|
|
388
|
+
* The main checkout is a worktree too: it comes back as its `keep` row
|
|
389
|
+
* ("primary worktree").
|
|
390
|
+
*
|
|
391
|
+
* @throws RepoCleanerError (`not-found`) when `path` is not a worktree of a
|
|
392
|
+
* repository git can read.
|
|
393
|
+
*/
|
|
394
|
+
export function inspectWorktree(path, opts = {}) {
|
|
395
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
396
|
+
const abs = isAbsolute(path) ? path : resolve(cwd, path);
|
|
397
|
+
const there = existsSync(abs);
|
|
398
|
+
const real = there ? realpathSync(abs) : resolve(abs);
|
|
399
|
+
// From the worktree itself when it is there; a gone one is read from the
|
|
400
|
+
// repository the caller stands in. The sweep finds the main checkout.
|
|
401
|
+
const swept = sweepRepo(there ? real : cwd, {
|
|
402
|
+
...opts,
|
|
403
|
+
only: ['worktree'],
|
|
404
|
+
provider: 'none',
|
|
405
|
+
fetch: false,
|
|
406
|
+
cwd,
|
|
407
|
+
worktree: real,
|
|
408
|
+
});
|
|
409
|
+
if (swept.error !== null)
|
|
410
|
+
throw new RepoCleanerError('not-found', swept.error);
|
|
411
|
+
const row = swept.rows.find((r) => r.kind === 'worktree');
|
|
412
|
+
if (row === undefined) {
|
|
413
|
+
throw new RepoCleanerError('not-found', `${path} is not a worktree of ${swept.repo?.root ?? cwd}`);
|
|
414
|
+
}
|
|
415
|
+
return row;
|
|
416
|
+
}
|