@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/CHANGELOG.md +35 -0
- package/README.md +140 -19
- package/dist/apply.d.ts +32 -2
- package/dist/apply.js +121 -18
- package/dist/branches.d.ts +2 -0
- package/dist/branches.js +8 -3
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +138 -24
- package/dist/discover.d.ts +11 -2
- package/dist/discover.js +20 -9
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/plan.d.ts +18 -1
- package/dist/plan.js +23 -1
- package/dist/providers/github.d.ts +10 -0
- package/dist/providers/github.js +56 -0
- package/dist/providers/gitlab.d.ts +32 -0
- package/dist/providers/gitlab.js +117 -0
- package/dist/providers/index.d.ts +30 -0
- package/dist/providers/index.js +63 -0
- package/dist/providers/types.d.ts +56 -0
- package/dist/providers/types.js +10 -0
- package/dist/prs.d.ts +14 -10
- package/dist/prs.js +20 -44
- package/dist/receipt.d.ts +4 -0
- package/dist/receipt.js +2 -0
- package/dist/render.d.ts +47 -4
- package/dist/render.js +132 -19
- package/dist/status-line.d.ts +4 -2
- package/dist/status-line.js +15 -5
- package/dist/step-board.d.ts +34 -0
- package/dist/step-board.js +101 -0
- package/dist/sweep.d.ts +10 -3
- package/dist/sweep.js +11 -5
- package/dist/types.d.ts +9 -2
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worktrees.d.ts +12 -0
- package/dist/worktrees.js +57 -0
- package/package.json +5 -5
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:
|
|
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
|
|
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: {
|
|
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
|
|
100
|
-
'the default branch.
|
|
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
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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');
|
package/dist/discover.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
return {
|
|
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
|
-
|
|
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 {
|
|
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
|
+
}
|