@osdlc/repo-cleaner 0.17.2 → 0.18.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 +18 -0
- package/README.md +497 -329
- package/dist/branches.d.ts +9 -36
- package/dist/branches.js +92 -172
- package/dist/clean.d.ts +162 -0
- package/dist/clean.js +755 -0
- package/dist/cli.d.ts +12 -16
- package/dist/cli.js +340 -479
- package/dist/hooks.d.ts +8 -12
- package/dist/hooks.js +2 -2
- package/dist/index.d.ts +18 -25
- package/dist/index.js +16 -23
- package/dist/landing.d.ts +3 -3
- package/dist/landing.js +7 -7
- package/dist/plan.d.ts +76 -70
- package/dist/plan.js +82 -77
- package/dist/receipt.d.ts +44 -57
- package/dist/receipt.js +46 -28
- package/dist/reference.d.ts +12 -0
- package/dist/reference.js +148 -0
- package/dist/remote-mirror.d.ts +8 -0
- package/dist/remote-mirror.js +18 -0
- package/dist/render.d.ts +61 -61
- package/dist/render.js +195 -254
- package/dist/resolve.js +1 -9
- package/dist/status-line.d.ts +1 -1
- package/dist/status-line.js +5 -5
- package/dist/step-board.d.ts +1 -1
- package/dist/step-board.js +2 -5
- package/dist/submodules.d.ts +120 -0
- package/dist/submodules.js +292 -0
- package/dist/sweep.d.ts +97 -67
- package/dist/sweep.js +272 -118
- package/dist/types.d.ts +276 -105
- package/dist/types.js +100 -54
- package/dist/util.d.ts +7 -0
- package/dist/util.js +20 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worktrees.d.ts +21 -37
- package/dist/worktrees.js +198 -165
- package/package.json +4 -4
- package/dist/apply.d.ts +0 -131
- package/dist/apply.js +0 -541
package/dist/cli.js
CHANGED
|
@@ -1,150 +1,129 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `repo-cleaner` — what has been merged, and what can go away.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* repo-cleaner [target...] the report; deletes nothing
|
|
5
|
+
* repo-cleaner plan save the sweep's rows and their evidence, to run later
|
|
6
|
+
* repo-cleaner clean check again, show the steps, ask, delete
|
|
7
|
+
* repo-cleaner cache list or remove the clones kept for remote URL targets
|
|
5
8
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* and `cache`, which lists and removes the clones `--remote-url` keeps.
|
|
12
|
-
*
|
|
13
|
-
* `apply` works from a saved plan (`--plan`) or sweeps first. Either way it
|
|
14
|
-
* re-checks every row, prints the steps it would run and asks before deleting;
|
|
15
|
-
* `--yes` skips the question. Without a terminal and without `--yes` it
|
|
16
|
-
* refuses rather than deleting. Scope is opt-in: the sweep covers the
|
|
17
|
-
* repository you are standing in unless a path says otherwise.
|
|
9
|
+
* Every command takes `--help`, and `--help --extended` for the full
|
|
10
|
+
* reference: what each option means, the words the report uses, and which
|
|
11
|
+
* combinations are refused. A combination that contradicts itself, or one
|
|
12
|
+
* with an option that would have no effect, is refused with exit 2 before
|
|
13
|
+
* anything is swept.
|
|
18
14
|
*
|
|
19
15
|
* Exit codes:
|
|
20
|
-
* 0 swept; or
|
|
16
|
+
* 0 swept; or clean ran, was declined, or had nothing to do
|
|
21
17
|
* 1 failed against the state it found
|
|
22
18
|
* 2 cannot run as typed
|
|
23
|
-
* 10
|
|
24
|
-
* 11
|
|
19
|
+
* 10 no target named a repository
|
|
20
|
+
* 11 clean stopped at a failed step; the receipt says which
|
|
25
21
|
*/
|
|
26
|
-
import {
|
|
27
|
-
import { basename, dirname, join, resolve
|
|
22
|
+
import { readSync, rmSync } from 'node:fs';
|
|
23
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
28
24
|
import { isatty } from 'node:tty';
|
|
29
|
-
import { EXIT, askYesNo, canPrompt, defineCli,
|
|
30
|
-
import { Git } from '@osdlc/typed-git';
|
|
25
|
+
import { EXIT, askYesNo, canPrompt, defineCli, } from '@osdlc/cli-tool';
|
|
31
26
|
import { detectColorLevel, stripControls } from '@osdlc/ux';
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import { cachedMirrorOf, ensureMirror, listMirrors, mirrorProblem, redactUrl, } from "./remote-mirror.js";
|
|
35
|
-
import { ApplyArgumentError, applyPlan, parseGrant, parseRowRef } from "./apply.js";
|
|
36
|
-
import { PLAN_VERSION, fileStamp, parsePlan, planRepo, readPlan, writePlan, } from "./plan.js";
|
|
27
|
+
import { check, checkApproval, parseAllow, run } from "./clean.js";
|
|
28
|
+
import { fileStamp, parsePlan, plan as planOf, readPlan, writePlan } from "./plan.js";
|
|
37
29
|
import { PROVIDER_CHOICES } from "./providers/index.js";
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
30
|
+
import { REFERENCE } from "./reference.js";
|
|
31
|
+
import { cachedMirrorOf, defaultCacheDir, listMirrors, redactUrl, } from "./remote-mirror.js";
|
|
32
|
+
import { PLAIN, TERMINAL_STYLE, renderCommands, renderJson, renderLateDrops, renderPreview, renderReceipt, renderStepFailure, renderSummary, renderText, renderTotals, rowsActedOn, summarizeRun, } from "./render.js";
|
|
40
33
|
import { NO_STATUS_LINE, describeStep, describeSweep, statusLine, } from "./status-line.js";
|
|
41
34
|
import { stepBoard } from "./step-board.js";
|
|
42
|
-
import {
|
|
35
|
+
import { sweep as sweepTargets, sweptWith } from "./sweep.js";
|
|
36
|
+
import { KINDS, RISKS, RepoCleanerError } from "./types.js";
|
|
43
37
|
const EXIT_NOTHING_TO_SWEEP = 10;
|
|
44
38
|
const EXIT_STEP_FAILED = 11;
|
|
45
39
|
const NAME = 'repo-cleaner';
|
|
46
|
-
/**
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
remotes: 'remote-branches',
|
|
40
|
+
/** `--help` and its modifier: commander must not own `--help`, or it would never see `--extended`. */
|
|
41
|
+
const HELP_FLAGS = {
|
|
42
|
+
help: { kind: 'boolean', short: 'h', help: 'show this message' },
|
|
43
|
+
extended: { kind: 'boolean', help: 'with --help: the full reference' },
|
|
51
44
|
};
|
|
52
|
-
/** The flags every
|
|
45
|
+
/** The flags every command that sweeps takes. */
|
|
53
46
|
const SWEEP_FLAGS = {
|
|
47
|
+
remote: {
|
|
48
|
+
kind: 'boolean',
|
|
49
|
+
help: 'also judge remote branches: fetches first and looks up pull requests; slower',
|
|
50
|
+
},
|
|
51
|
+
offline: { kind: 'boolean', help: 'with --remote: do not fetch' },
|
|
54
52
|
only: {
|
|
55
53
|
kind: 'list',
|
|
56
|
-
valueName: '
|
|
57
|
-
help: `
|
|
58
|
-
},
|
|
59
|
-
remotes: {
|
|
60
|
-
kind: 'boolean',
|
|
61
|
-
help: 'also sweep remote branches, which is slower: each one is compared with the default branch',
|
|
54
|
+
valueName: 'kinds',
|
|
55
|
+
help: `only these kinds of row: ${KINDS.join(', ')} (default: worktree,branch; remote too with --remote)`,
|
|
62
56
|
},
|
|
63
57
|
branch: {
|
|
64
58
|
kind: 'string',
|
|
65
59
|
valueName: 'name|HEAD',
|
|
66
|
-
help:
|
|
60
|
+
help: "only one branch's rows: its worktree, local branch, and remote branch with --remote",
|
|
67
61
|
},
|
|
68
|
-
|
|
62
|
+
against: {
|
|
69
63
|
kind: 'string',
|
|
70
|
-
valueName: '
|
|
71
|
-
help: 'the remote whose default branch
|
|
72
|
-
},
|
|
73
|
-
remoteUrl: {
|
|
74
|
-
kind: 'list',
|
|
75
|
-
valueName: 'url',
|
|
76
|
-
default: [],
|
|
77
|
-
commaSeparated: false,
|
|
78
|
-
help: 'sweep the branches of this remote with no checkout of your own, through a clone kept in the cache directory; repeatable',
|
|
64
|
+
valueName: 'remote',
|
|
65
|
+
help: 'the remote whose default branch counts as merged into (default: origin)',
|
|
79
66
|
},
|
|
80
67
|
provider: {
|
|
81
68
|
kind: 'enum',
|
|
82
69
|
choices: PROVIDER_CHOICES,
|
|
83
70
|
valueName: 'name',
|
|
84
|
-
help: 'with --
|
|
85
|
-
},
|
|
86
|
-
fetch: {
|
|
87
|
-
kind: 'boolean',
|
|
88
|
-
negatable: true,
|
|
89
|
-
help: 'with --remotes: classify on the remote-tracking refs already on disk (without --remotes nothing is fetched)',
|
|
90
|
-
},
|
|
91
|
-
progress: {
|
|
92
|
-
kind: 'boolean',
|
|
93
|
-
negatable: true,
|
|
94
|
-
help: 'no status line on stderr (drawn only when stderr is a terminal)',
|
|
71
|
+
help: 'with --remote: where pull requests come from',
|
|
95
72
|
},
|
|
73
|
+
progress: { kind: 'boolean', negatable: true, help: 'no progress line on stderr' },
|
|
96
74
|
};
|
|
97
|
-
|
|
75
|
+
/** A repeatable flag that takes a row (or `risk[=row]`) each time, never split on commas. */
|
|
76
|
+
const rowList = (valueName, help) => ({
|
|
77
|
+
kind: 'list',
|
|
78
|
+
valueName,
|
|
79
|
+
default: [],
|
|
80
|
+
commaSeparated: false,
|
|
81
|
+
help,
|
|
82
|
+
});
|
|
83
|
+
const TARGETS = [
|
|
98
84
|
{
|
|
99
|
-
name: '
|
|
85
|
+
name: 'target',
|
|
100
86
|
variadic: true,
|
|
101
|
-
help: 'a checkout,
|
|
87
|
+
help: 'a checkout, a directory of checkouts, or a remote URL (default: the checkout you are in)',
|
|
102
88
|
},
|
|
103
89
|
];
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
choices: ['text', 'json'],
|
|
107
|
-
valueName: 'form',
|
|
108
|
-
default: 'text',
|
|
109
|
-
help: 'report format',
|
|
110
|
-
};
|
|
111
|
-
const statusCli = () => defineCli({
|
|
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.`;
|
|
91
|
+
const reportCli = () => defineCli({
|
|
112
92
|
name: NAME,
|
|
113
|
-
summary: '
|
|
114
|
-
'
|
|
115
|
-
'each
|
|
116
|
-
|
|
93
|
+
summary: 'Say which worktrees and local branches (remote branches too, with --remote) are merged ' +
|
|
94
|
+
'into the default branch and can go, with the evidence and the command that would delete ' +
|
|
95
|
+
'each. Deletes nothing.',
|
|
96
|
+
autoHelp: false,
|
|
97
|
+
positionals: TARGETS,
|
|
117
98
|
flags: {
|
|
118
99
|
...SWEEP_FLAGS,
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
100
|
+
all: { kind: 'boolean', help: 'also show keep rows (with --format text)' },
|
|
101
|
+
format: {
|
|
102
|
+
kind: 'enum',
|
|
103
|
+
choices: ['text', 'json', 'summary', 'commands'],
|
|
104
|
+
valueName: 'form',
|
|
105
|
+
default: 'text',
|
|
106
|
+
help: 'text (the report), json (everything), summary (counts), commands (only the safe commands)',
|
|
107
|
+
},
|
|
108
|
+
...HELP_FLAGS,
|
|
122
109
|
},
|
|
123
|
-
|
|
124
|
-
${NAME} [
|
|
125
|
-
${NAME}
|
|
126
|
-
${NAME}
|
|
127
|
-
${NAME}
|
|
128
|
-
${NAME}
|
|
129
|
-
${NAME} dev run ${NAME} from a development checkout
|
|
110
|
+
shortEpilogue: `commands:
|
|
111
|
+
${NAME} [target...] this report (the default)
|
|
112
|
+
${NAME} plan save the sweep's rows and their evidence, to run later
|
|
113
|
+
${NAME} clean check again, show the steps, ask, delete
|
|
114
|
+
${NAME} cache list or remove the clones kept for remote URL targets
|
|
115
|
+
${NAME} dev run ${NAME} from a development checkout
|
|
130
116
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const summaryCli = () => defineCli({
|
|
135
|
-
name: `${NAME} summary`,
|
|
136
|
-
summary: 'Count local branches and worktrees (and remote branches with --remotes) per repository: how many there are, ' +
|
|
137
|
-
'how many are merged into the default branch, and how many would be deleted, pruned, reviewed or kept.',
|
|
138
|
-
positionals: PATHS,
|
|
139
|
-
flags: { ...SWEEP_FLAGS, output: OUTPUT },
|
|
140
|
-
epilogue: 'merged: the work is on the default branch. Rows kept for structure (the default ' +
|
|
141
|
-
'branch, the primary worktree) are counted in total but not judged merged.',
|
|
117
|
+
A checkout directory named like a command is swept as ./<name>.
|
|
118
|
+
${MORE}`,
|
|
119
|
+
epilogue: REFERENCE.report,
|
|
142
120
|
});
|
|
143
121
|
const planCli = () => defineCli({
|
|
144
122
|
name: `${NAME} plan`,
|
|
145
|
-
summary: 'Sweep, and
|
|
146
|
-
'
|
|
147
|
-
|
|
123
|
+
summary: 'Sweep, and save every row, each with a fingerprint of the evidence ' +
|
|
124
|
+
'behind its verdict, so `clean --plan` can run it later and drop what moved since.',
|
|
125
|
+
autoHelp: false,
|
|
126
|
+
positionals: TARGETS,
|
|
148
127
|
flags: {
|
|
149
128
|
...SWEEP_FLAGS,
|
|
150
129
|
out: {
|
|
@@ -154,77 +133,72 @@ const planCli = () => defineCli({
|
|
|
154
133
|
default: '-',
|
|
155
134
|
help: 'write the plan here; - is stdout (the default)',
|
|
156
135
|
},
|
|
136
|
+
...HELP_FLAGS,
|
|
157
137
|
},
|
|
158
|
-
|
|
138
|
+
shortEpilogue: `The totals line goes to stderr, so stdout carries only the plan.\n${MORE}`,
|
|
139
|
+
epilogue: REFERENCE.plan,
|
|
159
140
|
});
|
|
160
|
-
const
|
|
161
|
-
name: `${NAME}
|
|
162
|
-
summary: 'Delete what
|
|
163
|
-
'
|
|
164
|
-
|
|
141
|
+
const cleanCli = () => defineCli({
|
|
142
|
+
name: `${NAME} clean`,
|
|
143
|
+
summary: 'Delete what the sweep (or a saved plan) found: check each row again, show the steps, ask, ' +
|
|
144
|
+
'then delete, compare-and-swap, verifying each step. Without --pick, every row judged ' +
|
|
145
|
+
'delete runs.',
|
|
146
|
+
autoHelp: false,
|
|
147
|
+
positionals: TARGETS,
|
|
165
148
|
flags: {
|
|
166
149
|
...SWEEP_FLAGS,
|
|
150
|
+
pick: rowList('row', 'work on exactly these rows; a review row runs only when picked; repeatable'),
|
|
151
|
+
spare: rowList('row', 'leave this row alone; a spared remote branch that moved stops the run; repeatable'),
|
|
152
|
+
allow: rowList('risk[=row]', 'accept a risk for every row, or for one row; repeatable'),
|
|
167
153
|
plan: {
|
|
168
154
|
kind: 'string',
|
|
169
155
|
valueName: 'file',
|
|
170
|
-
help: '
|
|
171
|
-
},
|
|
172
|
-
row: {
|
|
173
|
-
kind: 'list',
|
|
174
|
-
valueName: 'category:id',
|
|
175
|
-
default: [],
|
|
176
|
-
commaSeparated: false,
|
|
177
|
-
help: 'apply only this row, e.g. local_branch:feat/x; repeatable (default: every row)',
|
|
178
|
-
},
|
|
179
|
-
keep: {
|
|
180
|
-
kind: 'list',
|
|
181
|
-
valueName: 'remote_branch:id',
|
|
182
|
-
default: [],
|
|
183
|
-
commaSeparated: false,
|
|
184
|
-
help: 'keep this remote branch, but refuse the run if its tip moved since the plan, e.g. remote_branch:origin/feat/x; repeatable',
|
|
185
|
-
},
|
|
186
|
-
allow: {
|
|
187
|
-
kind: 'list',
|
|
188
|
-
valueName: 'risk[=category:id]',
|
|
189
|
-
default: [],
|
|
190
|
-
commaSeparated: false,
|
|
191
|
-
help: 'accept a risk for every row, or for one row; repeatable',
|
|
192
|
-
},
|
|
193
|
-
repoRoot: {
|
|
194
|
-
kind: 'string',
|
|
195
|
-
valueName: 'path',
|
|
196
|
-
help: 'with a plan of several repositories: apply only the one rooted here',
|
|
156
|
+
help: 'run this plan (- reads stdin) instead of sweeping',
|
|
197
157
|
},
|
|
158
|
+
yes: { kind: 'boolean', short: 'y', help: 'delete without asking' },
|
|
159
|
+
dryRun: { kind: 'boolean', help: 'check and show the steps; delete nothing' },
|
|
198
160
|
receipt: {
|
|
199
161
|
kind: 'string',
|
|
200
162
|
valueName: 'file',
|
|
201
163
|
help: 'write the receipt here (default: beside a --plan file; none otherwise)',
|
|
202
164
|
},
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
165
|
+
format: {
|
|
166
|
+
kind: 'enum',
|
|
167
|
+
choices: ['text', 'json'],
|
|
168
|
+
valueName: 'form',
|
|
169
|
+
default: 'text',
|
|
170
|
+
help: 'how the steps and the outcome are shown',
|
|
171
|
+
},
|
|
172
|
+
...HELP_FLAGS,
|
|
206
173
|
},
|
|
207
|
-
|
|
208
|
-
|
|
174
|
+
shortEpilogue: `rows: worktree:<path>, branch:<name>, remote:<remote>/<name>, or a bare name
|
|
175
|
+
risks: ${RISKS.join(', ')}
|
|
176
|
+
A row that carries a risk you did not --allow is skipped, never deleted.
|
|
177
|
+
${MORE}`,
|
|
178
|
+
epilogue: REFERENCE.clean,
|
|
209
179
|
});
|
|
210
180
|
const cacheCli = () => defineCli({
|
|
211
181
|
name: `${NAME} cache`,
|
|
212
|
-
summary: 'List the clones
|
|
213
|
-
'
|
|
182
|
+
summary: 'List the clones kept for remote URL targets, or remove them. A removed clone is cloned ' +
|
|
183
|
+
'again by the next sweep of its URL.',
|
|
184
|
+
autoHelp: false,
|
|
214
185
|
positionals: [
|
|
215
186
|
{ name: 'verb', help: 'list (the default) or remove' },
|
|
216
|
-
{
|
|
217
|
-
name: 'url',
|
|
218
|
-
variadic: true,
|
|
219
|
-
help: 'with remove: the URL whose clone to remove, as given to --remote-url',
|
|
220
|
-
},
|
|
187
|
+
{ name: 'url', variadic: true, help: 'with remove: the URL whose clone to remove' },
|
|
221
188
|
],
|
|
222
189
|
flags: {
|
|
223
190
|
all: { kind: 'boolean', help: 'with remove: remove every clone' },
|
|
224
|
-
|
|
191
|
+
format: {
|
|
192
|
+
kind: 'enum',
|
|
193
|
+
choices: ['text', 'json'],
|
|
194
|
+
valueName: 'form',
|
|
195
|
+
default: 'text',
|
|
196
|
+
help: 'how the clones are shown',
|
|
197
|
+
},
|
|
198
|
+
...HELP_FLAGS,
|
|
225
199
|
},
|
|
226
|
-
|
|
227
|
-
|
|
200
|
+
shortEpilogue: `A plan that names a removed clone cannot run; sweep again instead.\n${MORE}`,
|
|
201
|
+
epilogue: REFERENCE.cache,
|
|
228
202
|
});
|
|
229
203
|
const terminalConfirm = (question) => (canPrompt() ? askYesNo(question) : null);
|
|
230
204
|
const PAUSE = new Int32Array(new SharedArrayBuffer(4));
|
|
@@ -256,211 +230,145 @@ function usageError(ctx, name, message) {
|
|
|
256
230
|
ctx.io.stderr(`${name}: ${message}\n`);
|
|
257
231
|
return EXIT.usage;
|
|
258
232
|
}
|
|
259
|
-
function failure(ctx, err) {
|
|
260
|
-
ctx.io.stderr(`${
|
|
233
|
+
function failure(ctx, name, err) {
|
|
234
|
+
ctx.io.stderr(`${name}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
261
235
|
return EXIT.error;
|
|
262
236
|
}
|
|
263
|
-
/**
|
|
237
|
+
/** A refusal as its exit code: 2 for what was typed, 1 for the state found. */
|
|
238
|
+
function refusal(ctx, name, err) {
|
|
239
|
+
if (err instanceof RepoCleanerError && (err.code === 'conflict' || err.code === 'bad-input')) {
|
|
240
|
+
return usageError(ctx, name, err.message);
|
|
241
|
+
}
|
|
242
|
+
return failure(ctx, name, err);
|
|
243
|
+
}
|
|
244
|
+
/** Parse, and answer `--help` (and `--help --extended`) itself. */
|
|
245
|
+
function parseWith(cli, name, argv, ctx) {
|
|
246
|
+
const parsed = cli.parse(argv, ctx.io);
|
|
247
|
+
if (parsed.status === 'help')
|
|
248
|
+
return { status: 'done', code: EXIT.ok };
|
|
249
|
+
if (parsed.status === 'error') {
|
|
250
|
+
return { status: 'done', code: usageError(ctx, name, parsed.message) };
|
|
251
|
+
}
|
|
252
|
+
const v = parsed.values;
|
|
253
|
+
if (v.help) {
|
|
254
|
+
ctx.io.stdout(cli.help({ all: v.extended }));
|
|
255
|
+
return { status: 'done', code: EXIT.ok };
|
|
256
|
+
}
|
|
257
|
+
if (v.extended) {
|
|
258
|
+
return { status: 'done', code: usageError(ctx, name, '--extended goes with --help') };
|
|
259
|
+
}
|
|
260
|
+
return parsed;
|
|
261
|
+
}
|
|
262
|
+
/** The sweep options as typed, for {@link sweptWith} and {@link sweepTargets}. */
|
|
263
|
+
function sweepOptions(v, ctx) {
|
|
264
|
+
const only = v.only?.filter((s) => s !== '');
|
|
265
|
+
return {
|
|
266
|
+
cwd: ctx.cwd,
|
|
267
|
+
remote: v.remote,
|
|
268
|
+
offline: v.offline,
|
|
269
|
+
...(only === undefined ? {} : { only: only }),
|
|
270
|
+
...(v.branch === undefined ? {} : { branch: v.branch }),
|
|
271
|
+
...(v.against === undefined ? {} : { against: v.against }),
|
|
272
|
+
...(v.provider === undefined ? {} : { provider: v.provider }),
|
|
273
|
+
cacheDir: defaultCacheDir(ctx.env),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/** Color for output on `stream`, when it is a terminal that takes color. */
|
|
264
277
|
function styleFor(ctx, stream) {
|
|
265
278
|
const level = detectColorLevel({ env: ctx.env, isTTY: ctx.terminal?.[stream] === true });
|
|
266
279
|
return level === 'none' ? PLAIN : TERMINAL_STYLE;
|
|
267
280
|
}
|
|
268
281
|
/** The note under a text report that left remote branches out, or '' when it did not. */
|
|
269
|
-
function
|
|
270
|
-
if (
|
|
282
|
+
function remoteNote(s, style) {
|
|
283
|
+
if (s.options.only.includes('remote'))
|
|
271
284
|
return '';
|
|
272
|
-
if (!
|
|
285
|
+
if (!s.repos.some((r) => r.repo?.remote != null))
|
|
273
286
|
return '';
|
|
274
|
-
return (style.muted('Local only: nothing fetched, no pull requests looked up, remote branches not checked; add --
|
|
275
|
-
}
|
|
276
|
-
/** Where `--remote-url` keeps its clones. */
|
|
277
|
-
function mirrorCache(ctx) {
|
|
278
|
-
return systemDirs(VENDOR_DIRS, { env: ctx.env }).standalone(NAME).cache().app('remotes').path;
|
|
287
|
+
return (style.muted('Local only: nothing fetched, no pull requests looked up, remote branches not checked; add --remote for all three.') + '\n');
|
|
279
288
|
}
|
|
280
|
-
/**
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
scope.push('remote-branches');
|
|
286
|
-
return scope;
|
|
287
|
-
}
|
|
288
|
-
/** Validate the sweep flags and run the sweep, or answer with the exit code. */
|
|
289
|
-
function sweep(name, v, paths, ctx, status) {
|
|
290
|
-
const only = v.only?.filter((s) => s !== '');
|
|
291
|
-
const unknown = (only ?? []).filter((s) => !(s in ONLY));
|
|
292
|
-
if (unknown.length > 0) {
|
|
293
|
-
const message = `unknown --only value(s): ${unknown.join(', ')}`;
|
|
294
|
-
return { kind: 'exit', code: usageError(ctx, name, message) };
|
|
295
|
-
}
|
|
296
|
-
if (v.branch !== undefined && paths.length > 0) {
|
|
297
|
-
const message = '--branch sweeps this repository only; drop the paths';
|
|
298
|
-
return { kind: 'exit', code: usageError(ctx, name, message) };
|
|
299
|
-
}
|
|
300
|
-
// An empty URL is refused, not dropped: `--remote-url "$UNSET"` must not
|
|
301
|
-
// fall back to sweeping the checkout you are standing in.
|
|
302
|
-
if (v.remoteUrl.some((u) => u.trim() === '')) {
|
|
303
|
-
return {
|
|
304
|
-
kind: 'exit',
|
|
305
|
-
code: usageError(ctx, name, '--remote-url needs a URL; got an empty one'),
|
|
306
|
-
};
|
|
307
|
-
}
|
|
308
|
-
const urls = v.remoteUrl;
|
|
309
|
-
if (urls.length > 0) {
|
|
310
|
-
const conflict = paths.length > 0
|
|
311
|
-
? '--remote-url sweeps a remote, not a checkout; drop the paths'
|
|
312
|
-
: v.branch !== undefined
|
|
313
|
-
? '--branch reads the checkout you are in; it does not combine with --remote-url'
|
|
314
|
-
: (only ?? []).some((s) => s !== 'remotes')
|
|
315
|
-
? '--remote-url has remote branches only; drop --only or pass --only remotes'
|
|
316
|
-
: null;
|
|
317
|
-
if (conflict !== null)
|
|
318
|
-
return { kind: 'exit', code: usageError(ctx, name, conflict) };
|
|
319
|
-
}
|
|
320
|
-
let branch;
|
|
321
|
-
try {
|
|
322
|
-
branch = v.branch === undefined ? undefined : resolveBranch(v.branch, new Git(ctx.cwd));
|
|
323
|
-
}
|
|
324
|
-
catch (err) {
|
|
325
|
-
return { kind: 'exit', code: failure(ctx, err) };
|
|
326
|
-
}
|
|
327
|
-
const scope = urls.length > 0 ? ['remote-branches'] : sweepScope(only, v.remotes);
|
|
328
|
-
const online = scope.includes('remote-branches');
|
|
329
|
-
const options = {
|
|
330
|
-
baseDir: ctx.cwd,
|
|
331
|
-
cwd: ctx.cwd,
|
|
332
|
-
// Local by default means offline: the fetch and the pull-request lookup go with remote branches.
|
|
333
|
-
noProvider: !online || v.provider === 'none',
|
|
334
|
-
fetch: online && v.fetch,
|
|
335
|
-
provider: v.provider,
|
|
336
|
-
branch,
|
|
337
|
-
// Mid-walk, while the wait is happening — the summary comes too late to
|
|
289
|
+
/** Sweep, or answer with the exit code. */
|
|
290
|
+
function doSweep(name, v, targets, ctx, status) {
|
|
291
|
+
const opts = {
|
|
292
|
+
...sweepOptions(v, ctx),
|
|
293
|
+
// Mid-walk, while the wait is happening: the totals come too late to
|
|
338
294
|
// explain why the tool appeared to hang.
|
|
339
295
|
onWarn: (message) => ctx.io.stderr(`${NAME}: ${message}\n`),
|
|
340
296
|
...(v.progress ? { onProgress: (p) => status.update(describeSweep(p)) } : {}),
|
|
341
|
-
scope,
|
|
342
|
-
...(v.measureRemote === undefined ? {} : { remote: v.measureRemote }),
|
|
343
297
|
};
|
|
344
|
-
let entries = paths.length > 0 ? [...paths] : [ctx.cwd];
|
|
345
|
-
if (urls.length > 0) {
|
|
346
|
-
const cache = mirrorCache(ctx);
|
|
347
|
-
try {
|
|
348
|
-
entries = urls.map((url, i) => {
|
|
349
|
-
if (v.progress)
|
|
350
|
-
status.update(`${NAME}: cloning remote ${i + 1}/${urls.length}`);
|
|
351
|
-
return ensureMirror(url, cache);
|
|
352
|
-
});
|
|
353
|
-
}
|
|
354
|
-
catch (err) {
|
|
355
|
-
status.clear();
|
|
356
|
-
return { kind: 'exit', code: failure(ctx, err) };
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
298
|
if (v.progress)
|
|
360
299
|
status.update(`${NAME}: finding repositories`);
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
const err = new Error(`--branch sweeps one repository; ${result.repos.length} found under ${ctx.cwd}`);
|
|
365
|
-
return { kind: 'exit', code: failure(ctx, err) };
|
|
300
|
+
let result;
|
|
301
|
+
try {
|
|
302
|
+
result = sweepTargets(targets, opts);
|
|
366
303
|
}
|
|
367
|
-
|
|
304
|
+
catch (err) {
|
|
305
|
+
status.clear();
|
|
306
|
+
return { kind: 'exit', code: refusal(ctx, name, err) };
|
|
307
|
+
}
|
|
308
|
+
status.clear();
|
|
309
|
+
const refused = v.branch === undefined ? null : (result.repos[0]?.error ?? null);
|
|
368
310
|
if (refused !== null)
|
|
369
|
-
return { kind: 'exit', code: failure(ctx, new Error(refused)) };
|
|
311
|
+
return { kind: 'exit', code: failure(ctx, name, new Error(refused)) };
|
|
370
312
|
if (result.repos.length === 0)
|
|
371
|
-
return { kind: 'nothing', result };
|
|
372
|
-
return { kind: 'ok',
|
|
313
|
+
return { kind: 'nothing', sweep: result };
|
|
314
|
+
return { kind: 'ok', sweep: result };
|
|
373
315
|
}
|
|
374
316
|
/**
|
|
375
317
|
* Name each repository the sweep could not read, on stderr. True when there
|
|
376
|
-
* was one: a
|
|
377
|
-
* off as an empty one.
|
|
318
|
+
* was one: a command that acts on the sweep must not pass an unread
|
|
319
|
+
* repository off as an empty one.
|
|
378
320
|
*/
|
|
379
|
-
function reportUnreadable(ctx, name,
|
|
380
|
-
const failed =
|
|
321
|
+
function reportUnreadable(ctx, name, s) {
|
|
322
|
+
const failed = s.repos.filter((r) => r.error !== null);
|
|
381
323
|
for (const r of failed)
|
|
382
324
|
ctx.io.stderr(`${name}: ${r.repo?.root ?? r.path}: ${r.error}\n`);
|
|
383
325
|
return failed.length > 0;
|
|
384
326
|
}
|
|
385
|
-
function
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
provider: swept.values.provider,
|
|
390
|
-
branch: swept.branch ?? null,
|
|
391
|
-
};
|
|
392
|
-
return {
|
|
393
|
-
version: PLAN_VERSION,
|
|
394
|
-
created_at: now.toISOString(),
|
|
395
|
-
repos: swept.result.repos.flatMap((s) => s.repo === null ? [] : [planRepo(s.repo.root, { ...opts, fetch: s.repo.fetched }, s.items)]),
|
|
396
|
-
};
|
|
397
|
-
}
|
|
398
|
-
function runStatus(argv, ctx, status) {
|
|
399
|
-
const parsed = statusCli().parse(argv, ctx.io);
|
|
400
|
-
if (parsed.status === 'help')
|
|
401
|
-
return EXIT.ok;
|
|
402
|
-
if (parsed.status === 'error')
|
|
403
|
-
return usageError(ctx, NAME, parsed.message);
|
|
327
|
+
function runReport(argv, ctx, status) {
|
|
328
|
+
const parsed = parseWith(reportCli(), NAME, argv, ctx);
|
|
329
|
+
if (parsed.status === 'done')
|
|
330
|
+
return parsed.code;
|
|
404
331
|
const v = parsed.values;
|
|
332
|
+
if (v.all && v.format !== 'text') {
|
|
333
|
+
return usageError(ctx, NAME, `--all has no effect with --format ${v.format}: it shows keep rows in the text report`);
|
|
334
|
+
}
|
|
405
335
|
const style = styleFor(ctx, 'stdout');
|
|
406
|
-
const
|
|
407
|
-
const swept = sweep(NAME, v, parsed.positionals, ctx, status);
|
|
336
|
+
const swept = doSweep(NAME, v, parsed.positionals, ctx, status);
|
|
408
337
|
if (swept.kind === 'exit')
|
|
409
338
|
return swept.code;
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
339
|
+
const s = swept.sweep;
|
|
340
|
+
if (v.format === 'json')
|
|
341
|
+
ctx.io.stdout(renderJson(s));
|
|
342
|
+
else if (v.format === 'summary')
|
|
343
|
+
ctx.io.stdout(renderSummary(s, style) + remoteNote(s, style));
|
|
344
|
+
else if (v.format === 'commands') {
|
|
345
|
+
const commands = renderCommands(s);
|
|
416
346
|
if (commands.length > 0)
|
|
417
347
|
ctx.io.stdout(commands.join('\n') + '\n');
|
|
418
348
|
}
|
|
419
349
|
else {
|
|
420
|
-
ctx.io.stdout(
|
|
421
|
-
if (v.output === 'text')
|
|
422
|
-
ctx.io.stdout(remotesNote(swept, style));
|
|
350
|
+
ctx.io.stdout(renderText(s, { all: v.all, style }) + remoteNote(s, style));
|
|
423
351
|
}
|
|
424
|
-
return EXIT.ok;
|
|
425
|
-
}
|
|
426
|
-
function runSummary(argv, ctx, status) {
|
|
427
|
-
const name = `${NAME} summary`;
|
|
428
|
-
const parsed = summaryCli().parse(argv, ctx.io);
|
|
429
|
-
if (parsed.status === 'help')
|
|
430
|
-
return EXIT.ok;
|
|
431
|
-
if (parsed.status === 'error')
|
|
432
|
-
return usageError(ctx, name, parsed.message);
|
|
433
|
-
const v = parsed.values;
|
|
434
|
-
const style = styleFor(ctx, 'stdout');
|
|
435
|
-
const render = (r) => v.output === 'json' ? renderCountsJson(r) : renderCountsText(r, style);
|
|
436
|
-
const swept = sweep(name, v, parsed.positionals, ctx, status);
|
|
437
|
-
if (swept.kind === 'exit')
|
|
438
|
-
return swept.code;
|
|
439
|
-
ctx.io.stdout(render(swept.result));
|
|
440
|
-
if (v.output === 'text')
|
|
441
|
-
ctx.io.stdout(remotesNote(swept, style));
|
|
442
352
|
return swept.kind === 'nothing' ? EXIT_NOTHING_TO_SWEEP : EXIT.ok;
|
|
443
353
|
}
|
|
444
354
|
function runPlan(argv, ctx, status) {
|
|
445
355
|
const name = `${NAME} plan`;
|
|
446
|
-
const parsed = planCli()
|
|
447
|
-
if (parsed.status === '
|
|
448
|
-
return
|
|
449
|
-
if (parsed.status === 'error')
|
|
450
|
-
return usageError(ctx, name, parsed.message);
|
|
356
|
+
const parsed = parseWith(planCli(), name, argv, ctx);
|
|
357
|
+
if (parsed.status === 'done')
|
|
358
|
+
return parsed.code;
|
|
451
359
|
const v = parsed.values;
|
|
452
|
-
const swept =
|
|
360
|
+
const swept = doSweep(name, v, parsed.positionals, ctx, status);
|
|
453
361
|
if (swept.kind === 'exit')
|
|
454
362
|
return swept.code;
|
|
455
|
-
// stdout carries only the plan, so the
|
|
363
|
+
// stdout carries only the plan, so the totals go to stderr either way.
|
|
456
364
|
if (swept.kind === 'nothing') {
|
|
457
|
-
ctx.io.stderr(
|
|
365
|
+
ctx.io.stderr(renderTotals(swept.sweep) + '\n');
|
|
458
366
|
return EXIT_NOTHING_TO_SWEEP;
|
|
459
367
|
}
|
|
460
|
-
const plan = planOf(swept
|
|
461
|
-
const unreadable = reportUnreadable(ctx, name, swept.
|
|
368
|
+
const plan = planOf(swept.sweep);
|
|
369
|
+
const unreadable = reportUnreadable(ctx, name, swept.sweep);
|
|
462
370
|
const style = styleFor(ctx, 'stderr');
|
|
463
|
-
ctx.io.stderr(
|
|
371
|
+
ctx.io.stderr(renderTotals(swept.sweep, style) + '\n' + remoteNote(swept.sweep, style));
|
|
464
372
|
if (v.out === '-') {
|
|
465
373
|
ctx.io.stdout(`${JSON.stringify(plan, null, 2)}\n`);
|
|
466
374
|
}
|
|
@@ -476,174 +384,141 @@ function receiptBeside(planPath, now) {
|
|
|
476
384
|
const stem = basename(planPath).replace(/\.json$/i, '');
|
|
477
385
|
return join(dirname(planPath), `${stem}.receipt-${fileStamp(now)}.json`);
|
|
478
386
|
}
|
|
479
|
-
const dropKey = (d) => JSON.stringify([d.repo, d.
|
|
480
|
-
/**
|
|
481
|
-
function
|
|
482
|
-
|
|
387
|
+
const dropKey = (d) => JSON.stringify([d.repo, d.row]);
|
|
388
|
+
/** The run's options that contradict each other, as a usage message, or null. */
|
|
389
|
+
function cleanConflict(v, targets) {
|
|
390
|
+
if (v.plan !== undefined) {
|
|
391
|
+
const fixed = [
|
|
392
|
+
targets.length > 0 ? 'targets' : null,
|
|
393
|
+
v.only !== undefined ? '--only' : null,
|
|
394
|
+
v.branch !== undefined ? '--branch' : null,
|
|
395
|
+
v.remote ? '--remote' : null,
|
|
396
|
+
v.offline ? '--offline' : null,
|
|
397
|
+
v.against !== undefined ? '--against' : null,
|
|
398
|
+
v.provider !== undefined ? '--provider' : null,
|
|
399
|
+
].filter((f) => f !== null);
|
|
400
|
+
if (fixed.length > 0)
|
|
401
|
+
return `--plan already fixed what is swept; drop ${fixed.join(', ')}`;
|
|
402
|
+
}
|
|
403
|
+
if (v.plan === '-' && !v.yes && !v.dryRun) {
|
|
404
|
+
return '--plan - reads the plan from stdin, so nothing can ask; pass --yes or --dry-run';
|
|
405
|
+
}
|
|
406
|
+
if (v.yes && v.dryRun)
|
|
407
|
+
return '--yes and --dry-run contradict: one deletes without asking, the other deletes nothing';
|
|
408
|
+
if (v.receipt !== undefined && v.dryRun)
|
|
409
|
+
return '--receipt has no effect with --dry-run: nothing runs to record';
|
|
410
|
+
return null;
|
|
483
411
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
const receiptPath = v.receipt === undefined ? null : resolve(ctx.cwd, v.receipt);
|
|
412
|
+
function loadPlan(name, v, targets, ctx, deps, status, now) {
|
|
413
|
+
const receiptPath = v.receipt === undefined ? undefined : resolve(ctx.cwd, v.receipt);
|
|
487
414
|
if (v.plan === undefined) {
|
|
488
|
-
const swept =
|
|
415
|
+
const swept = doSweep(name, v, targets, ctx, status);
|
|
489
416
|
if (swept.kind === 'exit')
|
|
490
417
|
return swept.code;
|
|
491
418
|
if (swept.kind === 'nothing') {
|
|
492
|
-
ctx.io.stdout(v.
|
|
419
|
+
ctx.io.stdout(v.format === 'json' ? renderJson(swept.sweep) : renderText(swept.sweep));
|
|
493
420
|
return EXIT_NOTHING_TO_SWEEP;
|
|
494
421
|
}
|
|
495
|
-
const items = new Map();
|
|
496
|
-
for (const r of swept.result.repos)
|
|
497
|
-
if (r.repo !== null)
|
|
498
|
-
items.set(r.repo.root, r.items);
|
|
499
422
|
return {
|
|
500
|
-
plan: planOf(swept, now),
|
|
423
|
+
plan: planOf(swept.sweep, now),
|
|
501
424
|
source: '(swept)',
|
|
502
425
|
receiptPath,
|
|
503
|
-
unreadable: reportUnreadable(ctx, name, swept.
|
|
504
|
-
|
|
426
|
+
unreadable: reportUnreadable(ctx, name, swept.sweep),
|
|
427
|
+
sweep: swept.sweep,
|
|
505
428
|
};
|
|
506
429
|
}
|
|
507
430
|
try {
|
|
508
431
|
if (v.plan === '-') {
|
|
509
432
|
const plan = parsePlan((deps.readStdin ?? readAllStdin)(), 'stdin');
|
|
510
|
-
return { plan, source: '(stdin)', receiptPath, unreadable: false,
|
|
433
|
+
return { plan, source: '(stdin)', receiptPath, unreadable: false, sweep: undefined };
|
|
511
434
|
}
|
|
512
435
|
const source = resolve(ctx.cwd, v.plan);
|
|
513
|
-
const plan = readPlan(source);
|
|
514
436
|
return {
|
|
515
|
-
plan,
|
|
437
|
+
plan: readPlan(source),
|
|
516
438
|
source,
|
|
517
439
|
receiptPath: receiptPath ?? receiptBeside(source, now),
|
|
518
440
|
unreadable: false,
|
|
519
|
-
|
|
441
|
+
sweep: undefined,
|
|
520
442
|
};
|
|
521
443
|
}
|
|
522
444
|
catch (err) {
|
|
523
|
-
return
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
/** The run's flags that contradict each other, as a usage message, or null. */
|
|
527
|
-
function applyConflict(v, paths) {
|
|
528
|
-
if (v.plan !== undefined) {
|
|
529
|
-
const sweepFlags = [
|
|
530
|
-
paths.length > 0 ? 'paths' : null,
|
|
531
|
-
v.only !== undefined ? '--only' : null,
|
|
532
|
-
v.branch !== undefined ? '--branch' : null,
|
|
533
|
-
v.measureRemote !== undefined ? '--measure-remote' : null,
|
|
534
|
-
v.remoteUrl.length > 0 ? '--remote-url' : null,
|
|
535
|
-
].filter((f) => f !== null);
|
|
536
|
-
if (sweepFlags.length > 0)
|
|
537
|
-
return `--plan fixes what is swept; drop ${sweepFlags.join(', ')}`;
|
|
445
|
+
return refusal(ctx, name, err);
|
|
538
446
|
}
|
|
539
|
-
if (v.plan === '-' && !v.yes && !v.dryRun) {
|
|
540
|
-
return '--plan - reads the plan from stdin, so it cannot ask; pass --yes or --dry-run';
|
|
541
|
-
}
|
|
542
|
-
return null;
|
|
543
447
|
}
|
|
544
|
-
function
|
|
545
|
-
const name = `${NAME}
|
|
546
|
-
const parsed =
|
|
547
|
-
if (parsed.status === '
|
|
548
|
-
return
|
|
549
|
-
if (parsed.status === 'error')
|
|
550
|
-
return usageError(ctx, name, parsed.message);
|
|
448
|
+
function runClean(argv, ctx, deps, status) {
|
|
449
|
+
const name = `${NAME} clean`;
|
|
450
|
+
const parsed = parseWith(cleanCli(), name, argv, ctx);
|
|
451
|
+
if (parsed.status === 'done')
|
|
452
|
+
return parsed.code;
|
|
551
453
|
const v = parsed.values;
|
|
552
|
-
const
|
|
553
|
-
let
|
|
554
|
-
let keep;
|
|
555
|
-
let grants;
|
|
454
|
+
const targets = parsed.positionals;
|
|
455
|
+
let approval;
|
|
556
456
|
try {
|
|
557
|
-
|
|
558
|
-
keep = v.keep.map(parseRowRef);
|
|
559
|
-
grants = v.allow.map(parseGrant);
|
|
457
|
+
approval = { pick: v.pick, spare: v.spare, allow: v.allow.map(parseAllow) };
|
|
560
458
|
}
|
|
561
459
|
catch (err) {
|
|
562
|
-
|
|
563
|
-
return usageError(ctx, name, err.message);
|
|
564
|
-
throw err;
|
|
460
|
+
return refusal(ctx, name, err);
|
|
565
461
|
}
|
|
566
|
-
const conflict =
|
|
462
|
+
const conflict = cleanConflict(v, targets);
|
|
567
463
|
if (conflict !== null)
|
|
568
464
|
return usageError(ctx, name, conflict);
|
|
465
|
+
// Refuse what can be refused before sweeping; a saved plan's own options
|
|
466
|
+
// are checked when the approval is resolved against it.
|
|
467
|
+
if (v.plan === undefined) {
|
|
468
|
+
try {
|
|
469
|
+
checkApproval(sweptWith(targets, sweepOptions(v, ctx)), approval);
|
|
470
|
+
}
|
|
471
|
+
catch (err) {
|
|
472
|
+
return refusal(ctx, name, err);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
569
475
|
const now = new Date();
|
|
570
|
-
const loaded = loadPlan(name, v,
|
|
476
|
+
const loaded = loadPlan(name, v, targets, ctx, deps, status, now);
|
|
571
477
|
if (typeof loaded === 'number')
|
|
572
478
|
return loaded;
|
|
573
479
|
const { plan, source, receiptPath } = loaded;
|
|
574
|
-
// A plan from `--remote-url` names a clone in the cache. Its deletes are
|
|
575
|
-
// pushed from there, so the clone must still push only where it fetches from.
|
|
576
|
-
const cacheDir = mirrorCache(ctx);
|
|
577
|
-
const cache = (existsSync(cacheDir) ? realpathSync(cacheDir) : cacheDir) + sep;
|
|
578
|
-
for (const repo of plan.repos) {
|
|
579
|
-
if (!repo.root.startsWith(cache))
|
|
580
|
-
continue;
|
|
581
|
-
const problem = mirrorProblem(repo.root);
|
|
582
|
-
if (problem !== null)
|
|
583
|
-
return failure(ctx, new Error(`${problem}; refusing to apply`));
|
|
584
|
-
}
|
|
585
480
|
const ok = () => (loaded.unreadable ? EXIT.error : EXIT.ok);
|
|
586
|
-
const repoRoot = v.repoRoot === undefined ? undefined : resolve(ctx.cwd, v.repoRoot);
|
|
587
481
|
// On a terminal the run is shown live: every step first, then each one
|
|
588
|
-
// checked off as it finishes
|
|
589
|
-
const live = v.
|
|
482
|
+
// checked off as it finishes.
|
|
483
|
+
const live = v.format === 'text' && ctx.terminal?.stdout === true;
|
|
590
484
|
const style = styleFor(ctx, 'stdout');
|
|
591
|
-
const
|
|
592
|
-
ctx.
|
|
593
|
-
? `${JSON.stringify({ receiptPath: shownPath, summary: summarizeReceipt(receipt), receipt }, null, 2)}\n`
|
|
594
|
-
: renderReceipt(receipt, shownPath, style, { steps }));
|
|
595
|
-
};
|
|
596
|
-
const planned = plan.repos.filter((r) => repoRoot === undefined || resolve(r.root) === resolve(repoRoot));
|
|
597
|
-
if (repoRoot !== undefined && planned.length === 0) {
|
|
598
|
-
return failure(ctx, new Error(`no plan repository at ${repoRoot}`));
|
|
599
|
-
}
|
|
600
|
-
if (rows.length === 0 && planned.every((r) => r.rows.length === 0)) {
|
|
601
|
-
if (v.output === 'json')
|
|
602
|
-
print(emptyReceipt(source, now, v.dryRun), null);
|
|
603
|
-
else
|
|
604
|
-
ctx.io.stdout('nothing to delete\n');
|
|
605
|
-
return ok();
|
|
606
|
-
}
|
|
607
|
-
const base = {
|
|
608
|
-
callerCwd: ctx.cwd,
|
|
609
|
-
planPath: source,
|
|
610
|
-
repoRoot,
|
|
611
|
-
keep,
|
|
612
|
-
// An implicit selection runs only safe-to-delete rows, in the preview and
|
|
613
|
-
// in the run it confirms, whose rows are passed explicitly.
|
|
614
|
-
safeOnly: rows.length === 0,
|
|
485
|
+
const shared = {
|
|
486
|
+
cwd: ctx.cwd,
|
|
615
487
|
...(v.progress
|
|
616
488
|
? {
|
|
617
|
-
|
|
618
|
-
onStep: (i, total, step) => status.update(describeStep(i, total, step)),
|
|
489
|
+
onProgress: (p) => status.update(`re-checking ${describeSweep(p)}`),
|
|
619
490
|
}
|
|
620
491
|
: {}),
|
|
621
492
|
};
|
|
622
493
|
let preview;
|
|
623
494
|
try {
|
|
624
|
-
// A sweep that just ran is the preview's re-check; the run
|
|
625
|
-
preview =
|
|
626
|
-
...
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
swept: loaded.swept,
|
|
495
|
+
// A sweep that just ran is the preview's re-check; the run sweeps again.
|
|
496
|
+
preview = check(plan, approval, {
|
|
497
|
+
...shared,
|
|
498
|
+
sweep: loaded.sweep,
|
|
499
|
+
cacheDir: defaultCacheDir(ctx.env),
|
|
630
500
|
});
|
|
631
501
|
}
|
|
632
502
|
catch (err) {
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
return failure(ctx, err);
|
|
503
|
+
status.clear();
|
|
504
|
+
return refusal(ctx, name, err);
|
|
636
505
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
if (
|
|
506
|
+
status.clear();
|
|
507
|
+
const showPreview = () => {
|
|
508
|
+
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`);
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
640
512
|
ctx.io.stdout(renderPreview(preview, style));
|
|
641
|
-
|
|
642
|
-
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
const count = rowsActedOn(summarizeRun(preview));
|
|
516
|
+
if (v.dryRun || count === 0) {
|
|
517
|
+
showPreview();
|
|
643
518
|
return ok();
|
|
644
519
|
}
|
|
645
520
|
if (!v.yes) {
|
|
646
|
-
const declined = confirm(ctx, name, preview, count, v.
|
|
521
|
+
const declined = confirm(ctx, name, preview, count, v.format === 'json', deps);
|
|
647
522
|
if (declined !== null)
|
|
648
523
|
return declined;
|
|
649
524
|
}
|
|
@@ -652,11 +527,6 @@ function runApply(argv, ctx, deps, status) {
|
|
|
652
527
|
}
|
|
653
528
|
if (live)
|
|
654
529
|
ctx.io.stdout('\n');
|
|
655
|
-
// Only what the preview showed: rows it dropped stay dropped.
|
|
656
|
-
// Pinned to their repositories: a branch name two repositories share stays two rows.
|
|
657
|
-
// A kept row's tip check is not a selection; `keep` carries it again.
|
|
658
|
-
const approved = preview.steps.flatMap((s) => s.row === null || s.action === 'verify-remote' ? [] : [{ ...s.row, repo: s.repo }]);
|
|
659
|
-
const approvedGrants = grants.filter((g) => g.row === null || approved.some((r) => r.category === g.row.category && r.id === g.row.id));
|
|
660
530
|
const size = deps.terminalSize ??
|
|
661
531
|
(() => ({
|
|
662
532
|
width: ctx.terminal?.columns || process.stdout.columns || 80,
|
|
@@ -665,11 +535,11 @@ function runApply(argv, ctx, deps, status) {
|
|
|
665
535
|
let board;
|
|
666
536
|
let receipt;
|
|
667
537
|
try {
|
|
668
|
-
receipt =
|
|
669
|
-
...
|
|
670
|
-
|
|
538
|
+
receipt = run(preview, {
|
|
539
|
+
...shared,
|
|
540
|
+
source,
|
|
671
541
|
receiptPath,
|
|
672
|
-
|
|
542
|
+
now: () => new Date(),
|
|
673
543
|
...(live
|
|
674
544
|
? {
|
|
675
545
|
// The board carries the run's progress; the status line stays clear.
|
|
@@ -682,16 +552,20 @@ function runApply(argv, ctx, deps, status) {
|
|
|
682
552
|
board?.draw(null);
|
|
683
553
|
// Here rather than after the run: a receipt that then cannot be
|
|
684
554
|
// saved must not take the step's own error with it.
|
|
685
|
-
if (step.result === 'failed')
|
|
555
|
+
if (step.result === 'failed')
|
|
686
556
|
ctx.io.stdout(`\n${renderStepFailure(step, style)}\n`);
|
|
687
|
-
}
|
|
688
557
|
},
|
|
689
558
|
}
|
|
690
|
-
:
|
|
559
|
+
: v.progress
|
|
560
|
+
? {
|
|
561
|
+
onStep: (i, total, step) => status.update(describeStep(i, total, step)),
|
|
562
|
+
}
|
|
563
|
+
: {}),
|
|
691
564
|
});
|
|
692
565
|
}
|
|
693
566
|
catch (err) {
|
|
694
|
-
|
|
567
|
+
status.clear();
|
|
568
|
+
return refusal(ctx, name, err);
|
|
695
569
|
}
|
|
696
570
|
status.clear();
|
|
697
571
|
if (live) {
|
|
@@ -700,11 +574,16 @@ function runApply(argv, ctx, deps, status) {
|
|
|
700
574
|
const seen = new Set(preview.dropped.map(dropKey));
|
|
701
575
|
const late = receipt.dropped.filter((d) => !seen.has(dropKey(d)));
|
|
702
576
|
if (late.length > 0)
|
|
703
|
-
ctx.io.stdout(`\n${
|
|
577
|
+
ctx.io.stdout(`\n${renderLateDrops(late, style)}`);
|
|
704
578
|
ctx.io.stdout('\n');
|
|
705
579
|
}
|
|
706
|
-
|
|
707
|
-
|
|
580
|
+
if (v.format === 'json') {
|
|
581
|
+
ctx.io.stdout(`${JSON.stringify({ receiptPath: receiptPath ?? null, summary: summarizeRun(receipt), receipt }, null, 2)}\n`);
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
ctx.io.stdout(renderReceipt(receipt, receiptPath ?? null, style, { steps: !live }));
|
|
585
|
+
}
|
|
586
|
+
if (receiptPath === undefined) {
|
|
708
587
|
ctx.io.stderr(`${name}: no receipt written; pass --receipt <file> to keep one\n`);
|
|
709
588
|
}
|
|
710
589
|
return receipt.status === 'failed' ? EXIT_STEP_FAILED : ok();
|
|
@@ -745,31 +624,6 @@ function cacheLine(m) {
|
|
|
745
624
|
return (stripControls(`${sizeText(m.bytes).padStart(9)} ${m.url ?? '(no origin)'} ${m.dir}${note}`) +
|
|
746
625
|
'\n');
|
|
747
626
|
}
|
|
748
|
-
function runCache(argv, ctx) {
|
|
749
|
-
const name = `${NAME} cache`;
|
|
750
|
-
const parsed = cacheCli().parse(argv, ctx.io);
|
|
751
|
-
if (parsed.status === 'help')
|
|
752
|
-
return EXIT.ok;
|
|
753
|
-
if (parsed.status === 'error')
|
|
754
|
-
return usageError(ctx, name, parsed.message);
|
|
755
|
-
const v = parsed.values;
|
|
756
|
-
const [verb = 'list', ...urls] = parsed.positionals;
|
|
757
|
-
const dir = mirrorCache(ctx);
|
|
758
|
-
const json = v.output === 'json';
|
|
759
|
-
if (verb === 'list') {
|
|
760
|
-
if (urls.length > 0 || v.all)
|
|
761
|
-
return usageError(ctx, name, 'list takes no URLs and no --all');
|
|
762
|
-
return cacheList(ctx, dir, json);
|
|
763
|
-
}
|
|
764
|
-
if (verb === 'remove') {
|
|
765
|
-
const hasUrls = urls.length > 0;
|
|
766
|
-
if (hasUrls === v.all) {
|
|
767
|
-
return usageError(ctx, name, 'remove takes URLs or --all, not both and not neither');
|
|
768
|
-
}
|
|
769
|
-
return cacheRemove(ctx, name, dir, v.all ? null : urls, json);
|
|
770
|
-
}
|
|
771
|
-
return usageError(ctx, name, `unknown verb ${JSON.stringify(verb)}; use list or remove`);
|
|
772
|
-
}
|
|
773
627
|
function cacheList(ctx, dir, json) {
|
|
774
628
|
const mirrors = listMirrors(dir);
|
|
775
629
|
if (json) {
|
|
@@ -841,17 +695,28 @@ function cacheRemove(ctx, name, dir, urls, json) {
|
|
|
841
695
|
}
|
|
842
696
|
return missing.length > 0 || failed.length > 0 ? EXIT.error : EXIT.ok;
|
|
843
697
|
}
|
|
844
|
-
function
|
|
845
|
-
const
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
698
|
+
function runCache(argv, ctx) {
|
|
699
|
+
const name = `${NAME} cache`;
|
|
700
|
+
const parsed = parseWith(cacheCli(), name, argv, ctx);
|
|
701
|
+
if (parsed.status === 'done')
|
|
702
|
+
return parsed.code;
|
|
703
|
+
const v = parsed.values;
|
|
704
|
+
const [verb = 'list', ...urls] = parsed.positionals;
|
|
705
|
+
const dir = defaultCacheDir(ctx.env);
|
|
706
|
+
const json = v.format === 'json';
|
|
707
|
+
if (verb === 'list') {
|
|
708
|
+
if (urls.length > 0 || v.all)
|
|
709
|
+
return usageError(ctx, name, 'list takes no URLs and no --all');
|
|
710
|
+
return cacheList(ctx, dir, json);
|
|
711
|
+
}
|
|
712
|
+
if (verb === 'remove') {
|
|
713
|
+
const hasUrls = urls.length > 0;
|
|
714
|
+
if (hasUrls === v.all) {
|
|
715
|
+
return usageError(ctx, name, 'remove takes URLs or --all, not both and not neither');
|
|
716
|
+
}
|
|
717
|
+
return cacheRemove(ctx, name, dir, v.all ? null : urls, json);
|
|
718
|
+
}
|
|
719
|
+
return usageError(ctx, name, `unknown verb ${JSON.stringify(verb)}; use list or remove`);
|
|
855
720
|
}
|
|
856
721
|
/** `main(argv, ctx)`: returns an exit code, never exits. */
|
|
857
722
|
export function main(argv, ctx, deps = {}) {
|
|
@@ -873,17 +738,13 @@ export function main(argv, ctx, deps = {}) {
|
|
|
873
738
|
const c = { ...ctx, io };
|
|
874
739
|
const [sub, ...rest] = argv;
|
|
875
740
|
try {
|
|
876
|
-
if (sub === 'summary')
|
|
877
|
-
return runSummary(rest, c, status);
|
|
878
741
|
if (sub === 'plan')
|
|
879
742
|
return runPlan(rest, c, status);
|
|
880
|
-
if (sub === '
|
|
881
|
-
return
|
|
743
|
+
if (sub === 'clean')
|
|
744
|
+
return runClean(rest, c, deps, status);
|
|
882
745
|
if (sub === 'cache')
|
|
883
746
|
return runCache(rest, c);
|
|
884
|
-
|
|
885
|
-
return runStatus(rest, c, status);
|
|
886
|
-
return runStatus(argv, c, status);
|
|
747
|
+
return runReport(argv, c, status);
|
|
887
748
|
}
|
|
888
749
|
finally {
|
|
889
750
|
status.clear();
|