@osdlc/repo-cleaner 0.10.1 → 0.13.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 +43 -0
- package/README.md +135 -19
- package/dist/apply.d.ts +30 -1
- package/dist/apply.js +118 -17
- package/dist/cli.d.ts +6 -1
- package/dist/cli.js +192 -30
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/plan.d.ts +3 -0
- package/dist/plan.js +14 -1
- package/dist/receipt.d.ts +4 -0
- package/dist/receipt.js +2 -0
- package/dist/remote-mirror.d.ts +55 -0
- package/dist/remote-mirror.js +123 -0
- package/dist/render.d.ts +48 -5
- package/dist/render.js +151 -29
- package/dist/status-line.d.ts +4 -2
- package/dist/status-line.js +11 -4
- package/dist/step-board.d.ts +34 -0
- package/dist/step-board.js +101 -0
- 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.d.ts +12 -0
- package/dist/worktrees.js +58 -1
- package/package.json +8 -8
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,17 +21,21 @@
|
|
|
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
|
+
import { detectColorLevel } from '@osdlc/ux';
|
|
29
30
|
import { resolveBranch } from "./branches.js";
|
|
31
|
+
import { VENDOR_DIRS } from "./devlink.js";
|
|
32
|
+
import { ensureMirror, mirrorProblem } from "./remote-mirror.js";
|
|
30
33
|
import { ApplyArgumentError, applyPlan, parseGrant, parseRowRef } from "./apply.js";
|
|
31
34
|
import { PLAN_VERSION, fileStamp, parsePlan, planRepo, readPlan, writePlan, } from "./plan.js";
|
|
32
35
|
import { RECEIPT_VERSION } from "./receipt.js";
|
|
33
|
-
import { proposedCommands, renderCountsJson, renderCountsText, renderJson, renderReceipt, renderSummary, renderText, rowsActedOn, summarizeReceipt, } from "./render.js";
|
|
36
|
+
import { PLAIN, proposedCommands, renderCountsJson, renderCountsText, renderJson, renderPreview, renderPreviewDrops, renderReceipt, renderStepFailure, renderSummary, renderText, rowsActedOn, summarizeReceipt, TERMINAL_STYLE, } from "./render.js";
|
|
34
37
|
import { NO_STATUS_LINE, describeStep, describeSweep, statusLine, } from "./status-line.js";
|
|
38
|
+
import { stepBoard } from "./step-board.js";
|
|
35
39
|
import { sweepRepos } from "./sweep.js";
|
|
36
40
|
const EXIT_NOTHING_TO_SWEEP = 10;
|
|
37
41
|
const EXIT_STEP_FAILED = 11;
|
|
@@ -47,23 +51,38 @@ const SWEEP_FLAGS = {
|
|
|
47
51
|
only: {
|
|
48
52
|
kind: 'list',
|
|
49
53
|
valueName: 'list',
|
|
50
|
-
help: `comma-separated: ${Object.keys(ONLY).join(', ')} (default:
|
|
54
|
+
help: `comma-separated: ${Object.keys(ONLY).join(', ')} (default: worktrees, branches; remotes with --remotes)`,
|
|
55
|
+
},
|
|
56
|
+
remotes: {
|
|
57
|
+
kind: 'boolean',
|
|
58
|
+
help: 'also sweep remote branches, which is slower: each one is compared with the default branch',
|
|
51
59
|
},
|
|
52
60
|
branch: {
|
|
53
61
|
kind: 'string',
|
|
54
62
|
valueName: 'name|HEAD',
|
|
55
|
-
help: 'only this branch of this repository: its worktree
|
|
63
|
+
help: 'only this branch of this repository: its worktree and local branch, and its remote branch with --remotes',
|
|
56
64
|
},
|
|
57
65
|
remote: {
|
|
58
66
|
kind: 'string',
|
|
59
67
|
valueName: 'name',
|
|
60
68
|
help: 'remote to measure against (default: origin)',
|
|
61
69
|
},
|
|
62
|
-
|
|
70
|
+
remoteUrl: {
|
|
71
|
+
kind: 'list',
|
|
72
|
+
valueName: 'url',
|
|
73
|
+
default: [],
|
|
74
|
+
commaSeparated: false,
|
|
75
|
+
help: 'sweep the branches of this remote with no checkout of your own, through a clone kept in the cache directory; repeatable',
|
|
76
|
+
},
|
|
77
|
+
gh: {
|
|
78
|
+
kind: 'boolean',
|
|
79
|
+
negatable: true,
|
|
80
|
+
help: 'with --remotes: classify on git evidence alone (without --remotes gh is never called)',
|
|
81
|
+
},
|
|
63
82
|
fetch: {
|
|
64
83
|
kind: 'boolean',
|
|
65
84
|
negatable: true,
|
|
66
|
-
help: 'classify on the remote-tracking refs already on disk',
|
|
85
|
+
help: 'with --remotes: classify on the remote-tracking refs already on disk (without --remotes nothing is fetched)',
|
|
67
86
|
},
|
|
68
87
|
progress: {
|
|
69
88
|
kind: 'boolean',
|
|
@@ -87,8 +106,9 @@ const OUTPUT = {
|
|
|
87
106
|
};
|
|
88
107
|
const statusCli = () => defineCli({
|
|
89
108
|
name: NAME,
|
|
90
|
-
summary: 'Inventory branches
|
|
91
|
-
'the default branch.
|
|
109
|
+
summary: 'Inventory local branches and worktrees (remote branches too with --remotes), and say ' +
|
|
110
|
+
'which have been merged into the default branch. Offline unless --remotes, which fetches ' +
|
|
111
|
+
'each remote first and asks gh about pull requests; deletes nothing.',
|
|
92
112
|
positionals: PATHS,
|
|
93
113
|
flags: {
|
|
94
114
|
...SWEEP_FLAGS,
|
|
@@ -98,7 +118,7 @@ const statusCli = () => defineCli({
|
|
|
98
118
|
},
|
|
99
119
|
epilogue: `commands:
|
|
100
120
|
${NAME} [status] this report (the default)
|
|
101
|
-
${NAME} summary counts per repository: total, merged, delete, review, keep
|
|
121
|
+
${NAME} summary counts per repository: total, merged, delete, prune, review, keep
|
|
102
122
|
${NAME} plan write the deletable rows as a plan
|
|
103
123
|
${NAME} apply re-check, show the steps, ask, delete
|
|
104
124
|
${NAME} dev run ${NAME} from a development checkout
|
|
@@ -108,8 +128,8 @@ swept as ./<name>.`,
|
|
|
108
128
|
});
|
|
109
129
|
const summaryCli = () => defineCli({
|
|
110
130
|
name: `${NAME} summary`,
|
|
111
|
-
summary: 'Count local branches
|
|
112
|
-
'how many are merged into the default branch, and how many would be deleted, reviewed or kept.',
|
|
131
|
+
summary: 'Count local branches and worktrees (and remote branches with --remotes) per repository: how many there are, ' +
|
|
132
|
+
'how many are merged into the default branch, and how many would be deleted, pruned, reviewed or kept.',
|
|
113
133
|
positionals: PATHS,
|
|
114
134
|
flags: { ...SWEEP_FLAGS, output: OUTPUT },
|
|
115
135
|
epilogue: 'merged: the work is on the default branch. Rows kept for structure (the default ' +
|
|
@@ -151,6 +171,13 @@ const applyCli = () => defineCli({
|
|
|
151
171
|
commaSeparated: false,
|
|
152
172
|
help: 'apply only this row, e.g. local_branch:feat/x; repeatable (default: every row)',
|
|
153
173
|
},
|
|
174
|
+
keep: {
|
|
175
|
+
kind: 'list',
|
|
176
|
+
valueName: 'remote_branch:id',
|
|
177
|
+
default: [],
|
|
178
|
+
commaSeparated: false,
|
|
179
|
+
help: 'keep this remote branch, but refuse the run if its tip moved since the plan, e.g. remote_branch:origin/feat/x; repeatable',
|
|
180
|
+
},
|
|
154
181
|
allow: {
|
|
155
182
|
kind: 'list',
|
|
156
183
|
valueName: 'risk[=category:id]',
|
|
@@ -209,6 +236,31 @@ function failure(ctx, err) {
|
|
|
209
236
|
ctx.io.stderr(`${NAME}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
210
237
|
return EXIT.error;
|
|
211
238
|
}
|
|
239
|
+
/** Color for a report on `stream`, when it is a terminal that takes color. */
|
|
240
|
+
function styleFor(ctx, stream) {
|
|
241
|
+
const level = detectColorLevel({ env: ctx.env, isTTY: ctx.terminal?.[stream] === true });
|
|
242
|
+
return level === 'none' ? PLAIN : TERMINAL_STYLE;
|
|
243
|
+
}
|
|
244
|
+
/** The note under a text report that left remote branches out, or '' when it did not. */
|
|
245
|
+
function remotesNote(swept, style) {
|
|
246
|
+
if (swept.kind !== 'ok' || swept.scope.includes('remote-branches'))
|
|
247
|
+
return '';
|
|
248
|
+
if (!swept.result.repos.some((r) => r.repo?.remote != null))
|
|
249
|
+
return '';
|
|
250
|
+
return (style.muted('Local only: nothing fetched, no gh, remote branches not checked; add --remotes for all three.') + '\n');
|
|
251
|
+
}
|
|
252
|
+
/** Where `--remote-url` keeps its clones. */
|
|
253
|
+
function mirrorCache(ctx) {
|
|
254
|
+
return systemDirs(VENDOR_DIRS, { env: ctx.env }).standalone(NAME).cache().app('remotes').path;
|
|
255
|
+
}
|
|
256
|
+
/** Local work by default: remote branches only when asked for, with `--remotes` or `--only remotes`. */
|
|
257
|
+
const LOCAL_SCOPE = ['worktrees', 'local-branches'];
|
|
258
|
+
function sweepScope(only, remotes) {
|
|
259
|
+
const scope = only === undefined ? [...LOCAL_SCOPE] : only.map((s) => ONLY[s]);
|
|
260
|
+
if (remotes && !scope.includes('remote-branches'))
|
|
261
|
+
scope.push('remote-branches');
|
|
262
|
+
return scope;
|
|
263
|
+
}
|
|
212
264
|
/** Validate the sweep flags and run the sweep, or answer with the exit code. */
|
|
213
265
|
function sweep(name, v, paths, ctx, status) {
|
|
214
266
|
const only = v.only?.filter((s) => s !== '');
|
|
@@ -221,6 +273,26 @@ function sweep(name, v, paths, ctx, status) {
|
|
|
221
273
|
const message = '--branch sweeps this repository only; drop the paths';
|
|
222
274
|
return { kind: 'exit', code: usageError(ctx, name, message) };
|
|
223
275
|
}
|
|
276
|
+
// An empty URL is refused, not dropped: `--remote-url "$UNSET"` must not
|
|
277
|
+
// fall back to sweeping the checkout you are standing in.
|
|
278
|
+
if (v.remoteUrl.some((u) => u.trim() === '')) {
|
|
279
|
+
return {
|
|
280
|
+
kind: 'exit',
|
|
281
|
+
code: usageError(ctx, name, '--remote-url needs a URL; got an empty one'),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const urls = v.remoteUrl;
|
|
285
|
+
if (urls.length > 0) {
|
|
286
|
+
const conflict = paths.length > 0
|
|
287
|
+
? '--remote-url sweeps a remote, not a checkout; drop the paths'
|
|
288
|
+
: v.branch !== undefined
|
|
289
|
+
? '--branch reads the checkout you are in; it does not combine with --remote-url'
|
|
290
|
+
: (only ?? []).some((s) => s !== 'remotes')
|
|
291
|
+
? '--remote-url has remote branches only; drop --only or pass --only remotes'
|
|
292
|
+
: null;
|
|
293
|
+
if (conflict !== null)
|
|
294
|
+
return { kind: 'exit', code: usageError(ctx, name, conflict) };
|
|
295
|
+
}
|
|
224
296
|
let branch;
|
|
225
297
|
try {
|
|
226
298
|
branch = v.branch === undefined ? undefined : resolveBranch(v.branch, new Git(ctx.cwd));
|
|
@@ -228,22 +300,40 @@ function sweep(name, v, paths, ctx, status) {
|
|
|
228
300
|
catch (err) {
|
|
229
301
|
return { kind: 'exit', code: failure(ctx, err) };
|
|
230
302
|
}
|
|
303
|
+
const scope = urls.length > 0 ? ['remote-branches'] : sweepScope(only, v.remotes);
|
|
304
|
+
const online = scope.includes('remote-branches');
|
|
231
305
|
const options = {
|
|
232
306
|
baseDir: ctx.cwd,
|
|
233
307
|
cwd: ctx.cwd,
|
|
234
|
-
|
|
235
|
-
|
|
308
|
+
// Local by default means offline: the fetch and gh go with remote branches.
|
|
309
|
+
noGh: !(online && v.gh),
|
|
310
|
+
fetch: online && v.fetch,
|
|
236
311
|
branch,
|
|
237
312
|
// Mid-walk, while the wait is happening — the summary comes too late to
|
|
238
313
|
// explain why the tool appeared to hang.
|
|
239
314
|
onWarn: (message) => ctx.io.stderr(`${NAME}: ${message}\n`),
|
|
240
315
|
...(v.progress ? { onProgress: (p) => status.update(describeSweep(p)) } : {}),
|
|
241
|
-
|
|
316
|
+
scope,
|
|
242
317
|
...(v.remote === undefined ? {} : { remote: v.remote }),
|
|
243
318
|
};
|
|
319
|
+
let entries = paths.length > 0 ? [...paths] : [ctx.cwd];
|
|
320
|
+
if (urls.length > 0) {
|
|
321
|
+
const cache = mirrorCache(ctx);
|
|
322
|
+
try {
|
|
323
|
+
entries = urls.map((url, i) => {
|
|
324
|
+
if (v.progress)
|
|
325
|
+
status.update(`${NAME}: cloning remote ${i + 1}/${urls.length}`);
|
|
326
|
+
return ensureMirror(url, cache);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
status.clear();
|
|
331
|
+
return { kind: 'exit', code: failure(ctx, err) };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
244
334
|
if (v.progress)
|
|
245
335
|
status.update(`${NAME}: finding repositories`);
|
|
246
|
-
const result = sweepRepos(
|
|
336
|
+
const result = sweepRepos(entries, options);
|
|
247
337
|
status.clear();
|
|
248
338
|
if (branch !== undefined && result.repos.length > 1) {
|
|
249
339
|
const err = new Error(`--branch sweeps one repository; ${result.repos.length} found under ${ctx.cwd}`);
|
|
@@ -254,7 +344,7 @@ function sweep(name, v, paths, ctx, status) {
|
|
|
254
344
|
return { kind: 'exit', code: failure(ctx, new Error(refused)) };
|
|
255
345
|
if (result.repos.length === 0)
|
|
256
346
|
return { kind: 'nothing', result };
|
|
257
|
-
return { kind: 'ok', result, branch, values: v };
|
|
347
|
+
return { kind: 'ok', result, branch, values: v, scope, noGh: options.noGh === true };
|
|
258
348
|
}
|
|
259
349
|
/**
|
|
260
350
|
* Name each repository the sweep could not read, on stderr. True when there
|
|
@@ -270,13 +360,13 @@ function reportUnreadable(ctx, name, result) {
|
|
|
270
360
|
function planOf(swept, now) {
|
|
271
361
|
const opts = {
|
|
272
362
|
remote: swept.values.remote ?? null,
|
|
273
|
-
noGh:
|
|
363
|
+
noGh: swept.noGh,
|
|
274
364
|
branch: swept.branch ?? null,
|
|
275
365
|
};
|
|
276
366
|
return {
|
|
277
367
|
version: PLAN_VERSION,
|
|
278
368
|
created_at: now.toISOString(),
|
|
279
|
-
repos: swept.result.repos.flatMap((s) => s.repo === null ? [] : [planRepo(s.repo.root, opts, s.items)]),
|
|
369
|
+
repos: swept.result.repos.flatMap((s) => s.repo === null ? [] : [planRepo(s.repo.root, { ...opts, fetch: s.repo.fetched }, s.items)]),
|
|
280
370
|
};
|
|
281
371
|
}
|
|
282
372
|
function runStatus(argv, ctx, status) {
|
|
@@ -286,7 +376,8 @@ function runStatus(argv, ctx, status) {
|
|
|
286
376
|
if (parsed.status === 'error')
|
|
287
377
|
return usageError(ctx, NAME, parsed.message);
|
|
288
378
|
const v = parsed.values;
|
|
289
|
-
const
|
|
379
|
+
const style = styleFor(ctx, 'stdout');
|
|
380
|
+
const render = (r) => v.output === 'json' ? renderJson(r) : renderText(r, { keep: v.keep, style });
|
|
290
381
|
const swept = sweep(NAME, v, parsed.positionals, ctx, status);
|
|
291
382
|
if (swept.kind === 'exit')
|
|
292
383
|
return swept.code;
|
|
@@ -301,6 +392,8 @@ function runStatus(argv, ctx, status) {
|
|
|
301
392
|
}
|
|
302
393
|
else {
|
|
303
394
|
ctx.io.stdout(render(swept.result));
|
|
395
|
+
if (v.output === 'text')
|
|
396
|
+
ctx.io.stdout(remotesNote(swept, style));
|
|
304
397
|
}
|
|
305
398
|
return EXIT.ok;
|
|
306
399
|
}
|
|
@@ -312,11 +405,14 @@ function runSummary(argv, ctx, status) {
|
|
|
312
405
|
if (parsed.status === 'error')
|
|
313
406
|
return usageError(ctx, name, parsed.message);
|
|
314
407
|
const v = parsed.values;
|
|
315
|
-
const
|
|
408
|
+
const style = styleFor(ctx, 'stdout');
|
|
409
|
+
const render = (r) => v.output === 'json' ? renderCountsJson(r) : renderCountsText(r, style);
|
|
316
410
|
const swept = sweep(name, v, parsed.positionals, ctx, status);
|
|
317
411
|
if (swept.kind === 'exit')
|
|
318
412
|
return swept.code;
|
|
319
413
|
ctx.io.stdout(render(swept.result));
|
|
414
|
+
if (v.output === 'text')
|
|
415
|
+
ctx.io.stdout(remotesNote(swept, style));
|
|
320
416
|
return swept.kind === 'nothing' ? EXIT_NOTHING_TO_SWEEP : EXIT.ok;
|
|
321
417
|
}
|
|
322
418
|
function runPlan(argv, ctx, status) {
|
|
@@ -337,7 +433,8 @@ function runPlan(argv, ctx, status) {
|
|
|
337
433
|
}
|
|
338
434
|
const plan = planOf(swept, new Date());
|
|
339
435
|
const unreadable = reportUnreadable(ctx, name, swept.result);
|
|
340
|
-
ctx
|
|
436
|
+
const style = styleFor(ctx, 'stderr');
|
|
437
|
+
ctx.io.stderr(renderSummary(swept.result, style) + '\n' + remotesNote(swept, style));
|
|
341
438
|
if (v.out === '-') {
|
|
342
439
|
ctx.io.stdout(`${JSON.stringify(plan, null, 2)}\n`);
|
|
343
440
|
}
|
|
@@ -353,6 +450,7 @@ function receiptBeside(planPath, now) {
|
|
|
353
450
|
const stem = basename(planPath).replace(/\.json$/i, '');
|
|
354
451
|
return join(dirname(planPath), `${stem}.receipt-${fileStamp(now)}.json`);
|
|
355
452
|
}
|
|
453
|
+
const dropKey = (d) => JSON.stringify([d.repo, d.category, d.id]);
|
|
356
454
|
/** Rows the run would delete or prune: one each, however many steps (an unlock, a remove) it takes. */
|
|
357
455
|
function wouldRun(receipt) {
|
|
358
456
|
return rowsActedOn(summarizeReceipt(receipt));
|
|
@@ -407,6 +505,7 @@ function applyConflict(v, paths) {
|
|
|
407
505
|
v.only !== undefined ? '--only' : null,
|
|
408
506
|
v.branch !== undefined ? '--branch' : null,
|
|
409
507
|
v.remote !== undefined ? '--remote' : null,
|
|
508
|
+
v.remoteUrl.length > 0 ? '--remote-url' : null,
|
|
410
509
|
].filter((f) => f !== null);
|
|
411
510
|
if (sweepFlags.length > 0)
|
|
412
511
|
return `--plan fixes what is swept; drop ${sweepFlags.join(', ')}`;
|
|
@@ -426,9 +525,11 @@ function runApply(argv, ctx, deps, status) {
|
|
|
426
525
|
const v = parsed.values;
|
|
427
526
|
const paths = parsed.positionals;
|
|
428
527
|
let rows;
|
|
528
|
+
let keep;
|
|
429
529
|
let grants;
|
|
430
530
|
try {
|
|
431
531
|
rows = v.row.map(parseRowRef);
|
|
532
|
+
keep = v.keep.map(parseRowRef);
|
|
432
533
|
grants = v.allow.map(parseGrant);
|
|
433
534
|
}
|
|
434
535
|
catch (err) {
|
|
@@ -444,12 +545,27 @@ function runApply(argv, ctx, deps, status) {
|
|
|
444
545
|
if (typeof loaded === 'number')
|
|
445
546
|
return loaded;
|
|
446
547
|
const { plan, source, receiptPath } = loaded;
|
|
548
|
+
// A plan from `--remote-url` names a clone in the cache. Its deletes are
|
|
549
|
+
// pushed from there, so the clone must still push only where it fetches from.
|
|
550
|
+
const cacheDir = mirrorCache(ctx);
|
|
551
|
+
const cache = (existsSync(cacheDir) ? realpathSync(cacheDir) : cacheDir) + sep;
|
|
552
|
+
for (const repo of plan.repos) {
|
|
553
|
+
if (!repo.root.startsWith(cache))
|
|
554
|
+
continue;
|
|
555
|
+
const problem = mirrorProblem(repo.root);
|
|
556
|
+
if (problem !== null)
|
|
557
|
+
return failure(ctx, new Error(`${problem}; refusing to apply`));
|
|
558
|
+
}
|
|
447
559
|
const ok = () => (loaded.unreadable ? EXIT.error : EXIT.ok);
|
|
448
560
|
const repoRoot = v.repoRoot === undefined ? undefined : resolve(ctx.cwd, v.repoRoot);
|
|
449
|
-
|
|
561
|
+
// On a terminal the run is shown live: every step first, then each one
|
|
562
|
+
// checked off as it finishes, with a bar on the status line.
|
|
563
|
+
const live = v.output === 'text' && ctx.terminal?.stdout === true;
|
|
564
|
+
const style = styleFor(ctx, 'stdout');
|
|
565
|
+
const print = (receipt, shownPath, steps = true) => {
|
|
450
566
|
ctx.io.stdout(v.output === 'json'
|
|
451
567
|
? `${JSON.stringify({ receiptPath: shownPath, summary: summarizeReceipt(receipt), receipt }, null, 2)}\n`
|
|
452
|
-
: renderReceipt(receipt, shownPath));
|
|
568
|
+
: renderReceipt(receipt, shownPath, style, { steps }));
|
|
453
569
|
};
|
|
454
570
|
const planned = plan.repos.filter((r) => repoRoot === undefined || resolve(r.root) === resolve(repoRoot));
|
|
455
571
|
if (repoRoot !== undefined && planned.length === 0) {
|
|
@@ -466,6 +582,7 @@ function runApply(argv, ctx, deps, status) {
|
|
|
466
582
|
callerCwd: ctx.cwd,
|
|
467
583
|
planPath: source,
|
|
468
584
|
repoRoot,
|
|
585
|
+
keep,
|
|
469
586
|
// An implicit selection runs only safe-to-delete rows, in the preview and
|
|
470
587
|
// in the run it confirms, whose rows are passed explicitly.
|
|
471
588
|
safeOnly: rows.length === 0,
|
|
@@ -487,11 +604,16 @@ function runApply(argv, ctx, deps, status) {
|
|
|
487
604
|
});
|
|
488
605
|
}
|
|
489
606
|
catch (err) {
|
|
607
|
+
if (err instanceof ApplyArgumentError)
|
|
608
|
+
return usageError(ctx, name, err.message);
|
|
490
609
|
return failure(ctx, err);
|
|
491
610
|
}
|
|
492
611
|
const count = wouldRun(preview);
|
|
493
612
|
if (v.dryRun || count === 0) {
|
|
494
|
-
|
|
613
|
+
if (live)
|
|
614
|
+
ctx.io.stdout(renderPreview(preview, style));
|
|
615
|
+
else
|
|
616
|
+
print(preview, null);
|
|
495
617
|
return ok();
|
|
496
618
|
}
|
|
497
619
|
if (!v.yes) {
|
|
@@ -499,10 +621,22 @@ function runApply(argv, ctx, deps, status) {
|
|
|
499
621
|
if (declined !== null)
|
|
500
622
|
return declined;
|
|
501
623
|
}
|
|
624
|
+
else if (live) {
|
|
625
|
+
ctx.io.stdout(renderPreview(preview, style));
|
|
626
|
+
}
|
|
627
|
+
if (live)
|
|
628
|
+
ctx.io.stdout('\n');
|
|
502
629
|
// Only what the preview showed: rows it dropped stay dropped.
|
|
503
630
|
// Pinned to their repositories: a branch name two repositories share stays two rows.
|
|
504
|
-
|
|
631
|
+
// A kept row's tip check is not a selection; `keep` carries it again.
|
|
632
|
+
const approved = preview.steps.flatMap((s) => s.row === null || s.action === 'verify-remote' ? [] : [{ ...s.row, repo: s.repo }]);
|
|
505
633
|
const approvedGrants = grants.filter((g) => g.row === null || approved.some((r) => r.category === g.row.category && r.id === g.row.id));
|
|
634
|
+
const size = deps.terminalSize ??
|
|
635
|
+
(() => ({
|
|
636
|
+
width: ctx.terminal?.columns || process.stdout.columns || 80,
|
|
637
|
+
height: process.stdout.rows || 24,
|
|
638
|
+
}));
|
|
639
|
+
let board;
|
|
506
640
|
let receipt;
|
|
507
641
|
try {
|
|
508
642
|
receipt = applyPlan(plan, approved, approvedGrants, {
|
|
@@ -510,12 +644,40 @@ function runApply(argv, ctx, deps, status) {
|
|
|
510
644
|
dryRun: false,
|
|
511
645
|
receiptPath,
|
|
512
646
|
dropped: preview.dropped,
|
|
647
|
+
...(live
|
|
648
|
+
? {
|
|
649
|
+
// The board carries the run's progress; the status line stays clear.
|
|
650
|
+
onStart: (steps) => {
|
|
651
|
+
board = stepBoard(ctx.io.stdout, steps, size, style);
|
|
652
|
+
board.draw(null);
|
|
653
|
+
},
|
|
654
|
+
onStep: (i) => board?.draw(i),
|
|
655
|
+
onStepDone: (_i, _total, step) => {
|
|
656
|
+
board?.draw(null);
|
|
657
|
+
// Here rather than after the run: a receipt that then cannot be
|
|
658
|
+
// saved must not take the step's own error with it.
|
|
659
|
+
if (step.result === 'failed') {
|
|
660
|
+
ctx.io.stdout(`\n${renderStepFailure(step, style)}\n`);
|
|
661
|
+
}
|
|
662
|
+
},
|
|
663
|
+
}
|
|
664
|
+
: {}),
|
|
513
665
|
});
|
|
514
666
|
}
|
|
515
667
|
catch (err) {
|
|
516
668
|
return failure(ctx, err);
|
|
517
669
|
}
|
|
518
|
-
|
|
670
|
+
status.clear();
|
|
671
|
+
if (live) {
|
|
672
|
+
// The run's own re-check can drop a row the preview showed, if it moved
|
|
673
|
+
// while the question was open: say which, since it gets no tick.
|
|
674
|
+
const seen = new Set(preview.dropped.map(dropKey));
|
|
675
|
+
const late = receipt.dropped.filter((d) => !seen.has(dropKey(d)));
|
|
676
|
+
if (late.length > 0)
|
|
677
|
+
ctx.io.stdout(`\n${renderPreviewDrops(late, style)}`);
|
|
678
|
+
ctx.io.stdout('\n');
|
|
679
|
+
}
|
|
680
|
+
print(receipt, receiptPath, !live);
|
|
519
681
|
if (receiptPath === null) {
|
|
520
682
|
ctx.io.stderr(`${name}: no receipt written; pass --receipt <file> to keep one\n`);
|
|
521
683
|
}
|
|
@@ -528,7 +690,7 @@ function runApply(argv, ctx, deps, status) {
|
|
|
528
690
|
function confirm(ctx, name, preview, count, json, deps) {
|
|
529
691
|
// The preview is for the person answering, so it goes where the question does.
|
|
530
692
|
const show = json ? ctx.io.stderr : ctx.io.stdout;
|
|
531
|
-
show(
|
|
693
|
+
show(renderPreview(preview, styleFor(ctx, json ? 'stderr' : 'stdout')));
|
|
532
694
|
const answer = (deps.confirm ?? terminalConfirm)(`Delete ${count} item${count === 1 ? '' : 's'}?`);
|
|
533
695
|
if (answer === null) {
|
|
534
696
|
return usageError(ctx, name, 'refusing to delete without a terminal to ask; pass --yes');
|
package/dist/index.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ 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';
|
package/dist/index.js
CHANGED
|
@@ -18,7 +18,7 @@ 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";
|
package/dist/plan.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export declare const PlanRepoSchema: z.ZodObject<{
|
|
|
29
29
|
remote: z.ZodNullable<z.ZodString>;
|
|
30
30
|
no_gh: z.ZodBoolean;
|
|
31
31
|
branch: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
|
32
|
+
fetch: z.ZodDefault<z.ZodBoolean>;
|
|
32
33
|
rows: z.ZodArray<z.ZodObject<{
|
|
33
34
|
category: z.ZodEnum<{
|
|
34
35
|
local_branch: "local_branch";
|
|
@@ -53,6 +54,7 @@ export declare const PlanSchema: z.ZodObject<{
|
|
|
53
54
|
remote: z.ZodNullable<z.ZodString>;
|
|
54
55
|
no_gh: z.ZodBoolean;
|
|
55
56
|
branch: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
|
57
|
+
fetch: z.ZodDefault<z.ZodBoolean>;
|
|
56
58
|
rows: z.ZodArray<z.ZodObject<{
|
|
57
59
|
category: z.ZodEnum<{
|
|
58
60
|
local_branch: "local_branch";
|
|
@@ -89,6 +91,7 @@ export declare function planRepo(root: string, opts: {
|
|
|
89
91
|
remote: string | null;
|
|
90
92
|
noGh: boolean;
|
|
91
93
|
branch?: string | null;
|
|
94
|
+
fetch?: boolean;
|
|
92
95
|
}, items: readonly PlannableRow[]): PlanRepo;
|
|
93
96
|
/** A UTC timestamp safe in a file name, e.g. `2026-10-04T12-30-05-123Z`. */
|
|
94
97
|
export declare function fileStamp(d: Date): string;
|
package/dist/plan.js
CHANGED
|
@@ -23,6 +23,12 @@ export const PlanRepoSchema = z.object({
|
|
|
23
23
|
no_gh: z.boolean(),
|
|
24
24
|
/** The `branch` the sweep was narrowed to; the re-sweep uses the same. */
|
|
25
25
|
branch: z.string().nullable().default(null),
|
|
26
|
+
/**
|
|
27
|
+
* Whether the sweep fetched before classifying. The re-sweep fetches too, so
|
|
28
|
+
* it judges against the remote's current default branch, as the sweep did.
|
|
29
|
+
* A plan written before this field reads as not fetched.
|
|
30
|
+
*/
|
|
31
|
+
fetch: z.boolean().default(false),
|
|
26
32
|
rows: z.array(PlanRowSchema),
|
|
27
33
|
});
|
|
28
34
|
export const PlanSchema = z.object({
|
|
@@ -78,7 +84,14 @@ export function planRepo(root, opts, items) {
|
|
|
78
84
|
fingerprint: fingerprint(item),
|
|
79
85
|
});
|
|
80
86
|
}
|
|
81
|
-
return {
|
|
87
|
+
return {
|
|
88
|
+
root,
|
|
89
|
+
remote: opts.remote,
|
|
90
|
+
no_gh: opts.noGh,
|
|
91
|
+
branch: opts.branch ?? null,
|
|
92
|
+
fetch: opts.fetch ?? false,
|
|
93
|
+
rows,
|
|
94
|
+
};
|
|
82
95
|
}
|
|
83
96
|
/** A UTC timestamp safe in a file name, e.g. `2026-10-04T12-30-05-123Z`. */
|
|
84
97
|
export function fileStamp(d) {
|
package/dist/receipt.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export declare const DroppedSchema: z.ZodObject<{
|
|
|
26
26
|
"facts-changed": "facts-changed";
|
|
27
27
|
"not-authorized": "not-authorized";
|
|
28
28
|
duplicate: "duplicate";
|
|
29
|
+
kept: "kept";
|
|
29
30
|
}>;
|
|
30
31
|
detail: z.ZodString;
|
|
31
32
|
}, z.core.$strip>;
|
|
@@ -41,6 +42,7 @@ export declare const StepSchema: z.ZodObject<{
|
|
|
41
42
|
id: z.ZodString;
|
|
42
43
|
}, z.core.$strip>>;
|
|
43
44
|
action: z.ZodEnum<{
|
|
45
|
+
"verify-remote": "verify-remote";
|
|
44
46
|
"unlock-worktree": "unlock-worktree";
|
|
45
47
|
"remove-worktree": "remove-worktree";
|
|
46
48
|
"delete-local": "delete-local";
|
|
@@ -85,6 +87,7 @@ export declare const ReceiptSchema: z.ZodObject<{
|
|
|
85
87
|
"facts-changed": "facts-changed";
|
|
86
88
|
"not-authorized": "not-authorized";
|
|
87
89
|
duplicate: "duplicate";
|
|
90
|
+
kept: "kept";
|
|
88
91
|
}>;
|
|
89
92
|
detail: z.ZodString;
|
|
90
93
|
}, z.core.$strip>>;
|
|
@@ -99,6 +102,7 @@ export declare const ReceiptSchema: z.ZodObject<{
|
|
|
99
102
|
id: z.ZodString;
|
|
100
103
|
}, z.core.$strip>>;
|
|
101
104
|
action: z.ZodEnum<{
|
|
105
|
+
"verify-remote": "verify-remote";
|
|
102
106
|
"unlock-worktree": "unlock-worktree";
|
|
103
107
|
"remove-worktree": "remove-worktree";
|
|
104
108
|
"delete-local": "delete-local";
|
package/dist/receipt.js
CHANGED
|
@@ -13,6 +13,7 @@ export const DroppedSchema = RowRefSchema.extend({
|
|
|
13
13
|
'facts-changed',
|
|
14
14
|
'not-authorized',
|
|
15
15
|
'duplicate',
|
|
16
|
+
'kept',
|
|
16
17
|
]),
|
|
17
18
|
detail: z.string(),
|
|
18
19
|
});
|
|
@@ -21,6 +22,7 @@ export const StepSchema = z.object({
|
|
|
21
22
|
/** Null for the closing prune, which belongs to no row. */
|
|
22
23
|
row: RowRefSchema.nullable(),
|
|
23
24
|
action: z.enum([
|
|
25
|
+
'verify-remote',
|
|
24
26
|
'unlock-worktree',
|
|
25
27
|
'remove-worktree',
|
|
26
28
|
'delete-local',
|
|
@@ -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;
|