@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/dist/sweep.js CHANGED
@@ -1,39 +1,187 @@
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
16
  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";
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
- function scopeSet(scope) {
19
- return new Set(scope === undefined ? ALL_SCOPES : [...scope]);
20
- }
21
- function clients(path, opts) {
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
- git: new Git(path, { runner: opts.git }),
24
- gh: new Gh(path, { runner: opts.gh }),
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
- * Inventory one repository.
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 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.
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 scope = scopeSet(opts.scope);
36
- const { git, gh } = clients(path, opts);
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.remote ?? null,
53
- allowProvider: opts.noProvider !== true,
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
- 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) };
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
- return { repo, path, items: [], error: `refusing to clean up the default branch \`${only}\`` };
213
+ throw new RepoCleanerError('conflict', `--branch ${only} is the default branch, which is never a candidate for deletion`);
67
214
  }
68
- const cwd = only === undefined ? (opts.cwd ?? process.cwd()) : repo.root;
69
- if (opts.fetch === true && repo.remote !== null) {
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 items = [];
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 worktreeItems = [];
238
+ let worktreeRows = [];
93
239
  let worktreeBranches = new Set();
94
- if (scope.has('worktrees') || scope.has('local-branches')) {
240
+ if (kinds.has('worktree') || kinds.has('branch')) {
95
241
  ;
96
- [worktreeItems, worktreeBranches] = gatherWorktrees(repo, prs, cwd, git, only, opts.probe ?? defaultProcessProbe(), ticker('worktrees'));
242
+ [worktreeRows, worktreeBranches] = gatherWorktrees(repo, prs, cwd, git, { branch: only, path: opts.worktree }, opts.probe ?? defaultProcessProbe(), ticker('worktree'));
97
243
  }
98
- if (scope.has('worktrees'))
99
- items.push(...worktreeItems);
244
+ if (kinds.has('worktree'))
245
+ rows.push(...worktreeRows);
100
246
  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.
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 (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
- }
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 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);
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
- item.facts['worktree'] = worktree;
264
+ row.evidence.worktree = worktree;
122
265
  }
123
- localBranchNames = new Set(localItems.map((item) => item.id));
124
- if (scope.has('local-branches'))
125
- items.push(...localItems);
266
+ localBranchNames = new Set(localRows.map((row) => row.name));
267
+ if (kinds.has('branch'))
268
+ rows.push(...localRows);
126
269
  }
127
- if (scope.has('remote-branches')) {
128
- items.push(...gatherRemoteBranches(repo, prs, localBranchNames, git, only, ticker('remote-branches')));
270
+ if (kinds.has('remote')) {
271
+ rows.push(...gatherRemoteBranches(repo, prs, localBranchNames, git, only, ticker('remote')));
129
272
  }
130
- return { repo, path, items, error: null };
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
- return {
134
- repo,
135
- path,
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
- * 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.
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
- 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
- };
289
+ function freesBranch(row, branchScoped) {
290
+ return row.action !== null && (branchScoped || row.verdict === 'delete');
163
291
  }
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');
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 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.
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(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;
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
- ...item,
192
- classification: 'safe-to-keep',
193
- action: 'keep',
194
- reason: HELD_BY_WORKTREE,
195
- facts,
312
+ ...row,
313
+ verdict: 'keep',
314
+ action: null,
315
+ why: HELD_BY_WORKTREE,
316
+ evidence,
196
317
  commands: [],
197
- investigationParts: [],
198
- requires: [],
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
+ }