@osdlc/repo-cleaner 0.18.0 → 0.19.0

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