@osdlc/repo-cleaner 0.18.0 → 0.19.1
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 +19 -0
- package/README.md +238 -20
- package/dist/branches.d.ts +29 -1
- package/dist/branches.js +111 -28
- package/dist/clean.d.ts +22 -5
- package/dist/clean.js +88 -19
- package/dist/cli.js +32 -4
- package/dist/index.d.ts +1 -1
- package/dist/providers/github.d.ts +2 -1
- package/dist/providers/github.js +2 -1
- package/dist/providers/gitlab.d.ts +3 -2
- package/dist/providers/gitlab.js +14 -8
- package/dist/providers/index.d.ts +5 -4
- package/dist/providers/index.js +8 -5
- package/dist/providers/types.d.ts +8 -2
- package/dist/prs.d.ts +35 -7
- package/dist/prs.js +45 -12
- package/dist/receipt.d.ts +2 -0
- package/dist/receipt.js +6 -0
- package/dist/reference.js +12 -8
- package/dist/remote-mirror.d.ts +1 -1
- package/dist/remote-mirror.js +1 -1
- package/dist/render.d.ts +6 -1
- package/dist/render.js +12 -2
- package/dist/resolve.d.ts +3 -3
- package/dist/resolve.js +3 -3
- package/dist/sweep.d.ts +14 -1
- package/dist/sweep.js +67 -7
- package/dist/types.d.ts +20 -2
- package/dist/types.js +8 -2
- package/dist/util.d.ts +7 -1
- package/dist/util.js +18 -2
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +4 -4
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
192
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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 =
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
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;
|
package/dist/providers/github.js
CHANGED
|
@@ -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
|
|
29
|
-
*
|
|
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 {};
|
package/dist/providers/gitlab.js
CHANGED
|
@@ -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
|
|
63
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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. */
|
package/dist/providers/index.js
CHANGED
|
@@ -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:
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
58
|
+
export declare function lookupPrs(repo: RepoInfo, provider: PrProvider, branch?: string): PrLookup;
|