@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/clean.d.ts CHANGED
@@ -27,7 +27,7 @@ import type { Annotate } from './hooks.ts';
27
27
  import { type Plan } from './plan.ts';
28
28
  import { type Dropped, type Receipt, type Step } from './receipt.ts';
29
29
  import { type Runners } from './sweep.ts';
30
- import { type Kind, type Risk, type RowId, type Sweep, type SweepProgress, type SweptWith } from './types.ts';
30
+ import { type Kind, type Risk, type RowId, type Sweep, type SweepProgress, type SweptWith, type RepoNote } from './types.ts';
31
31
  /** A risk allowed for every row (`row` absent), or for one row. */
32
32
  export interface Allow {
33
33
  risk: Risk;
@@ -41,7 +41,7 @@ export interface Allow {
41
41
  * `pick` work on exactly these rows; a `review` row, or a `keep` row with
42
42
  * an action, runs only when picked. Default: every `delete` row.
43
43
  * `spare` leave these rows alone; a spared remote branch is still checked,
44
- * and the run stops if it is gone or moved since the plan.
44
+ * and the run stops if it moved or is gone since the plan.
45
45
  * `allow` the risks a row's action may carry.
46
46
  */
47
47
  export interface Approval {
@@ -74,7 +74,9 @@ interface Shared extends Runners {
74
74
  export interface CheckOptions extends Shared {
75
75
  /**
76
76
  * A sweep made just now, used as the re-check instead of sweeping again: for
77
- * a preview straight after a sweep. `run` always sweeps again.
77
+ * a preview straight after a sweep. A repository whose remote rows it read
78
+ * from disk (offline) is swept again, asking the remote. `run` always sweeps
79
+ * again.
78
80
  */
79
81
  sweep?: Sweep | undefined;
80
82
  /** Where remote URL clones are kept; a plan repository there must still push only where it fetches from. */
@@ -92,6 +94,12 @@ export interface RunOptions extends Shared {
92
94
  onStep?: ((index: number, total: number, step: Step) => void) | undefined;
93
95
  /** Told after each step, with its `result` set, before the receipt is saved. */
94
96
  onStepDone?: ((index: number, total: number, step: Step) => void) | undefined;
97
+ /**
98
+ * Told the run's own re-check's notes, before the first step: what it judged
99
+ * without. The re-check may differ from the preview's, for example when a
100
+ * listing fails only at the run.
101
+ */
102
+ onNotes?: ((notes: RepoNote[]) => void) | undefined;
95
103
  }
96
104
  /** What a run would do, for a person to approve: the steps, and the rows dropped and why. */
97
105
  export interface Preview {
@@ -105,6 +113,12 @@ export interface Preview {
105
113
  risk: Risk;
106
114
  row: RowId | null;
107
115
  }[];
116
+ /**
117
+ * `RepoSweep.notes`, with each repository's root: what the re-check judged
118
+ * without. With a sweep passed in (`CheckOptions.sweep`), every repository
119
+ * of that sweep; otherwise each plan repository the re-check swept again.
120
+ */
121
+ notes: RepoNote[];
108
122
  }
109
123
  /** A row reference as typed: a kind and a name, or a bare name (`kind: null`). */
110
124
  export interface RowRef {
@@ -146,7 +160,9 @@ export declare function networkRemoteKey(url: string): string | null;
146
160
  *
147
161
  * @throws RepoCleanerError for an approval that contradicts itself, names a
148
162
  * row the plan does not have or names one ambiguously, or spares a remote
149
- * branch that moved. Nothing has been touched.
163
+ * branch that moved. A plain `Error`, naming the repository, when the
164
+ * re-check cannot read one: a failed fetch, or a remote `ls-remote` cannot
165
+ * reach. Nothing has been touched.
150
166
  */
151
167
  export declare function check(plan: Plan, approval: Approval, opts?: CheckOptions): Preview;
152
168
  /**
@@ -156,7 +172,8 @@ export declare function check(plan: Plan, approval: Approval, opts?: CheckOption
156
172
  * row it dropped stays dropped.
157
173
  *
158
174
  * @throws RepoCleanerError (`spared-changed`) when a spared remote branch moved
159
- * since the preview. Nothing has been touched.
175
+ * since the preview, and a plain `Error` when the re-check cannot read a
176
+ * repository, as {@link check} does. Nothing has been touched.
160
177
  */
161
178
  export declare function run(preview: Preview, opts?: RunOptions): Receipt;
162
179
  export {};
package/dist/clean.js CHANGED
@@ -35,7 +35,7 @@ import { RECEIPT_VERSION } from "./receipt.js";
35
35
  import { defaultCacheDir, mirrorProblem } from "./remote-mirror.js";
36
36
  import { inspectSubmodules, submoduleHazards } from "./submodules.js";
37
37
  import { holdFreedBranches, sweepRepo } from "./sweep.js";
38
- import { KINDS, RISKS, RepoCleanerError, parseRowId, } from "./types.js";
38
+ import { KINDS, RISKS, RepoCleanerError, parseRowId, sweepNotes, } from "./types.js";
39
39
  import { errorText } from "./util.js";
40
40
  import { removeIsForced, worktreeCommands } from "./worktrees.js";
41
41
  import { writeJsonAtomic } from "./write-json.js";
@@ -184,12 +184,21 @@ export function resolveApproval(plan, approval, cwd) {
184
184
  function allowed(allow, at, risk) {
185
185
  return allow.some((a) => a.risk === risk && (a.row === null || same(a.row, at)));
186
186
  }
187
- /** The plan repository's rows as they stand now, annotated as the sweep annotated them. */
187
+ /**
188
+ * The plan repository's rows as they stand now, annotated as the sweep
189
+ * annotated them, and, when it had to sweep again, that sweep's notes on what
190
+ * it judged without. A sweep passed in reports its notes whole, in `recheck`.
191
+ */
188
192
  function resweep(repo, plan, kinds, opts) {
189
193
  const branchScoped = plan.options.branch !== null;
190
194
  const given = opts.sweep?.repos.find((r) => r.repo?.root === repo.root);
191
- if (given !== undefined && given.error === null) {
192
- return holdFreedBranches([...given.rows], branchScoped);
195
+ // An offline sweep read remote tips from disk, so it cannot stand in for
196
+ // the re-check of remote rows: sweep again and ask the remote, so the
197
+ // preview shows what the run will do.
198
+ const readFromDisk = kinds.includes('remote') && given?.repo?.fetched !== true;
199
+ if (given !== undefined && given.error === null && !readFromDisk) {
200
+ // Its notes are reported with the rest of that sweep's, by `recheck`.
201
+ return { rows: holdFreedBranches([...given.rows], branchScoped), notes: [] };
193
202
  }
194
203
  const swept = sweepRepo(repo.root, {
195
204
  ...opts,
@@ -197,13 +206,16 @@ function resweep(repo, plan, kinds, opts) {
197
206
  against: plan.options.against ?? undefined,
198
207
  provider: plan.options.provider,
199
208
  fetch: repo.fetched,
209
+ // Offline, the plan read tracking refs that can be stale; before anything
210
+ // is deleted on the remote, ask the remote itself.
211
+ liveRemoteTips: kinds.includes('remote'),
200
212
  branch: plan.options.branch ?? undefined,
201
213
  cwd: opts.cwd ?? process.cwd(),
202
214
  });
203
215
  if (swept.repo === null || swept.error !== null) {
204
- throw new Error(swept.error ?? `could not read repository: ${repo.root}`);
216
+ throw new Error(`${repo.root}: ${swept.error ?? 'could not read the repository'}`);
205
217
  }
206
- return swept.rows;
218
+ return { rows: swept.rows, notes: swept.notes };
207
219
  }
208
220
  /**
209
221
  * Re-sweep, then split the selection into rows to run and rows dropped. With
@@ -229,6 +241,7 @@ function recheck(plan, approval, opts, safeOnly) {
229
241
  kindsIn.set(s.repo, kinds);
230
242
  }
231
243
  const currentRows = new Map();
244
+ const notes = [];
232
245
  for (const [root, wanted] of kindsIn) {
233
246
  const repo = plan.repos.find((r) => r.root === root);
234
247
  // Only the kinds the selection is in: a plan of local rows need not
@@ -236,12 +249,19 @@ function recheck(plan, approval, opts, safeOnly) {
236
249
  // decide whether it is free to go or held.
237
250
  if (wanted.has('branch') && plan.options.only.includes('worktree'))
238
251
  wanted.add('worktree');
239
- for (const row of resweep(repo, plan, KINDS.filter((k) => wanted.has(k)), opts)) {
252
+ const now = resweep(repo, plan, KINDS.filter((k) => wanted.has(k)), opts);
253
+ for (const row of now.rows)
240
254
  currentRows.set(keyOf({ repo: root, id: row.id }), row);
241
- }
255
+ for (const note of now.notes)
256
+ notes.push({ repo: root, note });
242
257
  }
243
258
  const planned = (s) => plannedRows.get(keyOf(s));
244
259
  const current = (s) => currentRows.get(keyOf(s));
260
+ // A refusal ends the check before its notes are shown, so it carries them.
261
+ const alsoNoted = (root) => {
262
+ const said = notes.filter((n) => n.repo === root).map((n) => n.note);
263
+ return said.length === 0 ? '' : ` (${said.join(' ')})`;
264
+ };
245
265
  // A spared remote branch is checked, not dropped: one that moved refuses the run.
246
266
  const verified = [];
247
267
  for (const s of approval.spare) {
@@ -250,7 +270,7 @@ function recheck(plan, approval, opts, safeOnly) {
250
270
  const row = current(s);
251
271
  const plannedSha = planned(s).fingerprint['sha'];
252
272
  if (row === undefined || row.kind !== 'remote') {
253
- throw new RepoCleanerError('spared-changed', `spared ${s.id} is no longer on the remote; refusing to run`);
273
+ throw new RepoCleanerError('spared-changed', `spared ${s.id} is no longer on the remote; refusing to run${alsoNoted(s.repo)}`);
254
274
  }
255
275
  if (typeof plannedSha !== 'string' || row.evidence.sha !== plannedSha) {
256
276
  throw new RepoCleanerError('spared-changed', `spared ${s.id} moved since the plan (planned ${String(plannedSha)}, now ${row.evidence.sha}); refusing to run`);
@@ -310,7 +330,25 @@ function recheck(plan, approval, opts, safeOnly) {
310
330
  detail: `checked out in ${worktree}, which this run does not remove`,
311
331
  });
312
332
  }
313
- return { accepted: kept, dropped, verified };
333
+ // A sweep passed in speaks for every repository it read, selected or not: a
334
+ // repository with nothing to run is where a missing listing matters most.
335
+ return {
336
+ accepted: kept,
337
+ dropped,
338
+ verified,
339
+ notes: opts.sweep === undefined ? notes : uniqueNotes([...sweepNotes(opts.sweep), ...notes]),
340
+ };
341
+ }
342
+ /** Notes once each: a repository the given sweep read offline is swept again, and can repeat one. */
343
+ function uniqueNotes(notes) {
344
+ const seen = new Set();
345
+ return notes.filter((n) => {
346
+ const key = `${n.repo}\0${n.note}`;
347
+ if (seen.has(key))
348
+ return false;
349
+ seen.add(key);
350
+ return true;
351
+ });
314
352
  }
315
353
  // ── one row per remote branch ──────────────────────────────────────────────
316
354
  /**
@@ -476,7 +514,7 @@ function planSteps(plan, approval, opts, safeOnly) {
476
514
  ...rechecked.verified.map((a) => [verifyStep(a), a]),
477
515
  ...ordered.flatMap((a) => stepsFor(a).map((step) => [step, a])),
478
516
  ];
479
- return { steps, dropped, accepted: ordered, removedHere };
517
+ return { steps, dropped, accepted: ordered, removedHere, notes: rechecked.notes };
480
518
  }
481
519
  /** A worktree row whose deletion takes the repository-wide prune: its directory is left without its `.git`. */
482
520
  function prunesAll(row) {
@@ -510,20 +548,22 @@ function checkMirrors(plan, cacheDir) {
510
548
  *
511
549
  * @throws RepoCleanerError for an approval that contradicts itself, names a
512
550
  * row the plan does not have or names one ambiguously, or spares a remote
513
- * branch that moved. Nothing has been touched.
551
+ * branch that moved. A plain `Error`, naming the repository, when the
552
+ * re-check cannot read one: a failed fetch, or a remote `ls-remote` cannot
553
+ * reach. Nothing has been touched.
514
554
  */
515
555
  export function check(plan, approval, opts = {}) {
516
556
  const cwd = opts.cwd ?? process.cwd();
517
557
  checkMirrors(plan, opts.cacheDir ?? defaultCacheDir());
518
558
  const resolved = resolveApproval(plan, approval, cwd);
519
- const { steps, dropped, accepted } = planSteps(plan, resolved, opts, resolved.pick.length === 0);
559
+ const { steps, dropped, accepted, notes } = planSteps(plan, resolved, opts, resolved.pick.length === 0);
520
560
  for (const [step] of steps)
521
561
  step.result = 'would-run';
522
562
  const unusedAllows = resolved.allow
523
563
  .filter((a) => !accepted.some((row) => row.row.risks.includes(a.risk) &&
524
564
  (a.row === null || same(a.row, { repo: row.repo, id: row.row.id }))))
525
565
  .map((a) => ({ risk: a.risk, row: a.row?.id ?? null }));
526
- return { plan, approval: resolved, steps: steps.map(([s]) => s), dropped, unusedAllows };
566
+ return { plan, approval: resolved, steps: steps.map(([s]) => s), dropped, unusedAllows, notes };
527
567
  }
528
568
  // ── the run ────────────────────────────────────────────────────────────────
529
569
  /**
@@ -562,9 +602,19 @@ function assertSafeToForce(path, git, a) {
562
602
  if (reasons.length > 0)
563
603
  throw new Error(`refused: ${reasons.join('; ')}`);
564
604
  }
565
- /** Run one step, then confirm it took. Throws with the reason on failure. */
605
+ /** A remote step that found the branch already gone: its verification, marked so. */
606
+ class Gone {
607
+ verification;
608
+ constructor(verification) {
609
+ this.verification = verification;
610
+ }
611
+ }
612
+ /**
613
+ * Run one step, then confirm it took. Throws with the reason on failure.
614
+ */
566
615
  function execute(step, a, git, probe, removedHere) {
567
616
  const at = git.at(step.repo);
617
+ const remoteTip = (remote, ref) => at.lsRemote(remote, ref).find((r) => r.ref === ref);
568
618
  const registered = (path) => at.worktrees().some((w) => resolve(w.path) === resolve(path));
569
619
  const row = a?.row;
570
620
  switch (step.action) {
@@ -641,7 +691,7 @@ function execute(step, a, git, probe, removedHere) {
641
691
  if (row?.kind !== 'remote')
642
692
  throw new Error('verify-remote without a remote branch');
643
693
  const ref = `refs/heads/${row.evidence.branch}`;
644
- const tip = at.lsRemote(row.evidence.remote, ref).find((r) => r.ref === ref);
694
+ const tip = remoteTip(row.evidence.remote, ref);
645
695
  if (tip === undefined)
646
696
  throw new Error(`refused: spared ${ref} is gone from ${row.evidence.remote}`);
647
697
  if (tip.sha !== step.expectedSha) {
@@ -659,10 +709,20 @@ function execute(step, a, git, probe, removedHere) {
659
709
  });
660
710
  const refused = report.refs.find((r) => r.flag === '!');
661
711
  if (refused?.flag === '!') {
712
+ // Someone deleted the branch between the re-check and this push, so
713
+ // the lease refuses; a ref the remote no longer lists is done. Drop
714
+ // the tracking ref, as a delete push would have.
715
+ if (remoteTip(remote, ref) === undefined) {
716
+ // Compare-and-swap: a tracking ref that moved since is left alone.
717
+ at.try.deleteRef(`refs/remotes/${remote}/${row.evidence.branch}`, {
718
+ expected: step.expectedSha,
719
+ });
720
+ return new Gone(`already gone from ${remote}`);
721
+ }
662
722
  const why = refused.rawReason === null ? '' : ` (${refused.rawReason})`;
663
723
  throw new Error(`${refused.summary}${why}: ${PUSH_REJECTIONS[refused.rejection].message}`);
664
724
  }
665
- if (at.lsRemote(remote, ref).some((r) => r.ref === ref))
725
+ if (remoteTip(remote, ref) !== undefined)
666
726
  throw new Error(`ls-remote still lists ${ref}`);
667
727
  return 'ls-remote lists nothing';
668
728
  }
@@ -675,7 +735,8 @@ function execute(step, a, git, probe, removedHere) {
675
735
  * row it dropped stays dropped.
676
736
  *
677
737
  * @throws RepoCleanerError (`spared-changed`) when a spared remote branch moved
678
- * since the preview. Nothing has been touched.
738
+ * since the preview, and a plain `Error` when the re-check cannot read a
739
+ * repository, as {@link check} does. Nothing has been touched.
679
740
  */
680
741
  export function run(preview, opts = {}) {
681
742
  const now = opts.now ?? (() => new Date());
@@ -714,6 +775,7 @@ export function run(preview, opts = {}) {
714
775
  // so a row the re-check now judges `review` drops rather than runs.
715
776
  const safeOnly = preview.approval.pick.length === 0;
716
777
  const planned = planSteps(preview.plan, approval, { ...opts, sweep: undefined }, safeOnly);
778
+ opts.onNotes?.(planned.notes);
717
779
  const known = new Set(receipt.dropped.map((d) => `${d.repo}\0${d.row}`));
718
780
  for (const d of planned.dropped) {
719
781
  if (!known.has(`${d.repo}\0${d.row}`))
@@ -732,7 +794,14 @@ export function run(preview, opts = {}) {
732
794
  for (const [i, [step, a]] of plannedSteps.entries()) {
733
795
  opts.onStep?.(i, plannedSteps.length, step);
734
796
  try {
735
- step.verification = execute(step, a, git, probe, removedHere);
797
+ const done = execute(step, a, git, probe, removedHere);
798
+ if (done instanceof Gone) {
799
+ step.verification = done.verification;
800
+ step.gone = true;
801
+ }
802
+ else {
803
+ step.verification = done;
804
+ }
736
805
  step.result = 'ok';
737
806
  if (step.action === 'unlock-worktree')
738
807
  unlocked.add(a.row.name);
package/dist/cli.js CHANGED
@@ -22,7 +22,7 @@
22
22
  import { readSync, rmSync } from 'node:fs';
23
23
  import { basename, dirname, join, resolve } from 'node:path';
24
24
  import { isatty } from 'node:tty';
25
- import { EXIT, askYesNo, canPrompt, defineCli, } from '@osdlc/cli-tool';
25
+ import { EXIT, askYesNo, canPrompt, defineCli, renderError, } from '@osdlc/cli-tool';
26
26
  import { detectColorLevel, stripControls } from '@osdlc/ux';
27
27
  import { check, checkApproval, parseAllow, run } from "./clean.js";
28
28
  import { fileStamp, parsePlan, plan as planOf, readPlan, writePlan } from "./plan.js";
@@ -33,7 +33,7 @@ import { PLAIN, TERMINAL_STYLE, renderCommands, renderJson, renderLateDrops, ren
33
33
  import { NO_STATUS_LINE, describeStep, describeSweep, statusLine, } from "./status-line.js";
34
34
  import { stepBoard } from "./step-board.js";
35
35
  import { sweep as sweepTargets, sweptWith } from "./sweep.js";
36
- import { KINDS, RISKS, RepoCleanerError } from "./types.js";
36
+ import { KINDS, RISKS, RepoCleanerError, sweepNotes, } from "./types.js";
37
37
  const EXIT_NOTHING_TO_SWEEP = 10;
38
38
  const EXIT_STEP_FAILED = 11;
39
39
  const NAME = 'repo-cleaner';
@@ -84,7 +84,7 @@ const TARGETS = [
84
84
  {
85
85
  name: 'target',
86
86
  variadic: true,
87
- help: 'a checkout, a directory of checkouts, or a remote URL (default: the checkout you are in)',
87
+ help: 'a checkout, a directory of checkouts, or a remote URL (default: the directory you are in)',
88
88
  },
89
89
  ];
90
90
  const MORE = `Run with --help --extended for the full reference: what every option means, the words the report uses, and which combinations are refused.`;
@@ -324,6 +324,15 @@ function reportUnreadable(ctx, name, s) {
324
324
  ctx.io.stderr(`${name}: ${r.repo?.root ?? r.path}: ${r.error}\n`);
325
325
  return failed.length > 0;
326
326
  }
327
+ /** Each repository's `RepoSweep.notes`, on stderr, for the outputs that do not carry them. */
328
+ function reportNotes(ctx, name, s) {
329
+ writeNotes(ctx, name, sweepNotes(s));
330
+ }
331
+ /** Notes as `<name>: <repo>: <note>` lines on stderr. A note quotes git's and the provider's output, so it goes out as an error line does, controls removed. */
332
+ function writeNotes(ctx, name, notes) {
333
+ for (const { repo, note } of notes)
334
+ ctx.io.stderr(renderError(`${repo}: ${note}`, name));
335
+ }
327
336
  function runReport(argv, ctx, status) {
328
337
  const parsed = parseWith(reportCli(), NAME, argv, ctx);
329
338
  if (parsed.status === 'done')
@@ -342,6 +351,7 @@ function runReport(argv, ctx, status) {
342
351
  else if (v.format === 'summary')
343
352
  ctx.io.stdout(renderSummary(s, style) + remoteNote(s, style));
344
353
  else if (v.format === 'commands') {
354
+ reportNotes(ctx, NAME, s);
345
355
  const commands = renderCommands(s);
346
356
  if (commands.length > 0)
347
357
  ctx.io.stdout(commands.join('\n') + '\n');
@@ -366,6 +376,7 @@ function runPlan(argv, ctx, status) {
366
376
  return EXIT_NOTHING_TO_SWEEP;
367
377
  }
368
378
  const plan = planOf(swept.sweep);
379
+ reportNotes(ctx, name, swept.sweep);
369
380
  const unreadable = reportUnreadable(ctx, name, swept.sweep);
370
381
  const style = styleFor(ctx, 'stderr');
371
382
  ctx.io.stderr(renderTotals(swept.sweep, style) + '\n' + remoteNote(swept.sweep, style));
@@ -504,9 +515,21 @@ function runClean(argv, ctx, deps, status) {
504
515
  return refusal(ctx, name, err);
505
516
  }
506
517
  status.clear();
518
+ // The re-check's notes: from the sweep just made, or from sweeping a saved
519
+ // plan's repositories again. The run re-checks once more; only a note that
520
+ // is new by then is printed again.
521
+ const noted = new Set();
522
+ const notesOnce = (notes) => writeNotes(ctx, name, notes.filter(({ repo, note }) => {
523
+ const key = `${repo}\0${note}`;
524
+ if (noted.has(key))
525
+ return false;
526
+ noted.add(key);
527
+ return true;
528
+ }));
529
+ notesOnce(preview.notes);
507
530
  const showPreview = () => {
508
531
  if (v.format === 'json') {
509
- ctx.io.stdout(`${JSON.stringify({ dryRun: v.dryRun, summary: summarizeRun(preview), steps: preview.steps, dropped: preview.dropped, unusedAllows: preview.unusedAllows }, null, 2)}\n`);
532
+ ctx.io.stdout(`${JSON.stringify({ dryRun: v.dryRun, summary: summarizeRun(preview), steps: preview.steps, dropped: preview.dropped, unusedAllows: preview.unusedAllows, notes: preview.notes }, null, 2)}\n`);
510
533
  }
511
534
  else {
512
535
  ctx.io.stdout(renderPreview(preview, style));
@@ -540,6 +563,11 @@ function runClean(argv, ctx, deps, status) {
540
563
  source,
541
564
  receiptPath,
542
565
  now: () => new Date(),
566
+ onNotes: (notes) => {
567
+ // The run's re-check draws its progress on the status line.
568
+ status.clear();
569
+ notesOnce(notes);
570
+ },
543
571
  ...(live
544
572
  ? {
545
573
  // The board carries the run's progress; the status line stays clear.
package/dist/index.d.ts CHANGED
@@ -21,4 +21,4 @@ export { render, type RunSummary, type Style } from './render.ts';
21
21
  export { defaultProcessProbe, type ProcessHolder, type ProcessProbe } from './processes.ts';
22
22
  export { providers, type PrProvider, type ProviderChoice } from './providers/index.ts';
23
23
  export type { Annotate } from './hooks.ts';
24
- export { KINDS, RISKS, RepoCleanerError, type Action, type Blocker, type BranchEvidence, type BranchRow, type Bucket, type Counts, type ErrorCode, type Kind, type LandingEvidence, type PrEvidence, type RemoteEvidence, type RemoteRow, type RepoInfo, type RepoSweep, type Risk, type Row, type RowId, type SubmoduleEvidence, type Sweep, type SweepProgress, type SweptWith, type Verdict, type WorktreeEvidence, type WorktreeRow, } from './types.ts';
24
+ export { KINDS, RISKS, RepoCleanerError, type Action, type Blocker, type BranchEvidence, type BranchRow, type Bucket, type Counts, type ErrorCode, type Kind, type LandingEvidence, type PrEvidence, type RemoteEvidence, type RemoteRow, type RepoInfo, type RepoNote, type RepoSweep, type Risk, type Row, type RowId, type SubmoduleEvidence, type Sweep, type SweepProgress, type SweptWith, type Verdict, type WorktreeEvidence, type WorktreeRow, } from './types.ts';
@@ -5,6 +5,7 @@ import type { PrProvider } from './types.ts';
5
5
  export declare const PR_LIMIT = 300;
6
6
  /**
7
7
  * A provider over `gh pr list`. `gh` picks the repository from the checkout's
8
- * remotes, so `target` is not consulted.
8
+ * remotes, so `target` is not consulted. A listing `gh` fails, such as one for
9
+ * a repository the signed-in account cannot see, throws with `gh`'s reason.
9
10
  */
10
11
  export declare function githubProvider(gh: Gh): PrProvider;
@@ -15,7 +15,8 @@ const FIELDS = [
15
15
  export const PR_LIMIT = 300;
16
16
  /**
17
17
  * A provider over `gh pr list`. `gh` picks the repository from the checkout's
18
- * remotes, so `target` is not consulted.
18
+ * remotes, so `target` is not consulted. A listing `gh` fails, such as one for
19
+ * a repository the signed-in account cannot see, throws with `gh`'s reason.
19
20
  */
20
21
  export function githubProvider(gh) {
21
22
  return {
@@ -25,8 +25,9 @@ interface MergeRequest {
25
25
  /** One merge request as a {@link PRInfo}, or null for a fork's or an unknown state's. */
26
26
  export declare function mergeRequestToPr(mr: MergeRequest): PRInfo | null;
27
27
  /**
28
- * A provider over `glab`. It needs `target.project`: without one there is
29
- * nothing to ask, and the listing answers null.
28
+ * A provider over `glab`. It needs `target.project`. A listing it cannot
29
+ * make or read throws with the reason: no project in the remote URL, `glab`'s
30
+ * own error, or a page that cannot be read.
30
31
  */
31
32
  export declare function gitlabProvider(glab: CommandRunner): PrProvider;
32
33
  export {};
@@ -6,7 +6,7 @@
6
6
  * an explicit host and project, so a checkout with several remotes is read
7
7
  * against the one the sweep measures.
8
8
  */
9
- import { parseJsonResult, runFailed, } from '@osdlc/command-seam';
9
+ import { parseJsonResult, runFailed, stderrOrError, } from '@osdlc/command-seam';
10
10
  import { PR_LIMIT } from "./github.js";
11
11
  const PER_PAGE = 100;
12
12
  /** GitLab's states, as the GitHub names classification reads. A locked MR is an open one mid-update. */
@@ -59,8 +59,9 @@ function succeeded(result) {
59
59
  return !runFailed(result);
60
60
  }
61
61
  /**
62
- * A provider over `glab`. It needs `target.project`: without one there is
63
- * nothing to ask, and the listing answers null.
62
+ * A provider over `glab`. It needs `target.project`. A listing it cannot
63
+ * make or read throws with the reason: no project in the remote URL, `glab`'s
64
+ * own error, or a page that cannot be read.
64
65
  */
65
66
  export function gitlabProvider(glab) {
66
67
  const run = (root, argv) => glab.run(argv, { cwd: root });
@@ -75,7 +76,7 @@ export function gitlabProvider(glab) {
75
76
  },
76
77
  listPrs(root, target, branch) {
77
78
  if (target.project === null)
78
- return null;
79
+ throw new Error('the remote URL names no GitLab project');
79
80
  const project = encodeURIComponent(target.project);
80
81
  const prs = [];
81
82
  for (let page = 1; (page - 1) * PER_PAGE < PR_LIMIT; page++) {
@@ -95,11 +96,16 @@ export function gitlabProvider(glab) {
95
96
  ]);
96
97
  // A failed, cut-short or unreadable page fails the listing: a missing
97
98
  // open MR would read as no MR.
98
- if (!succeeded(result))
99
- return null;
99
+ if (!succeeded(result)) {
100
+ // Not stdout: a page cut short is half a JSON listing, not a reason.
101
+ throw new Error(result.outputIncomplete === true
102
+ ? `page ${page} was cut short`
103
+ : stderrOrError(result) || `glab exited ${String(result.exitCode)}`);
104
+ }
100
105
  const listed = parseJsonResult(result);
101
- if (!listed.ok || !Array.isArray(listed.value))
102
- return null;
106
+ if (!listed.ok || !Array.isArray(listed.value)) {
107
+ throw new Error(`page ${page} is not a merge request list`);
108
+ }
103
109
  const rows = listed.value;
104
110
  for (const row of rows) {
105
111
  if (!isMergeRequest(row))
@@ -20,10 +20,11 @@ export declare function providers(clients: {
20
20
  */
21
21
  export declare function forgeTarget(url: string | null): ForgeTarget;
22
22
  /**
23
- * The provider `choice` names for `target`. `auto` reads the host: one with
24
- * `gitlab` in its name is GitLab, any other is GitHub, which is what the sweep
25
- * did before providers existed (`gh` answers nothing for a host it does not
26
- * know). `none` is no provider.
23
+ * The provider `choice` names for `target`. `auto` reads the host: none, for
24
+ * a remote that names no host (a local path or `file://`), which cannot have
25
+ * pull requests; one with `gitlab` in its name is GitLab; any other is
26
+ * GitHub, which is what the sweep did before providers existed (`gh` answers
27
+ * nothing for a host it does not know). `none` is no provider.
27
28
  */
28
29
  export declare function chooseProvider(choice: ProviderChoice, target: ForgeTarget): ProviderName | null;
29
30
  /** The chosen provider for a repository, probed; null for none. */
@@ -41,17 +41,20 @@ export function forgeTarget(url) {
41
41
  return { host: match[1].toLowerCase(), project: parts.join('/') };
42
42
  }
43
43
  /**
44
- * The provider `choice` names for `target`. `auto` reads the host: one with
45
- * `gitlab` in its name is GitLab, any other is GitHub, which is what the sweep
46
- * did before providers existed (`gh` answers nothing for a host it does not
47
- * know). `none` is no provider.
44
+ * The provider `choice` names for `target`. `auto` reads the host: none, for
45
+ * a remote that names no host (a local path or `file://`), which cannot have
46
+ * pull requests; one with `gitlab` in its name is GitLab; any other is
47
+ * GitHub, which is what the sweep did before providers existed (`gh` answers
48
+ * nothing for a host it does not know). `none` is no provider.
48
49
  */
49
50
  export function chooseProvider(choice, target) {
50
51
  if (choice === 'none')
51
52
  return null;
52
53
  if (choice !== 'auto')
53
54
  return choice;
54
- return target.host !== null && target.host.includes('gitlab') ? 'gitlab' : 'github';
55
+ if (target.host === null)
56
+ return null;
57
+ return target.host.includes('gitlab') ? 'gitlab' : 'github';
55
58
  }
56
59
  /** The chosen provider for a repository, probed; null for none. */
57
60
  export function probeProvider(choice, root, target, all) {
@@ -34,6 +34,11 @@ export interface ProviderStatus extends ForgeTarget, ProviderWords {
34
34
  available: boolean;
35
35
  /** The CLI has credentials for `host`. */
36
36
  authenticated: boolean;
37
+ /**
38
+ * Set by the sweep when the provider was asked and its listing failed: why,
39
+ * in the provider's words. Absent when it answered or was not asked.
40
+ */
41
+ listError?: string | undefined;
37
42
  }
38
43
  /** One source of pull request evidence. */
39
44
  export interface PrProvider {
@@ -46,8 +51,9 @@ export interface PrProvider {
46
51
  };
47
52
  /**
48
53
  * The repository's pull requests, fork PRs already dropped, in any order;
49
- * null when the listing failed. A failure is not "no PRs": the caller
50
- * classifies on git evidence alone.
54
+ * null, or a throw whose message says why, when the listing failed. A
55
+ * failure is not "no PRs": the caller classifies on git evidence alone and
56
+ * notes the failure on the repository.
51
57
  *
52
58
  * @param branch Narrow the listing to this exact head branch. The caller
53
59
  * checks the head again, so a provider that cannot narrow may list all.
package/dist/prs.d.ts CHANGED
@@ -7,24 +7,52 @@
7
7
  * `providers/`.
8
8
  */
9
9
  import type { PrProvider } from './providers/index.ts';
10
+ import type { ProviderStatus } from './providers/types.ts';
10
11
  import type { PRInfo, RepoInfo } from './types.ts';
12
+ /** Why the provider did not answer, and the words for it. */
13
+ export interface PrsUnchecked {
14
+ kind: 'not-installed' | 'not-signed-in' | 'list-failed';
15
+ /** For a sentence: "`gh` is not installed", "`gh pr list` failed". */
16
+ why: string;
17
+ }
11
18
  /**
12
- * Every same-repository pull request, keyed by head branch. Fork PRs are
13
- * dropped by the provider: a fork branch that shares a name proves nothing
14
- * about ours.
19
+ * Why open pull requests cannot be ruled out for this repository, or null
20
+ * when the provider answered. A missing or signed-out CLI was never asked, and
21
+ * a failed listing answered nothing; either way a remote branch with no known
22
+ * PR may still have an open one. Every sentence about a lookup that did not
23
+ * answer is built from this one answer.
24
+ */
25
+ export declare function prsUnchecked(status: ProviderStatus): PrsUnchecked | null;
26
+ /** A provider's answer for one repository: its pull requests, or why there are none to read. */
27
+ export interface PrLookup {
28
+ /** Pull requests keyed by head branch; empty when the lookup failed or was not made. */
29
+ prs: Map<string, PRInfo>;
30
+ /**
31
+ * Why the listing failed, in the provider's own words where it gave any;
32
+ * null when it answered, or when it was not asked at all.
33
+ */
34
+ error: string | null;
35
+ }
36
+ /**
37
+ * Every same-repository pull request, keyed by head branch, and why there are
38
+ * none when the listing failed. Fork PRs are dropped by the provider: a fork
39
+ * branch that shares a name proves nothing about ours.
15
40
  *
16
41
  * A branch can carry several PRs over its life. The one that decides a
17
42
  * verdict is the most live: an open PR means keep whatever else happened, and
18
43
  * a merged one means the work landed. Ties go to the higher number, so two
19
44
  * runs over unchanged state agree.
20
45
  *
21
- * An empty map is the honest answer when the provider's CLI is missing,
22
- * unauthenticated or failing, or when there is no provider. Classification
23
- * degrades to git evidence and the report says so.
46
+ * The map is empty when the provider's CLI is missing or unauthenticated, when
47
+ * there is no provider, and when the listing fails. A provider that answers
48
+ * null or throws has failed (a repository the signed-in account cannot see, a
49
+ * forge that is down, output that cannot be read), and `error` says why.
50
+ * Classification then degrades to git evidence, and the sweep notes it rather
51
+ * than losing every row to one listing.
24
52
  *
25
53
  * @param provider The provider to ask. It is asked only when it is the one
26
54
  * `repo.provider` names and that probe found it signed in.
27
55
  * @param branch Ask only for PRs whose head is exactly this branch; rows
28
56
  * for any other head are dropped here too.
29
57
  */
30
- export declare function fetchPrs(repo: RepoInfo, provider: PrProvider, branch?: string): Map<string, PRInfo>;
58
+ export declare function lookupPrs(repo: RepoInfo, provider: PrProvider, branch?: string): PrLookup;