@osdlc/repo-cleaner 0.10.0 → 0.11.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 +31 -0
- package/README.md +56 -6
- package/dist/apply.d.ts +23 -2
- package/dist/apply.js +75 -2
- package/dist/branches.d.ts +10 -0
- package/dist/branches.js +15 -5
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +218 -109
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/receipt.d.ts +4 -0
- package/dist/receipt.js +11 -2
- package/dist/remote-mirror.d.ts +55 -0
- package/dist/remote-mirror.js +123 -0
- package/dist/render.d.ts +25 -2
- package/dist/render.js +92 -13
- package/dist/types.d.ts +4 -0
- package/dist/types.js +14 -1
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worktrees.js +8 -6
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Four verbs over one set of sweep filters:
|
|
5
5
|
*
|
|
6
6
|
* status (the default) inventory and classify; read-only but for the fetch
|
|
7
|
-
* summary the same sweep as counts: totals, merged, delete, review, keep
|
|
7
|
+
* summary the same sweep as counts: totals, merged, delete, prune, review, keep
|
|
8
8
|
* plan write the deletable rows as a plan, to stdout or a file
|
|
9
9
|
* apply re-check, show the steps, ask, then delete
|
|
10
10
|
*
|
|
@@ -21,15 +21,18 @@
|
|
|
21
21
|
* 10 nothing to sweep: no entry named a repository
|
|
22
22
|
* 11 apply stopped at a failed step; the receipt says which
|
|
23
23
|
*/
|
|
24
|
-
import { readSync } from 'node:fs';
|
|
25
|
-
import { basename, dirname, join, resolve } from 'node:path';
|
|
24
|
+
import { existsSync, readSync, realpathSync } from 'node:fs';
|
|
25
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
26
26
|
import { isatty } from 'node:tty';
|
|
27
|
-
import { EXIT, askYesNo, canPrompt, defineCli } from '@osdlc/cli-tool';
|
|
27
|
+
import { EXIT, askYesNo, canPrompt, defineCli, systemDirs } from '@osdlc/cli-tool';
|
|
28
28
|
import { Git } from '@osdlc/typed-git';
|
|
29
29
|
import { resolveBranch } from "./branches.js";
|
|
30
|
+
import { VENDOR_DIRS } from "./devlink.js";
|
|
31
|
+
import { ensureMirror, mirrorProblem } from "./remote-mirror.js";
|
|
30
32
|
import { ApplyArgumentError, applyPlan, parseGrant, parseRowRef } from "./apply.js";
|
|
31
33
|
import { PLAN_VERSION, fileStamp, parsePlan, planRepo, readPlan, writePlan, } from "./plan.js";
|
|
32
|
-
import {
|
|
34
|
+
import { RECEIPT_VERSION } from "./receipt.js";
|
|
35
|
+
import { proposedCommands, renderCountsJson, renderCountsText, renderJson, renderReceipt, renderSummary, renderText, rowsActedOn, summarizeReceipt, } from "./render.js";
|
|
33
36
|
import { NO_STATUS_LINE, describeStep, describeSweep, statusLine, } from "./status-line.js";
|
|
34
37
|
import { sweepRepos } from "./sweep.js";
|
|
35
38
|
const EXIT_NOTHING_TO_SWEEP = 10;
|
|
@@ -58,6 +61,13 @@ const SWEEP_FLAGS = {
|
|
|
58
61
|
valueName: 'name',
|
|
59
62
|
help: 'remote to measure against (default: origin)',
|
|
60
63
|
},
|
|
64
|
+
remoteUrl: {
|
|
65
|
+
kind: 'list',
|
|
66
|
+
valueName: 'url',
|
|
67
|
+
default: [],
|
|
68
|
+
commaSeparated: false,
|
|
69
|
+
help: 'sweep the branches of this remote with no checkout of your own, through a clone kept in the cache directory; repeatable',
|
|
70
|
+
},
|
|
61
71
|
gh: { kind: 'boolean', negatable: true, help: 'classify on git evidence alone' },
|
|
62
72
|
fetch: {
|
|
63
73
|
kind: 'boolean',
|
|
@@ -97,7 +107,7 @@ const statusCli = () => defineCli({
|
|
|
97
107
|
},
|
|
98
108
|
epilogue: `commands:
|
|
99
109
|
${NAME} [status] this report (the default)
|
|
100
|
-
${NAME} summary counts per repository: total, merged, delete, review, keep
|
|
110
|
+
${NAME} summary counts per repository: total, merged, delete, prune, review, keep
|
|
101
111
|
${NAME} plan write the deletable rows as a plan
|
|
102
112
|
${NAME} apply re-check, show the steps, ask, delete
|
|
103
113
|
${NAME} dev run ${NAME} from a development checkout
|
|
@@ -108,7 +118,7 @@ swept as ./<name>.`,
|
|
|
108
118
|
const summaryCli = () => defineCli({
|
|
109
119
|
name: `${NAME} summary`,
|
|
110
120
|
summary: 'Count local branches, worktrees and remote branches per repository: how many there are, ' +
|
|
111
|
-
'how many are merged into the default branch, and how many would be deleted, reviewed or kept.',
|
|
121
|
+
'how many are merged into the default branch, and how many would be deleted, pruned, reviewed or kept.',
|
|
112
122
|
positionals: PATHS,
|
|
113
123
|
flags: { ...SWEEP_FLAGS, output: OUTPUT },
|
|
114
124
|
epilogue: 'merged: the work is on the default branch. Rows kept for structure (the default ' +
|
|
@@ -160,7 +170,7 @@ const applyCli = () => defineCli({
|
|
|
160
170
|
repoRoot: {
|
|
161
171
|
kind: 'string',
|
|
162
172
|
valueName: 'path',
|
|
163
|
-
help: 'apply only the
|
|
173
|
+
help: 'with a plan of several repositories: apply only the one rooted here',
|
|
164
174
|
},
|
|
165
175
|
receipt: {
|
|
166
176
|
kind: 'string',
|
|
@@ -208,28 +218,48 @@ function failure(ctx, err) {
|
|
|
208
218
|
ctx.io.stderr(`${NAME}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
209
219
|
return EXIT.error;
|
|
210
220
|
}
|
|
221
|
+
/** Where `--remote-url` keeps its clones. */
|
|
222
|
+
function mirrorCache(ctx) {
|
|
223
|
+
return systemDirs(VENDOR_DIRS, { env: ctx.env }).standalone(NAME).cache().app('remotes').path;
|
|
224
|
+
}
|
|
211
225
|
/** Validate the sweep flags and run the sweep, or answer with the exit code. */
|
|
212
|
-
function sweep(name, v, paths, ctx,
|
|
226
|
+
function sweep(name, v, paths, ctx, status) {
|
|
213
227
|
const only = v.only?.filter((s) => s !== '');
|
|
214
228
|
const unknown = (only ?? []).filter((s) => !(s in ONLY));
|
|
215
229
|
if (unknown.length > 0) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
code: usageError(ctx, name, `unknown --only value(s): ${unknown.join(', ')}`),
|
|
219
|
-
};
|
|
230
|
+
const message = `unknown --only value(s): ${unknown.join(', ')}`;
|
|
231
|
+
return { kind: 'exit', code: usageError(ctx, name, message) };
|
|
220
232
|
}
|
|
221
233
|
if (v.branch !== undefined && paths.length > 0) {
|
|
234
|
+
const message = '--branch sweeps this repository only; drop the paths';
|
|
235
|
+
return { kind: 'exit', code: usageError(ctx, name, message) };
|
|
236
|
+
}
|
|
237
|
+
// An empty URL is refused, not dropped: `--remote-url "$UNSET"` must not
|
|
238
|
+
// fall back to sweeping the checkout you are standing in.
|
|
239
|
+
if (v.remoteUrl.some((u) => u.trim() === '')) {
|
|
222
240
|
return {
|
|
223
|
-
|
|
224
|
-
code: usageError(ctx, name, '--
|
|
241
|
+
kind: 'exit',
|
|
242
|
+
code: usageError(ctx, name, '--remote-url needs a URL; got an empty one'),
|
|
225
243
|
};
|
|
226
244
|
}
|
|
245
|
+
const urls = v.remoteUrl;
|
|
246
|
+
if (urls.length > 0) {
|
|
247
|
+
const conflict = paths.length > 0
|
|
248
|
+
? '--remote-url sweeps a remote, not a checkout; drop the paths'
|
|
249
|
+
: v.branch !== undefined
|
|
250
|
+
? '--branch reads the checkout you are in; it does not combine with --remote-url'
|
|
251
|
+
: (only ?? []).some((s) => s !== 'remotes')
|
|
252
|
+
? '--remote-url has remote branches only; drop --only or pass --only remotes'
|
|
253
|
+
: null;
|
|
254
|
+
if (conflict !== null)
|
|
255
|
+
return { kind: 'exit', code: usageError(ctx, name, conflict) };
|
|
256
|
+
}
|
|
227
257
|
let branch;
|
|
228
258
|
try {
|
|
229
259
|
branch = v.branch === undefined ? undefined : resolveBranch(v.branch, new Git(ctx.cwd));
|
|
230
260
|
}
|
|
231
261
|
catch (err) {
|
|
232
|
-
return {
|
|
262
|
+
return { kind: 'exit', code: failure(ctx, err) };
|
|
233
263
|
}
|
|
234
264
|
const options = {
|
|
235
265
|
baseDir: ctx.cwd,
|
|
@@ -244,22 +274,36 @@ function sweep(name, v, paths, ctx, render, status) {
|
|
|
244
274
|
...(only === undefined ? {} : { scope: only.map((s) => ONLY[s]) }),
|
|
245
275
|
...(v.remote === undefined ? {} : { remote: v.remote }),
|
|
246
276
|
};
|
|
277
|
+
let entries = paths.length > 0 ? [...paths] : [ctx.cwd];
|
|
278
|
+
if (urls.length > 0) {
|
|
279
|
+
const cache = mirrorCache(ctx);
|
|
280
|
+
try {
|
|
281
|
+
entries = urls.map((url, i) => {
|
|
282
|
+
if (v.progress)
|
|
283
|
+
status.update(`${NAME}: cloning remote ${i + 1}/${urls.length}`);
|
|
284
|
+
return ensureMirror(url, cache);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
status.clear();
|
|
289
|
+
return { kind: 'exit', code: failure(ctx, err) };
|
|
290
|
+
}
|
|
291
|
+
options.scope = ['remote-branches'];
|
|
292
|
+
}
|
|
247
293
|
if (v.progress)
|
|
248
294
|
status.update(`${NAME}: finding repositories`);
|
|
249
|
-
const result = sweepRepos(
|
|
295
|
+
const result = sweepRepos(entries, options);
|
|
250
296
|
status.clear();
|
|
251
297
|
if (branch !== undefined && result.repos.length > 1) {
|
|
252
298
|
const err = new Error(`--branch sweeps one repository; ${result.repos.length} found under ${ctx.cwd}`);
|
|
253
|
-
return {
|
|
299
|
+
return { kind: 'exit', code: failure(ctx, err) };
|
|
254
300
|
}
|
|
255
301
|
const refused = branch === undefined ? null : (result.repos[0]?.error ?? null);
|
|
256
302
|
if (refused !== null)
|
|
257
|
-
return {
|
|
258
|
-
if (result.repos.length === 0)
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
return { ok: true, result, branch, values: v };
|
|
303
|
+
return { kind: 'exit', code: failure(ctx, new Error(refused)) };
|
|
304
|
+
if (result.repos.length === 0)
|
|
305
|
+
return { kind: 'nothing', result };
|
|
306
|
+
return { kind: 'ok', result, branch, values: v };
|
|
263
307
|
}
|
|
264
308
|
/**
|
|
265
309
|
* Name each repository the sweep could not read, on stderr. True when there
|
|
@@ -292,9 +336,13 @@ function runStatus(argv, ctx, status) {
|
|
|
292
336
|
return usageError(ctx, NAME, parsed.message);
|
|
293
337
|
const v = parsed.values;
|
|
294
338
|
const render = (r) => v.output === 'json' ? renderJson(r) : renderText(r, { keep: v.keep });
|
|
295
|
-
const swept = sweep(NAME, v, parsed.positionals, ctx,
|
|
296
|
-
if (
|
|
339
|
+
const swept = sweep(NAME, v, parsed.positionals, ctx, status);
|
|
340
|
+
if (swept.kind === 'exit')
|
|
297
341
|
return swept.code;
|
|
342
|
+
if (swept.kind === 'nothing') {
|
|
343
|
+
ctx.io.stdout(render(swept.result));
|
|
344
|
+
return EXIT_NOTHING_TO_SWEEP;
|
|
345
|
+
}
|
|
298
346
|
if (v.commands) {
|
|
299
347
|
const commands = proposedCommands(swept.result);
|
|
300
348
|
if (commands.length > 0)
|
|
@@ -314,11 +362,11 @@ function runSummary(argv, ctx, status) {
|
|
|
314
362
|
return usageError(ctx, name, parsed.message);
|
|
315
363
|
const v = parsed.values;
|
|
316
364
|
const render = (r) => v.output === 'json' ? renderCountsJson(r) : renderCountsText(r);
|
|
317
|
-
const swept = sweep(name, v, parsed.positionals, ctx,
|
|
318
|
-
if (
|
|
365
|
+
const swept = sweep(name, v, parsed.positionals, ctx, status);
|
|
366
|
+
if (swept.kind === 'exit')
|
|
319
367
|
return swept.code;
|
|
320
368
|
ctx.io.stdout(render(swept.result));
|
|
321
|
-
return EXIT.ok;
|
|
369
|
+
return swept.kind === 'nothing' ? EXIT_NOTHING_TO_SWEEP : EXIT.ok;
|
|
322
370
|
}
|
|
323
371
|
function runPlan(argv, ctx, status) {
|
|
324
372
|
const name = `${NAME} plan`;
|
|
@@ -328,12 +376,14 @@ function runPlan(argv, ctx, status) {
|
|
|
328
376
|
if (parsed.status === 'error')
|
|
329
377
|
return usageError(ctx, name, parsed.message);
|
|
330
378
|
const v = parsed.values;
|
|
331
|
-
const swept = sweep(name, v, parsed.positionals, ctx,
|
|
332
|
-
|
|
333
|
-
return '';
|
|
334
|
-
}, status);
|
|
335
|
-
if (!swept.ok)
|
|
379
|
+
const swept = sweep(name, v, parsed.positionals, ctx, status);
|
|
380
|
+
if (swept.kind === 'exit')
|
|
336
381
|
return swept.code;
|
|
382
|
+
// stdout carries only the plan, so the summary goes to stderr either way.
|
|
383
|
+
if (swept.kind === 'nothing') {
|
|
384
|
+
ctx.io.stderr(renderSummary(swept.result) + '\n');
|
|
385
|
+
return EXIT_NOTHING_TO_SWEEP;
|
|
386
|
+
}
|
|
337
387
|
const plan = planOf(swept, new Date());
|
|
338
388
|
const unreadable = reportUnreadable(ctx, name, swept.result);
|
|
339
389
|
ctx.io.stderr(renderSummary(swept.result) + '\n');
|
|
@@ -352,8 +402,69 @@ function receiptBeside(planPath, now) {
|
|
|
352
402
|
const stem = basename(planPath).replace(/\.json$/i, '');
|
|
353
403
|
return join(dirname(planPath), `${stem}.receipt-${fileStamp(now)}.json`);
|
|
354
404
|
}
|
|
405
|
+
/** Rows the run would delete or prune: one each, however many steps (an unlock, a remove) it takes. */
|
|
355
406
|
function wouldRun(receipt) {
|
|
356
|
-
return
|
|
407
|
+
return rowsActedOn(summarizeReceipt(receipt));
|
|
408
|
+
}
|
|
409
|
+
/** Sweep and plan in memory, or read `--plan`; or answer with the exit code. */
|
|
410
|
+
function loadPlan(name, v, paths, ctx, deps, status, now) {
|
|
411
|
+
const receiptPath = v.receipt === undefined ? null : resolve(ctx.cwd, v.receipt);
|
|
412
|
+
if (v.plan === undefined) {
|
|
413
|
+
const swept = sweep(name, v, paths, ctx, status);
|
|
414
|
+
if (swept.kind === 'exit')
|
|
415
|
+
return swept.code;
|
|
416
|
+
if (swept.kind === 'nothing') {
|
|
417
|
+
ctx.io.stdout(v.output === 'json' ? renderJson(swept.result) : renderText(swept.result));
|
|
418
|
+
return EXIT_NOTHING_TO_SWEEP;
|
|
419
|
+
}
|
|
420
|
+
const items = new Map();
|
|
421
|
+
for (const r of swept.result.repos)
|
|
422
|
+
if (r.repo !== null)
|
|
423
|
+
items.set(r.repo.root, r.items);
|
|
424
|
+
return {
|
|
425
|
+
plan: planOf(swept, now),
|
|
426
|
+
source: '(swept)',
|
|
427
|
+
receiptPath,
|
|
428
|
+
unreadable: reportUnreadable(ctx, name, swept.result),
|
|
429
|
+
swept: items,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
if (v.plan === '-') {
|
|
434
|
+
const plan = parsePlan((deps.readStdin ?? readAllStdin)(), 'stdin');
|
|
435
|
+
return { plan, source: '(stdin)', receiptPath, unreadable: false, swept: undefined };
|
|
436
|
+
}
|
|
437
|
+
const source = resolve(ctx.cwd, v.plan);
|
|
438
|
+
const plan = readPlan(source);
|
|
439
|
+
return {
|
|
440
|
+
plan,
|
|
441
|
+
source,
|
|
442
|
+
receiptPath: receiptPath ?? receiptBeside(source, now),
|
|
443
|
+
unreadable: false,
|
|
444
|
+
swept: undefined,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
catch (err) {
|
|
448
|
+
return failure(ctx, err);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/** The run's flags that contradict each other, as a usage message, or null. */
|
|
452
|
+
function applyConflict(v, paths) {
|
|
453
|
+
if (v.plan !== undefined) {
|
|
454
|
+
const sweepFlags = [
|
|
455
|
+
paths.length > 0 ? 'paths' : null,
|
|
456
|
+
v.only !== undefined ? '--only' : null,
|
|
457
|
+
v.branch !== undefined ? '--branch' : null,
|
|
458
|
+
v.remote !== undefined ? '--remote' : null,
|
|
459
|
+
v.remoteUrl.length > 0 ? '--remote-url' : null,
|
|
460
|
+
].filter((f) => f !== null);
|
|
461
|
+
if (sweepFlags.length > 0)
|
|
462
|
+
return `--plan fixes what is swept; drop ${sweepFlags.join(', ')}`;
|
|
463
|
+
}
|
|
464
|
+
if (v.plan === '-' && !v.yes && !v.dryRun) {
|
|
465
|
+
return '--plan - reads the plan from stdin, so it cannot ask; pass --yes or --dry-run';
|
|
466
|
+
}
|
|
467
|
+
return null;
|
|
357
468
|
}
|
|
358
469
|
function runApply(argv, ctx, deps, status) {
|
|
359
470
|
const name = `${NAME} apply`;
|
|
@@ -375,57 +486,50 @@ function runApply(argv, ctx, deps, status) {
|
|
|
375
486
|
return usageError(ctx, name, err.message);
|
|
376
487
|
throw err;
|
|
377
488
|
}
|
|
378
|
-
const
|
|
379
|
-
if (
|
|
380
|
-
|
|
381
|
-
paths.length > 0 ? 'paths' : null,
|
|
382
|
-
v.only !== undefined ? '--only' : null,
|
|
383
|
-
v.branch !== undefined ? '--branch' : null,
|
|
384
|
-
v.remote !== undefined ? '--remote' : null,
|
|
385
|
-
].filter((f) => f !== null);
|
|
386
|
-
if (sweepFlags.length > 0) {
|
|
387
|
-
return usageError(ctx, name, `--plan fixes what is swept; drop ${sweepFlags.join(', ')}`);
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
if (fromStdin && !v.yes && !v.dryRun) {
|
|
391
|
-
return usageError(ctx, name, '--plan - reads the plan from stdin, so it cannot ask; pass --yes or --dry-run');
|
|
392
|
-
}
|
|
489
|
+
const conflict = applyConflict(v, paths);
|
|
490
|
+
if (conflict !== null)
|
|
491
|
+
return usageError(ctx, name, conflict);
|
|
393
492
|
const now = new Date();
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
else {
|
|
409
|
-
try {
|
|
410
|
-
if (fromStdin) {
|
|
411
|
-
plan = parsePlan((deps.readStdin ?? readAllStdin)(), 'stdin');
|
|
412
|
-
planSource = '(stdin)';
|
|
413
|
-
}
|
|
414
|
-
else {
|
|
415
|
-
planSource = resolve(ctx.cwd, v.plan);
|
|
416
|
-
plan = readPlan(planSource);
|
|
417
|
-
receiptPath ??= receiptBeside(planSource, now);
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
catch (err) {
|
|
421
|
-
return failure(ctx, err);
|
|
422
|
-
}
|
|
493
|
+
const loaded = loadPlan(name, v, paths, ctx, deps, status, now);
|
|
494
|
+
if (typeof loaded === 'number')
|
|
495
|
+
return loaded;
|
|
496
|
+
const { plan, source, receiptPath } = loaded;
|
|
497
|
+
// A plan from `--remote-url` names a clone in the cache. Its deletes are
|
|
498
|
+
// pushed from there, so the clone must still push only where it fetches from.
|
|
499
|
+
const cacheDir = mirrorCache(ctx);
|
|
500
|
+
const cache = (existsSync(cacheDir) ? realpathSync(cacheDir) : cacheDir) + sep;
|
|
501
|
+
for (const repo of plan.repos) {
|
|
502
|
+
if (!repo.root.startsWith(cache))
|
|
503
|
+
continue;
|
|
504
|
+
const problem = mirrorProblem(repo.root);
|
|
505
|
+
if (problem !== null)
|
|
506
|
+
return failure(ctx, new Error(`${problem}; refusing to apply`));
|
|
423
507
|
}
|
|
508
|
+
const ok = () => (loaded.unreadable ? EXIT.error : EXIT.ok);
|
|
424
509
|
const repoRoot = v.repoRoot === undefined ? undefined : resolve(ctx.cwd, v.repoRoot);
|
|
510
|
+
const print = (receipt, shownPath) => {
|
|
511
|
+
ctx.io.stdout(v.output === 'json'
|
|
512
|
+
? `${JSON.stringify({ receiptPath: shownPath, summary: summarizeReceipt(receipt), receipt }, null, 2)}\n`
|
|
513
|
+
: renderReceipt(receipt, shownPath));
|
|
514
|
+
};
|
|
515
|
+
const planned = plan.repos.filter((r) => repoRoot === undefined || resolve(r.root) === resolve(repoRoot));
|
|
516
|
+
if (repoRoot !== undefined && planned.length === 0) {
|
|
517
|
+
return failure(ctx, new Error(`no plan repository at ${repoRoot}`));
|
|
518
|
+
}
|
|
519
|
+
if (rows.length === 0 && planned.every((r) => r.rows.length === 0)) {
|
|
520
|
+
if (v.output === 'json')
|
|
521
|
+
print(emptyReceipt(source, now, v.dryRun), null);
|
|
522
|
+
else
|
|
523
|
+
ctx.io.stdout('nothing to delete\n');
|
|
524
|
+
return ok();
|
|
525
|
+
}
|
|
425
526
|
const base = {
|
|
426
527
|
callerCwd: ctx.cwd,
|
|
427
|
-
planPath:
|
|
528
|
+
planPath: source,
|
|
428
529
|
repoRoot,
|
|
530
|
+
// An implicit selection runs only safe-to-delete rows, in the preview and
|
|
531
|
+
// in the run it confirms, whose rows are passed explicitly.
|
|
532
|
+
safeOnly: rows.length === 0,
|
|
429
533
|
...(v.progress
|
|
430
534
|
? {
|
|
431
535
|
onSweepProgress: (p) => status.update(`re-checking ${describeSweep(p)}`),
|
|
@@ -433,25 +537,17 @@ function runApply(argv, ctx, deps, status) {
|
|
|
433
537
|
}
|
|
434
538
|
: {}),
|
|
435
539
|
};
|
|
436
|
-
const print = (receipt, shownPath) => {
|
|
437
|
-
ctx.io.stdout(v.output === 'json'
|
|
438
|
-
? `${JSON.stringify({ receiptPath: shownPath, receipt }, null, 2)}\n`
|
|
439
|
-
: renderReceipt(receipt, shownPath));
|
|
440
|
-
};
|
|
441
540
|
let preview;
|
|
442
541
|
try {
|
|
443
|
-
|
|
542
|
+
// A sweep that just ran is the preview's re-check; the run that deletes sweeps again.
|
|
543
|
+
preview = applyPlan(plan, rows, grants, {
|
|
544
|
+
...base,
|
|
545
|
+
dryRun: true,
|
|
546
|
+
receiptPath: null,
|
|
547
|
+
swept: loaded.swept,
|
|
548
|
+
});
|
|
444
549
|
}
|
|
445
550
|
catch (err) {
|
|
446
|
-
if (err instanceof ApplyArgumentError) {
|
|
447
|
-
if (rows.length === 0 && plan.repos.every((r) => r.rows.length === 0)) {
|
|
448
|
-
if (v.output === 'json')
|
|
449
|
-
print(emptyReceipt(planSource, now), null);
|
|
450
|
-
else
|
|
451
|
-
ctx.io.stdout('nothing to delete\n');
|
|
452
|
-
return ok();
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
551
|
return failure(ctx, err);
|
|
456
552
|
}
|
|
457
553
|
const count = wouldRun(preview);
|
|
@@ -460,17 +556,9 @@ function runApply(argv, ctx, deps, status) {
|
|
|
460
556
|
return ok();
|
|
461
557
|
}
|
|
462
558
|
if (!v.yes) {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
const answer = (deps.confirm ?? terminalConfirm)(`Delete ${count} item${count === 1 ? '' : 's'}?`);
|
|
467
|
-
if (answer === null) {
|
|
468
|
-
return usageError(ctx, name, 'refusing to delete without a terminal to ask; pass --yes');
|
|
469
|
-
}
|
|
470
|
-
if (!answer) {
|
|
471
|
-
ctx.io.stderr('nothing deleted\n');
|
|
472
|
-
return EXIT.ok;
|
|
473
|
-
}
|
|
559
|
+
const declined = confirm(ctx, name, preview, count, v.output === 'json', deps);
|
|
560
|
+
if (declined !== null)
|
|
561
|
+
return declined;
|
|
474
562
|
}
|
|
475
563
|
// Only what the preview showed: rows it dropped stay dropped.
|
|
476
564
|
// Pinned to their repositories: a branch name two repositories share stays two rows.
|
|
@@ -489,14 +577,35 @@ function runApply(argv, ctx, deps, status) {
|
|
|
489
577
|
return failure(ctx, err);
|
|
490
578
|
}
|
|
491
579
|
print(receipt, receiptPath);
|
|
580
|
+
if (receiptPath === null) {
|
|
581
|
+
ctx.io.stderr(`${name}: no receipt written; pass --receipt <file> to keep one\n`);
|
|
582
|
+
}
|
|
492
583
|
return receipt.status === 'failed' ? EXIT_STEP_FAILED : ok();
|
|
493
584
|
}
|
|
494
|
-
|
|
585
|
+
/**
|
|
586
|
+
* Show the preview and ask. Null to go ahead; otherwise the exit code: 0 for
|
|
587
|
+
* a no, 2 when there is no one to ask.
|
|
588
|
+
*/
|
|
589
|
+
function confirm(ctx, name, preview, count, json, deps) {
|
|
590
|
+
// The preview is for the person answering, so it goes where the question does.
|
|
591
|
+
const show = json ? ctx.io.stderr : ctx.io.stdout;
|
|
592
|
+
show(renderReceipt(preview, null));
|
|
593
|
+
const answer = (deps.confirm ?? terminalConfirm)(`Delete ${count} item${count === 1 ? '' : 's'}?`);
|
|
594
|
+
if (answer === null) {
|
|
595
|
+
return usageError(ctx, name, 'refusing to delete without a terminal to ask; pass --yes');
|
|
596
|
+
}
|
|
597
|
+
if (!answer) {
|
|
598
|
+
ctx.io.stderr('nothing deleted\n');
|
|
599
|
+
return EXIT.ok;
|
|
600
|
+
}
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
function emptyReceipt(plan, now, dryRun) {
|
|
495
604
|
const at = now.toISOString();
|
|
496
605
|
return {
|
|
497
|
-
version:
|
|
606
|
+
version: RECEIPT_VERSION,
|
|
498
607
|
plan,
|
|
499
|
-
status: 'dry-run',
|
|
608
|
+
status: dryRun ? 'dry-run' : 'complete',
|
|
500
609
|
started_at: at,
|
|
501
610
|
finished_at: at,
|
|
502
611
|
dropped: [],
|
package/dist/index.d.ts
CHANGED
|
@@ -21,10 +21,10 @@ export { assessLanding, patchEquivalent, type Landing, type LandingCheck } from
|
|
|
21
21
|
export { ApplyArgumentError, 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
|
-
export { ReceiptSchema, type Dropped, type Receipt, type RowRef, type Step } from './receipt.ts';
|
|
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
27
|
export { expandTilde, isCheckoutRoot, resolveEntries, type Resolution, type ResolveOptions, } from './resolve.ts';
|
|
28
28
|
export { sweepRepo, sweepRepos, type SweepOptions, type SweepReposOptions } from './sweep.ts';
|
|
29
|
-
export { proposedCommands, renderJson, renderReceipt, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from './render.ts';
|
|
29
|
+
export { proposedCommands, renderJson, renderApplySummary, renderReceipt, summarizeReceipt, type ApplySummary, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from './render.ts';
|
|
30
30
|
export { ALL_SCOPES, AUTHORIZATIONS, authorizationsFor, emptyBucket, emptyScan, findDuplicateCheckouts, isUnfetched, makeItem, summarize, type Authorization, type Category, type Classification, type DuplicateCheckout, type PRInfo, type RepoInfo, type RepoSweep, type ScanStats, type Scope, type SummaryBucket, type SweepItem, type SweepProgress, type SweepResult, type UnresolvedEntry, } from './types.ts';
|
package/dist/index.js
CHANGED
|
@@ -20,10 +20,10 @@ export { DiscoveryFailed, discoverRepo } from "./discover.js";
|
|
|
20
20
|
export { assessLanding, patchEquivalent } from "./landing.js";
|
|
21
21
|
export { ApplyArgumentError, applyPlan, parseGrant, parseRowRef, } from "./apply.js";
|
|
22
22
|
export { PLAN_VERSION, PlanReadError, planRepo, parsePlan, readPlan, writePlan, } from "./plan.js";
|
|
23
|
-
export { ReceiptSchema } from "./receipt.js";
|
|
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
26
|
export { expandTilde, isCheckoutRoot, resolveEntries, } from "./resolve.js";
|
|
27
27
|
export { sweepRepo, sweepRepos } from "./sweep.js";
|
|
28
|
-
export { proposedCommands, renderJson, renderReceipt, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from "./render.js";
|
|
28
|
+
export { proposedCommands, renderJson, renderApplySummary, renderReceipt, summarizeReceipt, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from "./render.js";
|
|
29
29
|
export { ALL_SCOPES, AUTHORIZATIONS, authorizationsFor, emptyBucket, emptyScan, findDuplicateCheckouts, isUnfetched, makeItem, summarize, } from "./types.js";
|
package/dist/receipt.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** What `applyPlan` did, step by step, rewritten after every step. */
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
/** The receipt format's version, written into every receipt. */
|
|
4
|
+
export declare const RECEIPT_VERSION = 1;
|
|
3
5
|
export declare const RowRefSchema: z.ZodObject<{
|
|
4
6
|
category: z.ZodEnum<{
|
|
5
7
|
local_branch: "local_branch";
|
|
@@ -23,6 +25,7 @@ export declare const DroppedSchema: z.ZodObject<{
|
|
|
23
25
|
"needs-review": "needs-review";
|
|
24
26
|
"facts-changed": "facts-changed";
|
|
25
27
|
"not-authorized": "not-authorized";
|
|
28
|
+
duplicate: "duplicate";
|
|
26
29
|
}>;
|
|
27
30
|
detail: z.ZodString;
|
|
28
31
|
}, z.core.$strip>;
|
|
@@ -81,6 +84,7 @@ export declare const ReceiptSchema: z.ZodObject<{
|
|
|
81
84
|
"needs-review": "needs-review";
|
|
82
85
|
"facts-changed": "facts-changed";
|
|
83
86
|
"not-authorized": "not-authorized";
|
|
87
|
+
duplicate: "duplicate";
|
|
84
88
|
}>;
|
|
85
89
|
detail: z.ZodString;
|
|
86
90
|
}, z.core.$strip>>;
|
package/dist/receipt.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
/** What `applyPlan` did, step by step, rewritten after every step. */
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { PLAN_CATEGORIES } from "./plan.js";
|
|
4
|
+
/** The receipt format's version, written into every receipt. */
|
|
5
|
+
export const RECEIPT_VERSION = 1;
|
|
4
6
|
export const RowRefSchema = z.object({ category: z.enum(PLAN_CATEGORIES), id: z.string() });
|
|
5
7
|
export const DroppedSchema = RowRefSchema.extend({
|
|
6
8
|
repo: z.string(),
|
|
7
|
-
reason: z.enum([
|
|
9
|
+
reason: z.enum([
|
|
10
|
+
'gone',
|
|
11
|
+
'not-deletable',
|
|
12
|
+
'needs-review',
|
|
13
|
+
'facts-changed',
|
|
14
|
+
'not-authorized',
|
|
15
|
+
'duplicate',
|
|
16
|
+
]),
|
|
8
17
|
detail: z.string(),
|
|
9
18
|
});
|
|
10
19
|
export const StepSchema = z.object({
|
|
@@ -25,7 +34,7 @@ export const StepSchema = z.object({
|
|
|
25
34
|
verification: z.string().nullable(),
|
|
26
35
|
});
|
|
27
36
|
export const ReceiptSchema = z.object({
|
|
28
|
-
version: z.literal(
|
|
37
|
+
version: z.literal(RECEIPT_VERSION),
|
|
29
38
|
plan: z.string(),
|
|
30
39
|
status: z.enum(['dry-run', 'applying', 'complete', 'failed']),
|
|
31
40
|
started_at: z.string(),
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sweep a remote with no checkout of your own: a clone of it, kept in
|
|
3
|
+
* repo-cleaner's cache directory, stands in for the checkout.
|
|
4
|
+
*
|
|
5
|
+
* The clone is `--no-checkout`, so no files are written outside `.git`. It is
|
|
6
|
+
* not a partial (`--filter=blob:none`) clone: the patch comparison that
|
|
7
|
+
* finds squash-merged branches reads file contents, and a partial clone
|
|
8
|
+
* fetches them over the network one comparison at a time, which made a sweep
|
|
9
|
+
* of a few hundred branches take minutes instead of seconds. It is kept
|
|
10
|
+
* rather than thrown away so a plan written from it can be applied later: the
|
|
11
|
+
* plan names the clone's directory, and `apply` re-sweeps and pushes deletes
|
|
12
|
+
* from there. A later run reuses it, and the sweep's fetch brings it up to
|
|
13
|
+
* date.
|
|
14
|
+
*/
|
|
15
|
+
import type { CommandRunner } from '@osdlc/command-seam';
|
|
16
|
+
/** A remote URL that cannot be mirrored, or a mirror that cannot be made or reused. */
|
|
17
|
+
export declare class RemoteMirrorError extends Error {
|
|
18
|
+
constructor(message: string);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* `text` with every URL's credentials removed: the user-info before `@` in a
|
|
22
|
+
* `scheme://` URL, and any query string or fragment. For a message or a name
|
|
23
|
+
* that must not carry a token.
|
|
24
|
+
*/
|
|
25
|
+
export declare function redactUrl(text: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* The directory name for `url`'s mirror: readable `host-owner-repo`, then a
|
|
28
|
+
* hash of the whole URL so two URLs never share a mirror. The readable part
|
|
29
|
+
* is read from the URL with its credentials, query and fragment removed, so a
|
|
30
|
+
* token never reaches the name.
|
|
31
|
+
*/
|
|
32
|
+
export declare function mirrorName(url: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Why `dir` is not a mirror of `url` — or of whatever it was cloned from, when
|
|
35
|
+
* `url` is omitted — or null when it is. A mirror's `origin` has one fetch URL
|
|
36
|
+
* and pushes to that URL alone, so a delete cannot be sent elsewhere.
|
|
37
|
+
*/
|
|
38
|
+
export declare function mirrorProblem(dir: string, url?: string, opts?: {
|
|
39
|
+
git?: CommandRunner | undefined;
|
|
40
|
+
}): string | null;
|
|
41
|
+
/**
|
|
42
|
+
* The directory of `url`'s mirror under `cacheDir`, cloned on first use. An
|
|
43
|
+
* existing mirror is reused as it is, once {@link mirrorProblem} finds
|
|
44
|
+
* nothing wrong with it; the sweep's own fetch brings it up to date. A first
|
|
45
|
+
* clone goes to a sibling directory and is renamed into place, so a clone
|
|
46
|
+
* that fails leaves nothing behind, and a run that loses a race to clone the
|
|
47
|
+
* same URL uses the winner's clone. Messages carry the URL with its
|
|
48
|
+
* credentials removed.
|
|
49
|
+
*
|
|
50
|
+
* @throws RemoteMirrorError when `url` is empty or looks like an option, when
|
|
51
|
+
* the clone fails, or when the directory is not a mirror of `url`.
|
|
52
|
+
*/
|
|
53
|
+
export declare function ensureMirror(url: string, cacheDir: string, opts?: {
|
|
54
|
+
git?: CommandRunner | undefined;
|
|
55
|
+
}): string;
|