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