@osdlc/repo-cleaner 0.11.0 → 0.14.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/cli.js CHANGED
@@ -26,14 +26,17 @@ import { basename, dirname, join, resolve, sep } from 'node:path';
26
26
  import { isatty } from 'node:tty';
27
27
  import { EXIT, askYesNo, canPrompt, defineCli, systemDirs } from '@osdlc/cli-tool';
28
28
  import { Git } from '@osdlc/typed-git';
29
+ import { detectColorLevel } from '@osdlc/ux';
29
30
  import { resolveBranch } from "./branches.js";
30
31
  import { VENDOR_DIRS } from "./devlink.js";
31
32
  import { ensureMirror, mirrorProblem } from "./remote-mirror.js";
32
33
  import { ApplyArgumentError, applyPlan, parseGrant, parseRowRef } from "./apply.js";
33
34
  import { PLAN_VERSION, fileStamp, parsePlan, planRepo, readPlan, writePlan, } from "./plan.js";
35
+ import { PROVIDER_CHOICES } from "./providers/index.js";
34
36
  import { RECEIPT_VERSION } from "./receipt.js";
35
- import { proposedCommands, renderCountsJson, renderCountsText, renderJson, renderReceipt, renderSummary, renderText, rowsActedOn, summarizeReceipt, } from "./render.js";
37
+ import { PLAIN, proposedCommands, renderCountsJson, renderCountsText, renderJson, renderPreview, renderPreviewDrops, renderReceipt, renderStepFailure, renderSummary, renderText, rowsActedOn, summarizeReceipt, TERMINAL_STYLE, } from "./render.js";
36
38
  import { NO_STATUS_LINE, describeStep, describeSweep, statusLine, } from "./status-line.js";
39
+ import { stepBoard } from "./step-board.js";
37
40
  import { sweepRepos } from "./sweep.js";
38
41
  const EXIT_NOTHING_TO_SWEEP = 10;
39
42
  const EXIT_STEP_FAILED = 11;
@@ -49,12 +52,16 @@ const SWEEP_FLAGS = {
49
52
  only: {
50
53
  kind: 'list',
51
54
  valueName: 'list',
52
- help: `comma-separated: ${Object.keys(ONLY).join(', ')} (default: all)`,
55
+ help: `comma-separated: ${Object.keys(ONLY).join(', ')} (default: worktrees, branches; remotes with --remotes)`,
56
+ },
57
+ remotes: {
58
+ kind: 'boolean',
59
+ help: 'also sweep remote branches, which is slower: each one is compared with the default branch',
53
60
  },
54
61
  branch: {
55
62
  kind: 'string',
56
63
  valueName: 'name|HEAD',
57
- help: 'only this branch of this repository: its worktree, local and remote branch',
64
+ help: 'only this branch of this repository: its worktree and local branch, and its remote branch with --remotes',
58
65
  },
59
66
  remote: {
60
67
  kind: 'string',
@@ -68,11 +75,21 @@ const SWEEP_FLAGS = {
68
75
  commaSeparated: false,
69
76
  help: 'sweep the branches of this remote with no checkout of your own, through a clone kept in the cache directory; repeatable',
70
77
  },
71
- gh: { kind: 'boolean', negatable: true, help: 'classify on git evidence alone' },
78
+ gh: {
79
+ kind: 'boolean',
80
+ negatable: true,
81
+ help: 'with --remotes: classify on git evidence alone, the same as --provider none (without --remotes no provider is asked)',
82
+ },
83
+ provider: {
84
+ kind: 'enum',
85
+ choices: PROVIDER_CHOICES,
86
+ valueName: 'name',
87
+ help: 'with --remotes: where pull requests come from: auto (by remote host), github (gh), gitlab (glab), none',
88
+ },
72
89
  fetch: {
73
90
  kind: 'boolean',
74
91
  negatable: true,
75
- help: 'classify on the remote-tracking refs already on disk',
92
+ help: 'with --remotes: classify on the remote-tracking refs already on disk (without --remotes nothing is fetched)',
76
93
  },
77
94
  progress: {
78
95
  kind: 'boolean',
@@ -96,8 +113,9 @@ const OUTPUT = {
96
113
  };
97
114
  const statusCli = () => defineCli({
98
115
  name: NAME,
99
- summary: 'Inventory branches, worktrees and remote branches, and say which have been merged into ' +
100
- 'the default branch. Fetches each remote first; deletes nothing.',
116
+ summary: 'Inventory local branches and worktrees (remote branches too with --remotes), and say ' +
117
+ 'which have been merged into the default branch. Offline unless --remotes, which fetches ' +
118
+ 'each remote first and asks gh about pull requests; deletes nothing.',
101
119
  positionals: PATHS,
102
120
  flags: {
103
121
  ...SWEEP_FLAGS,
@@ -117,7 +135,7 @@ swept as ./<name>.`,
117
135
  });
118
136
  const summaryCli = () => defineCli({
119
137
  name: `${NAME} summary`,
120
- summary: 'Count local branches, worktrees and remote branches per repository: how many there are, ' +
138
+ summary: 'Count local branches and worktrees (and remote branches with --remotes) per repository: how many there are, ' +
121
139
  'how many are merged into the default branch, and how many would be deleted, pruned, reviewed or kept.',
122
140
  positionals: PATHS,
123
141
  flags: { ...SWEEP_FLAGS, output: OUTPUT },
@@ -160,6 +178,13 @@ const applyCli = () => defineCli({
160
178
  commaSeparated: false,
161
179
  help: 'apply only this row, e.g. local_branch:feat/x; repeatable (default: every row)',
162
180
  },
181
+ keep: {
182
+ kind: 'list',
183
+ valueName: 'remote_branch:id',
184
+ default: [],
185
+ commaSeparated: false,
186
+ help: 'keep this remote branch, but refuse the run if its tip moved since the plan, e.g. remote_branch:origin/feat/x; repeatable',
187
+ },
163
188
  allow: {
164
189
  kind: 'list',
165
190
  valueName: 'risk[=category:id]',
@@ -218,10 +243,31 @@ function failure(ctx, err) {
218
243
  ctx.io.stderr(`${NAME}: ${err instanceof Error ? err.message : String(err)}\n`);
219
244
  return EXIT.error;
220
245
  }
246
+ /** Color for a report on `stream`, when it is a terminal that takes color. */
247
+ function styleFor(ctx, stream) {
248
+ const level = detectColorLevel({ env: ctx.env, isTTY: ctx.terminal?.[stream] === true });
249
+ return level === 'none' ? PLAIN : TERMINAL_STYLE;
250
+ }
251
+ /** The note under a text report that left remote branches out, or '' when it did not. */
252
+ function remotesNote(swept, style) {
253
+ if (swept.kind !== 'ok' || swept.scope.includes('remote-branches'))
254
+ return '';
255
+ if (!swept.result.repos.some((r) => r.repo?.remote != null))
256
+ return '';
257
+ return (style.muted('Local only: nothing fetched, no gh, remote branches not checked; add --remotes for all three.') + '\n');
258
+ }
221
259
  /** Where `--remote-url` keeps its clones. */
222
260
  function mirrorCache(ctx) {
223
261
  return systemDirs(VENDOR_DIRS, { env: ctx.env }).standalone(NAME).cache().app('remotes').path;
224
262
  }
263
+ /** Local work by default: remote branches only when asked for, with `--remotes` or `--only remotes`. */
264
+ const LOCAL_SCOPE = ['worktrees', 'local-branches'];
265
+ function sweepScope(only, remotes) {
266
+ const scope = only === undefined ? [...LOCAL_SCOPE] : only.map((s) => ONLY[s]);
267
+ if (remotes && !scope.includes('remote-branches'))
268
+ scope.push('remote-branches');
269
+ return scope;
270
+ }
225
271
  /** Validate the sweep flags and run the sweep, or answer with the exit code. */
226
272
  function sweep(name, v, paths, ctx, status) {
227
273
  const only = v.only?.filter((s) => s !== '');
@@ -254,6 +300,10 @@ function sweep(name, v, paths, ctx, status) {
254
300
  if (conflict !== null)
255
301
  return { kind: 'exit', code: usageError(ctx, name, conflict) };
256
302
  }
303
+ if (!v.gh && v.provider !== undefined && v.provider !== 'none') {
304
+ const message = `--no-gh asks no provider; drop it or --provider ${v.provider}`;
305
+ return { kind: 'exit', code: usageError(ctx, name, message) };
306
+ }
257
307
  let branch;
258
308
  try {
259
309
  branch = v.branch === undefined ? undefined : resolveBranch(v.branch, new Git(ctx.cwd));
@@ -261,17 +311,21 @@ function sweep(name, v, paths, ctx, status) {
261
311
  catch (err) {
262
312
  return { kind: 'exit', code: failure(ctx, err) };
263
313
  }
314
+ const scope = urls.length > 0 ? ['remote-branches'] : sweepScope(only, v.remotes);
315
+ const online = scope.includes('remote-branches');
264
316
  const options = {
265
317
  baseDir: ctx.cwd,
266
318
  cwd: ctx.cwd,
267
- noGh: !v.gh,
268
- fetch: v.fetch,
319
+ // Local by default means offline: the fetch and gh go with remote branches.
320
+ noProvider: !(online && v.gh) || v.provider === 'none',
321
+ fetch: online && v.fetch,
322
+ provider: v.provider,
269
323
  branch,
270
324
  // Mid-walk, while the wait is happening — the summary comes too late to
271
325
  // explain why the tool appeared to hang.
272
326
  onWarn: (message) => ctx.io.stderr(`${NAME}: ${message}\n`),
273
327
  ...(v.progress ? { onProgress: (p) => status.update(describeSweep(p)) } : {}),
274
- ...(only === undefined ? {} : { scope: only.map((s) => ONLY[s]) }),
328
+ scope,
275
329
  ...(v.remote === undefined ? {} : { remote: v.remote }),
276
330
  };
277
331
  let entries = paths.length > 0 ? [...paths] : [ctx.cwd];
@@ -288,7 +342,6 @@ function sweep(name, v, paths, ctx, status) {
288
342
  status.clear();
289
343
  return { kind: 'exit', code: failure(ctx, err) };
290
344
  }
291
- options.scope = ['remote-branches'];
292
345
  }
293
346
  if (v.progress)
294
347
  status.update(`${NAME}: finding repositories`);
@@ -303,7 +356,7 @@ function sweep(name, v, paths, ctx, status) {
303
356
  return { kind: 'exit', code: failure(ctx, new Error(refused)) };
304
357
  if (result.repos.length === 0)
305
358
  return { kind: 'nothing', result };
306
- return { kind: 'ok', result, branch, values: v };
359
+ return { kind: 'ok', result, branch, values: v, scope, noProvider: options.noProvider === true };
307
360
  }
308
361
  /**
309
362
  * Name each repository the sweep could not read, on stderr. True when there
@@ -319,13 +372,14 @@ function reportUnreadable(ctx, name, result) {
319
372
  function planOf(swept, now) {
320
373
  const opts = {
321
374
  remote: swept.values.remote ?? null,
322
- noGh: !swept.values.gh,
375
+ noProvider: swept.noProvider,
376
+ provider: swept.values.provider,
323
377
  branch: swept.branch ?? null,
324
378
  };
325
379
  return {
326
380
  version: PLAN_VERSION,
327
381
  created_at: now.toISOString(),
328
- repos: swept.result.repos.flatMap((s) => s.repo === null ? [] : [planRepo(s.repo.root, opts, s.items)]),
382
+ repos: swept.result.repos.flatMap((s) => s.repo === null ? [] : [planRepo(s.repo.root, { ...opts, fetch: s.repo.fetched }, s.items)]),
329
383
  };
330
384
  }
331
385
  function runStatus(argv, ctx, status) {
@@ -335,7 +389,8 @@ function runStatus(argv, ctx, status) {
335
389
  if (parsed.status === 'error')
336
390
  return usageError(ctx, NAME, parsed.message);
337
391
  const v = parsed.values;
338
- const render = (r) => v.output === 'json' ? renderJson(r) : renderText(r, { keep: v.keep });
392
+ const style = styleFor(ctx, 'stdout');
393
+ const render = (r) => v.output === 'json' ? renderJson(r) : renderText(r, { keep: v.keep, style });
339
394
  const swept = sweep(NAME, v, parsed.positionals, ctx, status);
340
395
  if (swept.kind === 'exit')
341
396
  return swept.code;
@@ -350,6 +405,8 @@ function runStatus(argv, ctx, status) {
350
405
  }
351
406
  else {
352
407
  ctx.io.stdout(render(swept.result));
408
+ if (v.output === 'text')
409
+ ctx.io.stdout(remotesNote(swept, style));
353
410
  }
354
411
  return EXIT.ok;
355
412
  }
@@ -361,11 +418,14 @@ function runSummary(argv, ctx, status) {
361
418
  if (parsed.status === 'error')
362
419
  return usageError(ctx, name, parsed.message);
363
420
  const v = parsed.values;
364
- const render = (r) => v.output === 'json' ? renderCountsJson(r) : renderCountsText(r);
421
+ const style = styleFor(ctx, 'stdout');
422
+ const render = (r) => v.output === 'json' ? renderCountsJson(r) : renderCountsText(r, style);
365
423
  const swept = sweep(name, v, parsed.positionals, ctx, status);
366
424
  if (swept.kind === 'exit')
367
425
  return swept.code;
368
426
  ctx.io.stdout(render(swept.result));
427
+ if (v.output === 'text')
428
+ ctx.io.stdout(remotesNote(swept, style));
369
429
  return swept.kind === 'nothing' ? EXIT_NOTHING_TO_SWEEP : EXIT.ok;
370
430
  }
371
431
  function runPlan(argv, ctx, status) {
@@ -386,7 +446,8 @@ function runPlan(argv, ctx, status) {
386
446
  }
387
447
  const plan = planOf(swept, new Date());
388
448
  const unreadable = reportUnreadable(ctx, name, swept.result);
389
- ctx.io.stderr(renderSummary(swept.result) + '\n');
449
+ const style = styleFor(ctx, 'stderr');
450
+ ctx.io.stderr(renderSummary(swept.result, style) + '\n' + remotesNote(swept, style));
390
451
  if (v.out === '-') {
391
452
  ctx.io.stdout(`${JSON.stringify(plan, null, 2)}\n`);
392
453
  }
@@ -402,6 +463,7 @@ function receiptBeside(planPath, now) {
402
463
  const stem = basename(planPath).replace(/\.json$/i, '');
403
464
  return join(dirname(planPath), `${stem}.receipt-${fileStamp(now)}.json`);
404
465
  }
466
+ const dropKey = (d) => JSON.stringify([d.repo, d.category, d.id]);
405
467
  /** Rows the run would delete or prune: one each, however many steps (an unlock, a remove) it takes. */
406
468
  function wouldRun(receipt) {
407
469
  return rowsActedOn(summarizeReceipt(receipt));
@@ -476,9 +538,11 @@ function runApply(argv, ctx, deps, status) {
476
538
  const v = parsed.values;
477
539
  const paths = parsed.positionals;
478
540
  let rows;
541
+ let keep;
479
542
  let grants;
480
543
  try {
481
544
  rows = v.row.map(parseRowRef);
545
+ keep = v.keep.map(parseRowRef);
482
546
  grants = v.allow.map(parseGrant);
483
547
  }
484
548
  catch (err) {
@@ -507,10 +571,14 @@ function runApply(argv, ctx, deps, status) {
507
571
  }
508
572
  const ok = () => (loaded.unreadable ? EXIT.error : EXIT.ok);
509
573
  const repoRoot = v.repoRoot === undefined ? undefined : resolve(ctx.cwd, v.repoRoot);
510
- const print = (receipt, shownPath) => {
574
+ // On a terminal the run is shown live: every step first, then each one
575
+ // checked off as it finishes, with a bar on the status line.
576
+ const live = v.output === 'text' && ctx.terminal?.stdout === true;
577
+ const style = styleFor(ctx, 'stdout');
578
+ const print = (receipt, shownPath, steps = true) => {
511
579
  ctx.io.stdout(v.output === 'json'
512
580
  ? `${JSON.stringify({ receiptPath: shownPath, summary: summarizeReceipt(receipt), receipt }, null, 2)}\n`
513
- : renderReceipt(receipt, shownPath));
581
+ : renderReceipt(receipt, shownPath, style, { steps }));
514
582
  };
515
583
  const planned = plan.repos.filter((r) => repoRoot === undefined || resolve(r.root) === resolve(repoRoot));
516
584
  if (repoRoot !== undefined && planned.length === 0) {
@@ -527,6 +595,7 @@ function runApply(argv, ctx, deps, status) {
527
595
  callerCwd: ctx.cwd,
528
596
  planPath: source,
529
597
  repoRoot,
598
+ keep,
530
599
  // An implicit selection runs only safe-to-delete rows, in the preview and
531
600
  // in the run it confirms, whose rows are passed explicitly.
532
601
  safeOnly: rows.length === 0,
@@ -548,11 +617,16 @@ function runApply(argv, ctx, deps, status) {
548
617
  });
549
618
  }
550
619
  catch (err) {
620
+ if (err instanceof ApplyArgumentError)
621
+ return usageError(ctx, name, err.message);
551
622
  return failure(ctx, err);
552
623
  }
553
624
  const count = wouldRun(preview);
554
625
  if (v.dryRun || count === 0) {
555
- print(preview, null);
626
+ if (live)
627
+ ctx.io.stdout(renderPreview(preview, style));
628
+ else
629
+ print(preview, null);
556
630
  return ok();
557
631
  }
558
632
  if (!v.yes) {
@@ -560,10 +634,22 @@ function runApply(argv, ctx, deps, status) {
560
634
  if (declined !== null)
561
635
  return declined;
562
636
  }
637
+ else if (live) {
638
+ ctx.io.stdout(renderPreview(preview, style));
639
+ }
640
+ if (live)
641
+ ctx.io.stdout('\n');
563
642
  // Only what the preview showed: rows it dropped stay dropped.
564
643
  // Pinned to their repositories: a branch name two repositories share stays two rows.
565
- const approved = preview.steps.flatMap((s) => s.row === null ? [] : [{ ...s.row, repo: s.repo }]);
644
+ // A kept row's tip check is not a selection; `keep` carries it again.
645
+ const approved = preview.steps.flatMap((s) => s.row === null || s.action === 'verify-remote' ? [] : [{ ...s.row, repo: s.repo }]);
566
646
  const approvedGrants = grants.filter((g) => g.row === null || approved.some((r) => r.category === g.row.category && r.id === g.row.id));
647
+ const size = deps.terminalSize ??
648
+ (() => ({
649
+ width: ctx.terminal?.columns || process.stdout.columns || 80,
650
+ height: process.stdout.rows || 24,
651
+ }));
652
+ let board;
567
653
  let receipt;
568
654
  try {
569
655
  receipt = applyPlan(plan, approved, approvedGrants, {
@@ -571,12 +657,40 @@ function runApply(argv, ctx, deps, status) {
571
657
  dryRun: false,
572
658
  receiptPath,
573
659
  dropped: preview.dropped,
660
+ ...(live
661
+ ? {
662
+ // The board carries the run's progress; the status line stays clear.
663
+ onStart: (steps) => {
664
+ board = stepBoard(ctx.io.stdout, steps, size, style);
665
+ board.draw(null);
666
+ },
667
+ onStep: (i) => board?.draw(i),
668
+ onStepDone: (_i, _total, step) => {
669
+ board?.draw(null);
670
+ // Here rather than after the run: a receipt that then cannot be
671
+ // saved must not take the step's own error with it.
672
+ if (step.result === 'failed') {
673
+ ctx.io.stdout(`\n${renderStepFailure(step, style)}\n`);
674
+ }
675
+ },
676
+ }
677
+ : {}),
574
678
  });
575
679
  }
576
680
  catch (err) {
577
681
  return failure(ctx, err);
578
682
  }
579
- print(receipt, receiptPath);
683
+ status.clear();
684
+ if (live) {
685
+ // The run's own re-check can drop a row the preview showed, if it moved
686
+ // while the question was open: say which, since it gets no tick.
687
+ const seen = new Set(preview.dropped.map(dropKey));
688
+ const late = receipt.dropped.filter((d) => !seen.has(dropKey(d)));
689
+ if (late.length > 0)
690
+ ctx.io.stdout(`\n${renderPreviewDrops(late, style)}`);
691
+ ctx.io.stdout('\n');
692
+ }
693
+ print(receipt, receiptPath, !live);
580
694
  if (receiptPath === null) {
581
695
  ctx.io.stderr(`${name}: no receipt written; pass --receipt <file> to keep one\n`);
582
696
  }
@@ -589,7 +703,7 @@ function runApply(argv, ctx, deps, status) {
589
703
  function confirm(ctx, name, preview, count, json, deps) {
590
704
  // The preview is for the person answering, so it goes where the question does.
591
705
  const show = json ? ctx.io.stderr : ctx.io.stdout;
592
- show(renderReceipt(preview, null));
706
+ show(renderPreview(preview, styleFor(ctx, json ? 'stderr' : 'stdout')));
593
707
  const answer = (deps.confirm ?? terminalConfirm)(`Delete ${count} item${count === 1 ? '' : 's'}?`);
594
708
  if (answer === null) {
595
709
  return usageError(ctx, name, 'refusing to delete without a terminal to ask; pass --yes');
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * What the sweep must know about a repository before it can classify anything:
3
3
  * where its main checkout is, what its default branch is called, which remote
4
- * to measure against, and whether `gh` can be asked about pull requests.
4
+ * to measure against, and which provider can be asked about pull requests.
5
5
  */
6
6
  import { type Git } from '@osdlc/typed-git';
7
+ import type { CommandRunner } from '@osdlc/command-seam';
7
8
  import type { Gh } from '@osdlc/typed-gh';
9
+ import { type PrProvider, type ProviderChoice, type ProviderName } from './providers/index.ts';
8
10
  import type { RepoInfo } from './types.ts';
9
11
  /**
10
12
  * Thrown when a path names no repository, or names one whose default branch
@@ -26,7 +28,14 @@ export declare class DiscoveryFailed extends Error {
26
28
  */
27
29
  export declare function discoverRepo(hint: string, opts: {
28
30
  remote?: string | null;
29
- allowGh?: boolean;
31
+ /** False is `provider: 'none'`, whatever `provider` says. */
32
+ allowProvider?: boolean;
33
+ /** Which provider to ask for pull requests. Defaults to `auto`, by the remote's host. */
34
+ provider?: ProviderChoice | undefined;
30
35
  git: Git;
31
36
  gh: Gh;
37
+ /** The `glab` runner for the GitLab provider. Defaults to the real binary. */
38
+ glab?: CommandRunner | undefined;
39
+ /** The providers to choose from. Defaults to {@link providers} over `gh` and `glab`. */
40
+ providers?: Record<ProviderName, PrProvider> | undefined;
32
41
  }): RepoInfo;
package/dist/discover.js CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * What the sweep must know about a repository before it can classify anything:
3
3
  * where its main checkout is, what its default branch is called, which remote
4
- * to measure against, and whether `gh` can be asked about pull requests.
4
+ * to measure against, and which provider can be asked about pull requests.
5
5
  */
6
6
  import { parseRemoteRepo } from '@osdlc/typed-git';
7
+ import { forgeTarget, probeProvider, providers, } from "./providers/index.js";
7
8
  /**
8
9
  * Thrown when a path names no repository, or names one whose default branch
9
10
  * cannot be determined. A single-repository caller lets it propagate; a
@@ -28,7 +29,7 @@ export class DiscoveryFailed extends Error {
28
29
  export function discoverRepo(hint, opts) {
29
30
  const { git, gh } = opts;
30
31
  const remoteHint = opts.remote ?? null;
31
- const allowGh = opts.allowGh ?? true;
32
+ const allowProvider = opts.allowProvider ?? true;
32
33
  // `topLevel()` answers null for exit 128 only — "this is not a repository",
33
34
  // which is the answer to the question. Any other git failure throws.
34
35
  const top = git.at(hint).topLevel();
@@ -70,14 +71,24 @@ export function discoverRepo(hint, opts) {
70
71
  throw new DiscoveryFailed(`could not determine the default branch of ${root}`);
71
72
  // A short name for the report's header, so a fan-out over long paths stays
72
73
  // readable. It also keys duplicate checkouts.
73
- const slug = remote === null ? null : remoteSlugOf(git.at(root).try.remoteGetUrl(remote));
74
+ const url = remote === null ? null : git.at(root).try.remoteGetUrl(remote);
75
+ const slug = url === null ? null : remoteSlugOf(url);
76
+ const remoteUrl = url?.ok === true && typeof url.value === 'string' ? url.value : null;
74
77
  const currentBranch = git.at(root).headRef() ?? '';
75
- // The two halves separately, not `ready()`: the report says "gh not
76
- // available" and "gh not authenticated" differently, because they send a
77
- // person somewhere different.
78
- const hasGh = allowGh && gh.at(root).installed();
79
- const ghAuthenticated = hasGh && gh.at(root).authenticated();
80
- return { root, slug, mainBranch, remote, currentBranch, hasGh, ghAuthenticated, fetched: false };
78
+ // Installed and signed in are probed separately: "not installed" and "not
79
+ // authenticated" send a person somewhere different.
80
+ const choice = allowProvider ? (opts.provider ?? 'auto') : 'none';
81
+ const target = forgeTarget(remoteUrl);
82
+ const provider = probeProvider(choice, root, target, opts.providers ?? providers({ gh, glab: opts.glab }));
83
+ return {
84
+ root,
85
+ slug,
86
+ mainBranch,
87
+ remote,
88
+ currentBranch,
89
+ provider,
90
+ fetched: false,
91
+ };
81
92
  }
82
93
  /**
83
94
  * `owner/name` for a github.com remote, `host/owner/name` for any other, or
package/dist/index.d.ts CHANGED
@@ -18,12 +18,13 @@ export { classifyLocalBranch, classifyRemoteBranch, gatherLocalBranches, gatherR
18
18
  export { classifyWorktree, gatherWorktrees, hasBlockers, worktreePruneCommand, worktreeRemoveCommand, worktreeUnlockCommand, type WorktreeBlocker, } from './worktrees.ts';
19
19
  export { DiscoveryFailed, discoverRepo } from './discover.ts';
20
20
  export { assessLanding, patchEquivalent, type Landing, type LandingCheck } from './landing.ts';
21
- export { ApplyArgumentError, applyPlan, type RowSelection, parseGrant, parseRowRef, type ApplyOptions, type Grant, } from './apply.ts';
21
+ export { ApplyArgumentError, ApplyRefused, applyPlan, type RowSelection, parseGrant, parseRowRef, type ApplyOptions, type Grant, } from './apply.ts';
22
22
  export type { Annotate, HostHooks } from './hooks.ts';
23
23
  export { PLAN_VERSION, PlanReadError, planRepo, parsePlan, readPlan, writePlan, type Plan, type PlanRepo, type PlannableRow, } from './plan.ts';
24
24
  export { RECEIPT_VERSION, ReceiptSchema, type Dropped, type Receipt, type RowRef, type Step, } from './receipt.ts';
25
25
  export { defaultProcessProbe, isIdleShell, parseLockOwner, parseLsofCwd, type DefaultProbeOptions, type LockOwner, type ProcessHolder, type ProcessProbe, } from './processes.ts';
26
26
  export { fetchPrs } from './prs.ts';
27
+ export { PROVIDER_CHOICES, PROVIDER_NAMES, chooseProvider, forgeTarget, githubProvider, gitlabProvider, probeProvider, providers, type ForgeTarget, type PrProvider, type ProviderChoice, type ProviderName, type ProviderStatus, } from './providers/index.ts';
27
28
  export { expandTilde, isCheckoutRoot, resolveEntries, type Resolution, type ResolveOptions, } from './resolve.ts';
28
29
  export { sweepRepo, sweepRepos, type SweepOptions, type SweepReposOptions } from './sweep.ts';
29
30
  export { proposedCommands, renderJson, renderApplySummary, renderReceipt, summarizeReceipt, type ApplySummary, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from './render.ts';
package/dist/index.js CHANGED
@@ -18,11 +18,12 @@ export { classifyLocalBranch, classifyRemoteBranch, gatherLocalBranches, gatherR
18
18
  export { classifyWorktree, gatherWorktrees, hasBlockers, worktreePruneCommand, worktreeRemoveCommand, worktreeUnlockCommand, } from "./worktrees.js";
19
19
  export { DiscoveryFailed, discoverRepo } from "./discover.js";
20
20
  export { assessLanding, patchEquivalent } from "./landing.js";
21
- export { ApplyArgumentError, applyPlan, parseGrant, parseRowRef, } from "./apply.js";
21
+ export { ApplyArgumentError, ApplyRefused, applyPlan, parseGrant, parseRowRef, } from "./apply.js";
22
22
  export { PLAN_VERSION, PlanReadError, planRepo, parsePlan, readPlan, writePlan, } from "./plan.js";
23
23
  export { RECEIPT_VERSION, ReceiptSchema, } from "./receipt.js";
24
24
  export { defaultProcessProbe, isIdleShell, parseLockOwner, parseLsofCwd, } from "./processes.js";
25
25
  export { fetchPrs } from "./prs.js";
26
+ export { PROVIDER_CHOICES, PROVIDER_NAMES, chooseProvider, forgeTarget, githubProvider, gitlabProvider, probeProvider, providers, } from "./providers/index.js";
26
27
  export { expandTilde, isCheckoutRoot, resolveEntries, } from "./resolve.js";
27
28
  export { sweepRepo, sweepRepos } from "./sweep.js";
28
29
  export { proposedCommands, renderJson, renderApplySummary, renderReceipt, summarizeReceipt, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from "./render.js";
package/dist/plan.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * whose fingerprint no longer matches.
5
5
  */
6
6
  import { z } from 'zod';
7
+ import { type ProviderChoice } from './providers/types.ts';
7
8
  import { type SweepItem } from './types.ts';
8
9
  export declare const PLAN_VERSION = 1;
9
10
  /** The categories a plan can hold, in the order apply runs them. */
@@ -28,7 +29,14 @@ export declare const PlanRepoSchema: z.ZodObject<{
28
29
  root: z.ZodString;
29
30
  remote: z.ZodNullable<z.ZodString>;
30
31
  no_gh: z.ZodBoolean;
32
+ provider: z.ZodOptional<z.ZodEnum<{
33
+ github: "github";
34
+ gitlab: "gitlab";
35
+ auto: "auto";
36
+ none: "none";
37
+ }>>;
31
38
  branch: z.ZodDefault<z.ZodNullable<z.ZodString>>;
39
+ fetch: z.ZodDefault<z.ZodBoolean>;
32
40
  rows: z.ZodArray<z.ZodObject<{
33
41
  category: z.ZodEnum<{
34
42
  local_branch: "local_branch";
@@ -52,7 +60,14 @@ export declare const PlanSchema: z.ZodObject<{
52
60
  root: z.ZodString;
53
61
  remote: z.ZodNullable<z.ZodString>;
54
62
  no_gh: z.ZodBoolean;
63
+ provider: z.ZodOptional<z.ZodEnum<{
64
+ github: "github";
65
+ gitlab: "gitlab";
66
+ auto: "auto";
67
+ none: "none";
68
+ }>>;
55
69
  branch: z.ZodDefault<z.ZodNullable<z.ZodString>>;
70
+ fetch: z.ZodDefault<z.ZodBoolean>;
56
71
  rows: z.ZodArray<z.ZodObject<{
57
72
  category: z.ZodEnum<{
58
73
  local_branch: "local_branch";
@@ -87,8 +102,10 @@ export declare function sameFingerprint(a: Record<string, unknown>, b: Record<st
87
102
  /** One repository's plan entry: its deletable rows. */
88
103
  export declare function planRepo(root: string, opts: {
89
104
  remote: string | null;
90
- noGh: boolean;
105
+ noProvider: boolean;
91
106
  branch?: string | null;
107
+ fetch?: boolean;
108
+ provider?: ProviderChoice | undefined;
92
109
  }, items: readonly PlannableRow[]): PlanRepo;
93
110
  /** A UTC timestamp safe in a file name, e.g. `2026-10-04T12-30-05-123Z`. */
94
111
  export declare function fileStamp(d: Date): string;
package/dist/plan.js CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { readFileSync } from 'node:fs';
7
7
  import { z } from 'zod';
8
+ import { PROVIDER_CHOICES } from "./providers/types.js";
8
9
  import { AUTHORIZATIONS } from "./types.js";
9
10
  import { writeJsonAtomic } from "./write-json.js";
10
11
  export const PLAN_VERSION = 1;
@@ -20,9 +21,22 @@ export const PlanRepoSchema = z.object({
20
21
  root: z.string(),
21
22
  /** The `remote` the sweep ran with; the re-sweep uses the same. */
22
23
  remote: z.string().nullable(),
24
+ /**
25
+ * True when the sweep asked no provider for pull requests. The name is the
26
+ * plan format's, from when GitHub was the only provider; renaming it would
27
+ * break every plan already written.
28
+ */
23
29
  no_gh: z.boolean(),
30
+ /** The provider choice the sweep ran with; absent is `auto`. `no_gh` wins over it. */
31
+ provider: z.enum(PROVIDER_CHOICES).optional(),
24
32
  /** The `branch` the sweep was narrowed to; the re-sweep uses the same. */
25
33
  branch: z.string().nullable().default(null),
34
+ /**
35
+ * Whether the sweep fetched before classifying. The re-sweep fetches too, so
36
+ * it judges against the remote's current default branch, as the sweep did.
37
+ * A plan written before this field reads as not fetched.
38
+ */
39
+ fetch: z.boolean().default(false),
26
40
  rows: z.array(PlanRowSchema),
27
41
  });
28
42
  export const PlanSchema = z.object({
@@ -78,7 +92,15 @@ export function planRepo(root, opts, items) {
78
92
  fingerprint: fingerprint(item),
79
93
  });
80
94
  }
81
- return { root, remote: opts.remote, no_gh: opts.noGh, branch: opts.branch ?? null, rows };
95
+ return {
96
+ root,
97
+ remote: opts.remote,
98
+ no_gh: opts.noProvider,
99
+ ...(opts.provider === undefined || opts.provider === 'auto' ? {} : { provider: opts.provider }),
100
+ branch: opts.branch ?? null,
101
+ fetch: opts.fetch ?? false,
102
+ rows,
103
+ };
82
104
  }
83
105
  /** A UTC timestamp safe in a file name, e.g. `2026-10-04T12-30-05-123Z`. */
84
106
  export function fileStamp(d) {
@@ -0,0 +1,10 @@
1
+ /** Pull requests from GitHub, through `gh`. */
2
+ import type { Gh } from '@osdlc/typed-gh';
3
+ import type { PrProvider } from './types.ts';
4
+ /** The most PRs one listing reads. */
5
+ export declare const PR_LIMIT = 300;
6
+ /**
7
+ * A provider over `gh pr list`. `gh` picks the repository from the checkout's
8
+ * remotes, so `target` is not consulted.
9
+ */
10
+ export declare function githubProvider(gh: Gh): PrProvider;
@@ -0,0 +1,56 @@
1
+ /** Pull requests from GitHub, through `gh`. */
2
+ /** The fields classification consults. Asking for more costs latency. */
3
+ const FIELDS = [
4
+ 'number',
5
+ 'state',
6
+ 'mergedAt',
7
+ 'headRefName',
8
+ 'headRefOid',
9
+ 'baseRefName',
10
+ 'isCrossRepository',
11
+ 'title',
12
+ 'url',
13
+ ];
14
+ /** The most PRs one listing reads. */
15
+ export const PR_LIMIT = 300;
16
+ /**
17
+ * A provider over `gh pr list`. `gh` picks the repository from the checkout's
18
+ * remotes, so `target` is not consulted.
19
+ */
20
+ export function githubProvider(gh) {
21
+ return {
22
+ name: 'github',
23
+ words: { tool: 'gh', noun: 'PR', listing: 'gh pr list' },
24
+ probe(root) {
25
+ const available = gh.at(root).installed();
26
+ return { available, authenticated: available && gh.at(root).authenticated() };
27
+ },
28
+ listPrs(root, _target, branch) {
29
+ const rows = gh.at(root).prList({
30
+ state: 'all',
31
+ limit: PR_LIMIT,
32
+ fields: FIELDS,
33
+ head: branch,
34
+ });
35
+ if (rows === null)
36
+ return null;
37
+ const prs = [];
38
+ for (const row of rows.rows) {
39
+ // A fork's head branch is not this repository's branch, whatever its name.
40
+ if (!row.headRefName || row.isCrossRepository)
41
+ continue;
42
+ prs.push({
43
+ number: row.number,
44
+ state: row.state,
45
+ mergedAt: row.mergedAt,
46
+ headRef: row.headRefName,
47
+ headOid: row.headRefOid,
48
+ baseRef: row.baseRefName,
49
+ title: row.title,
50
+ url: row.url,
51
+ });
52
+ }
53
+ return prs;
54
+ },
55
+ };
56
+ }