@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +691 -305
  3. package/dist/branches.d.ts +37 -36
  4. package/dist/branches.js +199 -196
  5. package/dist/clean.d.ts +179 -0
  6. package/dist/clean.js +824 -0
  7. package/dist/cli.d.ts +12 -16
  8. package/dist/cli.js +367 -478
  9. package/dist/hooks.d.ts +8 -12
  10. package/dist/hooks.js +2 -2
  11. package/dist/index.d.ts +18 -25
  12. package/dist/index.js +16 -23
  13. package/dist/landing.d.ts +3 -3
  14. package/dist/landing.js +7 -7
  15. package/dist/plan.d.ts +76 -70
  16. package/dist/plan.js +82 -77
  17. package/dist/providers/github.d.ts +2 -1
  18. package/dist/providers/github.js +2 -1
  19. package/dist/providers/gitlab.d.ts +3 -2
  20. package/dist/providers/gitlab.js +14 -8
  21. package/dist/providers/index.d.ts +5 -4
  22. package/dist/providers/index.js +8 -5
  23. package/dist/providers/types.d.ts +8 -2
  24. package/dist/prs.d.ts +35 -7
  25. package/dist/prs.js +45 -12
  26. package/dist/receipt.d.ts +46 -57
  27. package/dist/receipt.js +52 -28
  28. package/dist/reference.d.ts +12 -0
  29. package/dist/reference.js +152 -0
  30. package/dist/remote-mirror.d.ts +9 -1
  31. package/dist/remote-mirror.js +19 -1
  32. package/dist/render.d.ts +66 -61
  33. package/dist/render.js +206 -255
  34. package/dist/resolve.d.ts +3 -3
  35. package/dist/resolve.js +4 -12
  36. package/dist/status-line.d.ts +1 -1
  37. package/dist/status-line.js +5 -5
  38. package/dist/step-board.d.ts +1 -1
  39. package/dist/step-board.js +2 -5
  40. package/dist/submodules.d.ts +120 -0
  41. package/dist/submodules.js +292 -0
  42. package/dist/sweep.d.ts +107 -64
  43. package/dist/sweep.js +333 -119
  44. package/dist/types.d.ts +294 -105
  45. package/dist/types.js +106 -54
  46. package/dist/util.d.ts +13 -0
  47. package/dist/util.js +36 -0
  48. package/dist/version.generated.d.ts +1 -1
  49. package/dist/version.generated.js +1 -1
  50. package/dist/worktrees.d.ts +21 -37
  51. package/dist/worktrees.js +198 -165
  52. package/package.json +5 -5
  53. package/dist/apply.d.ts +0 -131
  54. package/dist/apply.js +0 -541
package/dist/sweep.js CHANGED
@@ -1,39 +1,225 @@
1
1
  /**
2
- * The sweep itself: one repository, or many.
2
+ * The sweep: resolve the targets, then inventory every repository they name.
3
3
  *
4
- * Every item carries the command that would act on it, and running that
5
- * command is the caller's decision. The only write is the optional fetch,
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 { DiscoveryFailed, discoverRepo } from "./discover.js";
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 { fetchPrs } from "./prs.js";
14
- import { providers } from "./providers/index.js";
15
- import { resolveEntries } from "./resolve.js";
16
- import { ALL_SCOPES, findDuplicateCheckouts, } from "./types.js";
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
- function scopeSet(scope) {
19
- return new Set(scope === undefined ? ALL_SCOPES : [...scope]);
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
- function clients(path, opts) {
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
- git: new Git(path, { runner: opts.git }),
24
- gh: new Gh(path, { runner: opts.gh }),
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
- * Inventory one repository.
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
- * Never throws for a repository that cannot be read: the failure is returned
31
- * as `error`, because a fan-out must not end on its first bad entry. A
32
- * single-repository caller checks that field.
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 scope = scopeSet(opts.scope);
36
- const { git, gh } = clients(path, opts);
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.remote ?? null,
53
- allowProvider: opts.noProvider !== true,
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
- if (err instanceof DiscoveryFailed)
62
- return { repo: null, path, items: [], error: err.message };
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
- return { repo, path, items: [], error: `refusing to clean up the default branch \`${only}\`` };
251
+ throw new RepoCleanerError('conflict', `--branch ${only} is the default branch, which is never a candidate for deletion`);
67
252
  }
68
- const cwd = only === undefined ? (opts.cwd ?? process.cwd()) : repo.root;
69
- if (opts.fetch === true && repo.remote !== null) {
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
- const prs = status === null ? new Map() : fetchPrs(repo, known[status.name], only);
89
- const items = [];
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 worktreeItems = [];
290
+ let worktreeRows = [];
93
291
  let worktreeBranches = new Set();
94
- if (scope.has('worktrees') || scope.has('local-branches')) {
292
+ if (kinds.has('worktree') || kinds.has('branch')) {
95
293
  ;
96
- [worktreeItems, worktreeBranches] = gatherWorktrees(repo, prs, cwd, git, only, opts.probe ?? defaultProcessProbe(), ticker('worktrees'));
294
+ [worktreeRows, worktreeBranches] = gatherWorktrees(repo, prs, cwd, git, { branch: only, path: opts.worktree }, opts.probe ?? defaultProcessProbe(), ticker('worktree'));
97
295
  }
98
- if (scope.has('worktrees'))
99
- items.push(...worktreeItems);
296
+ if (kinds.has('worktree'))
297
+ rows.push(...worktreeRows);
100
298
  let localBranchNames = new Set();
101
- if (scope.has('local-branches') || scope.has('remote-branches')) {
102
- // A branch whose worktree goes in the same plan is judged on its own:
103
- // apply removes worktrees before it deletes branches. A full sweep frees
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 (scope.has('worktrees')) {
108
- for (const item of worktreeItems) {
109
- const branch = item.facts['branch'];
110
- if (freesBranch(item, only !== undefined) && typeof branch === 'string') {
111
- freed.set(branch, item.id);
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 localItems = gatherLocalBranches(repo, prs, pinned, git, only, ticker('local-branches'));
117
- // Apply drops a freed branch unless the same run removes its worktree.
118
- for (const item of localItems) {
119
- const worktree = freed.get(item.id);
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
- item.facts['worktree'] = worktree;
316
+ row.evidence.worktree = worktree;
122
317
  }
123
- localBranchNames = new Set(localItems.map((item) => item.id));
124
- if (scope.has('local-branches'))
125
- items.push(...localItems);
318
+ localBranchNames = new Set(localRows.map((row) => row.name));
319
+ if (kinds.has('branch'))
320
+ rows.push(...localRows);
126
321
  }
127
- if (scope.has('remote-branches')) {
128
- items.push(...gatherRemoteBranches(repo, prs, localBranchNames, git, only, ticker('remote-branches')));
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
- return { repo, path, items, error: null };
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
- return {
134
- repo,
135
- path,
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
- * Resolve `entries` and inventory every repository they name.
143
- *
144
- * Entries may be checkouts or directories containing checkouts; see
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
- export function sweepRepos(entries, opts = {}) {
149
- const probe = new Git(opts.baseDir ?? process.cwd(), { runner: opts.git });
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
- * Whether worktree row `item` frees its branch to be judged on its own. A full
166
- * sweep frees it only for a safe-to-delete worktree; a branch-scoped sweep,
167
- * where the caller named the branch, for any deletable one.
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 a sweep freed for its worktree's removal when that
174
- * worktree no longer frees it by the sweep's own rule: a host's `annotate`
175
- * held it back or demoted it, or dropped its row. Run it on annotated rows,
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(items, opts = {}) {
180
- const going = new Set(items
181
- .filter((it) => it.category === 'worktree' && freesBranch(it, opts.branchScoped === true))
182
- .map((it) => it.id));
183
- return items.map((item) => {
184
- const worktree = item.facts['worktree'];
185
- if (item.category !== 'local_branch' || typeof worktree !== 'string')
186
- return item;
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
- ...item,
192
- classification: 'safe-to-keep',
193
- action: 'keep',
194
- reason: HELD_BY_WORKTREE,
195
- facts,
372
+ ...row,
373
+ verdict: 'keep',
374
+ action: null,
375
+ why: HELD_BY_WORKTREE,
376
+ evidence,
196
377
  commands: [],
197
- investigationParts: [],
198
- requires: [],
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
+ }