@osdlc/repo-cleaner 0.17.2 → 0.19.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 +691 -305
- package/dist/branches.d.ts +37 -36
- package/dist/branches.js +199 -196
- package/dist/clean.d.ts +179 -0
- package/dist/clean.js +824 -0
- package/dist/cli.d.ts +12 -16
- package/dist/cli.js +367 -478
- 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/providers/github.d.ts +2 -1
- package/dist/providers/github.js +2 -1
- package/dist/providers/gitlab.d.ts +3 -2
- package/dist/providers/gitlab.js +14 -8
- package/dist/providers/index.d.ts +5 -4
- package/dist/providers/index.js +8 -5
- package/dist/providers/types.d.ts +8 -2
- package/dist/prs.d.ts +35 -7
- package/dist/prs.js +45 -12
- package/dist/receipt.d.ts +46 -57
- package/dist/receipt.js +52 -28
- package/dist/reference.d.ts +12 -0
- package/dist/reference.js +152 -0
- package/dist/remote-mirror.d.ts +9 -1
- package/dist/remote-mirror.js +19 -1
- package/dist/render.d.ts +66 -61
- package/dist/render.js +206 -255
- package/dist/resolve.d.ts +3 -3
- package/dist/resolve.js +4 -12
- 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 +107 -64
- package/dist/sweep.js +333 -119
- package/dist/types.d.ts +294 -105
- package/dist/types.js +106 -54
- package/dist/util.d.ts +13 -0
- package/dist/util.js +36 -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 +5 -5
- 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, renderError, } 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, sweepNotes, } 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 directory 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,156 @@ 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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
noProvider: swept.noProvider,
|
|
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
|
-
};
|
|
327
|
+
/** Each repository's `RepoSweep.notes`, on stderr, for the outputs that do not carry them. */
|
|
328
|
+
function reportNotes(ctx, name, s) {
|
|
329
|
+
writeNotes(ctx, name, sweepNotes(s));
|
|
397
330
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
331
|
+
/** Notes as `<name>: <repo>: <note>` lines on stderr. A note quotes git's and the provider's output, so it goes out as an error line does, controls removed. */
|
|
332
|
+
function writeNotes(ctx, name, notes) {
|
|
333
|
+
for (const { repo, note } of notes)
|
|
334
|
+
ctx.io.stderr(renderError(`${repo}: ${note}`, name));
|
|
335
|
+
}
|
|
336
|
+
function runReport(argv, ctx, status) {
|
|
337
|
+
const parsed = parseWith(reportCli(), NAME, argv, ctx);
|
|
338
|
+
if (parsed.status === 'done')
|
|
339
|
+
return parsed.code;
|
|
404
340
|
const v = parsed.values;
|
|
341
|
+
if (v.all && v.format !== 'text') {
|
|
342
|
+
return usageError(ctx, NAME, `--all has no effect with --format ${v.format}: it shows keep rows in the text report`);
|
|
343
|
+
}
|
|
405
344
|
const style = styleFor(ctx, 'stdout');
|
|
406
|
-
const
|
|
407
|
-
const swept = sweep(NAME, v, parsed.positionals, ctx, status);
|
|
345
|
+
const swept = doSweep(NAME, v, parsed.positionals, ctx, status);
|
|
408
346
|
if (swept.kind === 'exit')
|
|
409
347
|
return swept.code;
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
348
|
+
const s = swept.sweep;
|
|
349
|
+
if (v.format === 'json')
|
|
350
|
+
ctx.io.stdout(renderJson(s));
|
|
351
|
+
else if (v.format === 'summary')
|
|
352
|
+
ctx.io.stdout(renderSummary(s, style) + remoteNote(s, style));
|
|
353
|
+
else if (v.format === 'commands') {
|
|
354
|
+
reportNotes(ctx, NAME, s);
|
|
355
|
+
const commands = renderCommands(s);
|
|
416
356
|
if (commands.length > 0)
|
|
417
357
|
ctx.io.stdout(commands.join('\n') + '\n');
|
|
418
358
|
}
|
|
419
359
|
else {
|
|
420
|
-
ctx.io.stdout(
|
|
421
|
-
if (v.output === 'text')
|
|
422
|
-
ctx.io.stdout(remotesNote(swept, style));
|
|
360
|
+
ctx.io.stdout(renderText(s, { all: v.all, style }) + remoteNote(s, style));
|
|
423
361
|
}
|
|
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
362
|
return swept.kind === 'nothing' ? EXIT_NOTHING_TO_SWEEP : EXIT.ok;
|
|
443
363
|
}
|
|
444
364
|
function runPlan(argv, ctx, status) {
|
|
445
365
|
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);
|
|
366
|
+
const parsed = parseWith(planCli(), name, argv, ctx);
|
|
367
|
+
if (parsed.status === 'done')
|
|
368
|
+
return parsed.code;
|
|
451
369
|
const v = parsed.values;
|
|
452
|
-
const swept =
|
|
370
|
+
const swept = doSweep(name, v, parsed.positionals, ctx, status);
|
|
453
371
|
if (swept.kind === 'exit')
|
|
454
372
|
return swept.code;
|
|
455
|
-
// stdout carries only the plan, so the
|
|
373
|
+
// stdout carries only the plan, so the totals go to stderr either way.
|
|
456
374
|
if (swept.kind === 'nothing') {
|
|
457
|
-
ctx.io.stderr(
|
|
375
|
+
ctx.io.stderr(renderTotals(swept.sweep) + '\n');
|
|
458
376
|
return EXIT_NOTHING_TO_SWEEP;
|
|
459
377
|
}
|
|
460
|
-
const plan = planOf(swept
|
|
461
|
-
|
|
378
|
+
const plan = planOf(swept.sweep);
|
|
379
|
+
reportNotes(ctx, name, swept.sweep);
|
|
380
|
+
const unreadable = reportUnreadable(ctx, name, swept.sweep);
|
|
462
381
|
const style = styleFor(ctx, 'stderr');
|
|
463
|
-
ctx.io.stderr(
|
|
382
|
+
ctx.io.stderr(renderTotals(swept.sweep, style) + '\n' + remoteNote(swept.sweep, style));
|
|
464
383
|
if (v.out === '-') {
|
|
465
384
|
ctx.io.stdout(`${JSON.stringify(plan, null, 2)}\n`);
|
|
466
385
|
}
|
|
@@ -476,174 +395,153 @@ function receiptBeside(planPath, now) {
|
|
|
476
395
|
const stem = basename(planPath).replace(/\.json$/i, '');
|
|
477
396
|
return join(dirname(planPath), `${stem}.receipt-${fileStamp(now)}.json`);
|
|
478
397
|
}
|
|
479
|
-
const dropKey = (d) => JSON.stringify([d.repo, d.
|
|
480
|
-
/**
|
|
481
|
-
function
|
|
482
|
-
|
|
398
|
+
const dropKey = (d) => JSON.stringify([d.repo, d.row]);
|
|
399
|
+
/** The run's options that contradict each other, as a usage message, or null. */
|
|
400
|
+
function cleanConflict(v, targets) {
|
|
401
|
+
if (v.plan !== undefined) {
|
|
402
|
+
const fixed = [
|
|
403
|
+
targets.length > 0 ? 'targets' : null,
|
|
404
|
+
v.only !== undefined ? '--only' : null,
|
|
405
|
+
v.branch !== undefined ? '--branch' : null,
|
|
406
|
+
v.remote ? '--remote' : null,
|
|
407
|
+
v.offline ? '--offline' : null,
|
|
408
|
+
v.against !== undefined ? '--against' : null,
|
|
409
|
+
v.provider !== undefined ? '--provider' : null,
|
|
410
|
+
].filter((f) => f !== null);
|
|
411
|
+
if (fixed.length > 0)
|
|
412
|
+
return `--plan already fixed what is swept; drop ${fixed.join(', ')}`;
|
|
413
|
+
}
|
|
414
|
+
if (v.plan === '-' && !v.yes && !v.dryRun) {
|
|
415
|
+
return '--plan - reads the plan from stdin, so nothing can ask; pass --yes or --dry-run';
|
|
416
|
+
}
|
|
417
|
+
if (v.yes && v.dryRun)
|
|
418
|
+
return '--yes and --dry-run contradict: one deletes without asking, the other deletes nothing';
|
|
419
|
+
if (v.receipt !== undefined && v.dryRun)
|
|
420
|
+
return '--receipt has no effect with --dry-run: nothing runs to record';
|
|
421
|
+
return null;
|
|
483
422
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
const receiptPath = v.receipt === undefined ? null : resolve(ctx.cwd, v.receipt);
|
|
423
|
+
function loadPlan(name, v, targets, ctx, deps, status, now) {
|
|
424
|
+
const receiptPath = v.receipt === undefined ? undefined : resolve(ctx.cwd, v.receipt);
|
|
487
425
|
if (v.plan === undefined) {
|
|
488
|
-
const swept =
|
|
426
|
+
const swept = doSweep(name, v, targets, ctx, status);
|
|
489
427
|
if (swept.kind === 'exit')
|
|
490
428
|
return swept.code;
|
|
491
429
|
if (swept.kind === 'nothing') {
|
|
492
|
-
ctx.io.stdout(v.
|
|
430
|
+
ctx.io.stdout(v.format === 'json' ? renderJson(swept.sweep) : renderText(swept.sweep));
|
|
493
431
|
return EXIT_NOTHING_TO_SWEEP;
|
|
494
432
|
}
|
|
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
433
|
return {
|
|
500
|
-
plan: planOf(swept, now),
|
|
434
|
+
plan: planOf(swept.sweep, now),
|
|
501
435
|
source: '(swept)',
|
|
502
436
|
receiptPath,
|
|
503
|
-
unreadable: reportUnreadable(ctx, name, swept.
|
|
504
|
-
|
|
437
|
+
unreadable: reportUnreadable(ctx, name, swept.sweep),
|
|
438
|
+
sweep: swept.sweep,
|
|
505
439
|
};
|
|
506
440
|
}
|
|
507
441
|
try {
|
|
508
442
|
if (v.plan === '-') {
|
|
509
443
|
const plan = parsePlan((deps.readStdin ?? readAllStdin)(), 'stdin');
|
|
510
|
-
return { plan, source: '(stdin)', receiptPath, unreadable: false,
|
|
444
|
+
return { plan, source: '(stdin)', receiptPath, unreadable: false, sweep: undefined };
|
|
511
445
|
}
|
|
512
446
|
const source = resolve(ctx.cwd, v.plan);
|
|
513
|
-
const plan = readPlan(source);
|
|
514
447
|
return {
|
|
515
|
-
plan,
|
|
448
|
+
plan: readPlan(source),
|
|
516
449
|
source,
|
|
517
450
|
receiptPath: receiptPath ?? receiptBeside(source, now),
|
|
518
451
|
unreadable: false,
|
|
519
|
-
|
|
452
|
+
sweep: undefined,
|
|
520
453
|
};
|
|
521
454
|
}
|
|
522
455
|
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(', ')}`;
|
|
456
|
+
return refusal(ctx, name, err);
|
|
538
457
|
}
|
|
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
458
|
}
|
|
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);
|
|
459
|
+
function runClean(argv, ctx, deps, status) {
|
|
460
|
+
const name = `${NAME} clean`;
|
|
461
|
+
const parsed = parseWith(cleanCli(), name, argv, ctx);
|
|
462
|
+
if (parsed.status === 'done')
|
|
463
|
+
return parsed.code;
|
|
551
464
|
const v = parsed.values;
|
|
552
|
-
const
|
|
553
|
-
let
|
|
554
|
-
let keep;
|
|
555
|
-
let grants;
|
|
465
|
+
const targets = parsed.positionals;
|
|
466
|
+
let approval;
|
|
556
467
|
try {
|
|
557
|
-
|
|
558
|
-
keep = v.keep.map(parseRowRef);
|
|
559
|
-
grants = v.allow.map(parseGrant);
|
|
468
|
+
approval = { pick: v.pick, spare: v.spare, allow: v.allow.map(parseAllow) };
|
|
560
469
|
}
|
|
561
470
|
catch (err) {
|
|
562
|
-
|
|
563
|
-
return usageError(ctx, name, err.message);
|
|
564
|
-
throw err;
|
|
471
|
+
return refusal(ctx, name, err);
|
|
565
472
|
}
|
|
566
|
-
const conflict =
|
|
473
|
+
const conflict = cleanConflict(v, targets);
|
|
567
474
|
if (conflict !== null)
|
|
568
475
|
return usageError(ctx, name, conflict);
|
|
476
|
+
// Refuse what can be refused before sweeping; a saved plan's own options
|
|
477
|
+
// are checked when the approval is resolved against it.
|
|
478
|
+
if (v.plan === undefined) {
|
|
479
|
+
try {
|
|
480
|
+
checkApproval(sweptWith(targets, sweepOptions(v, ctx)), approval);
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
return refusal(ctx, name, err);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
569
486
|
const now = new Date();
|
|
570
|
-
const loaded = loadPlan(name, v,
|
|
487
|
+
const loaded = loadPlan(name, v, targets, ctx, deps, status, now);
|
|
571
488
|
if (typeof loaded === 'number')
|
|
572
489
|
return loaded;
|
|
573
490
|
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
491
|
const ok = () => (loaded.unreadable ? EXIT.error : EXIT.ok);
|
|
586
|
-
const repoRoot = v.repoRoot === undefined ? undefined : resolve(ctx.cwd, v.repoRoot);
|
|
587
492
|
// On a terminal the run is shown live: every step first, then each one
|
|
588
|
-
// checked off as it finishes
|
|
589
|
-
const live = v.
|
|
493
|
+
// checked off as it finishes.
|
|
494
|
+
const live = v.format === 'text' && ctx.terminal?.stdout === true;
|
|
590
495
|
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,
|
|
496
|
+
const shared = {
|
|
497
|
+
cwd: ctx.cwd,
|
|
615
498
|
...(v.progress
|
|
616
499
|
? {
|
|
617
|
-
|
|
618
|
-
onStep: (i, total, step) => status.update(describeStep(i, total, step)),
|
|
500
|
+
onProgress: (p) => status.update(`re-checking ${describeSweep(p)}`),
|
|
619
501
|
}
|
|
620
502
|
: {}),
|
|
621
503
|
};
|
|
622
504
|
let preview;
|
|
623
505
|
try {
|
|
624
|
-
// A sweep that just ran is the preview's re-check; the run
|
|
625
|
-
preview =
|
|
626
|
-
...
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
swept: loaded.swept,
|
|
506
|
+
// A sweep that just ran is the preview's re-check; the run sweeps again.
|
|
507
|
+
preview = check(plan, approval, {
|
|
508
|
+
...shared,
|
|
509
|
+
sweep: loaded.sweep,
|
|
510
|
+
cacheDir: defaultCacheDir(ctx.env),
|
|
630
511
|
});
|
|
631
512
|
}
|
|
632
513
|
catch (err) {
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
return failure(ctx, err);
|
|
514
|
+
status.clear();
|
|
515
|
+
return refusal(ctx, name, err);
|
|
636
516
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
517
|
+
status.clear();
|
|
518
|
+
// The re-check's notes: from the sweep just made, or from sweeping a saved
|
|
519
|
+
// plan's repositories again. The run re-checks once more; only a note that
|
|
520
|
+
// is new by then is printed again.
|
|
521
|
+
const noted = new Set();
|
|
522
|
+
const notesOnce = (notes) => writeNotes(ctx, name, notes.filter(({ repo, note }) => {
|
|
523
|
+
const key = `${repo}\0${note}`;
|
|
524
|
+
if (noted.has(key))
|
|
525
|
+
return false;
|
|
526
|
+
noted.add(key);
|
|
527
|
+
return true;
|
|
528
|
+
}));
|
|
529
|
+
notesOnce(preview.notes);
|
|
530
|
+
const showPreview = () => {
|
|
531
|
+
if (v.format === 'json') {
|
|
532
|
+
ctx.io.stdout(`${JSON.stringify({ dryRun: v.dryRun, summary: summarizeRun(preview), steps: preview.steps, dropped: preview.dropped, unusedAllows: preview.unusedAllows, notes: preview.notes }, null, 2)}\n`);
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
640
535
|
ctx.io.stdout(renderPreview(preview, style));
|
|
641
|
-
|
|
642
|
-
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
const count = rowsActedOn(summarizeRun(preview));
|
|
539
|
+
if (v.dryRun || count === 0) {
|
|
540
|
+
showPreview();
|
|
643
541
|
return ok();
|
|
644
542
|
}
|
|
645
543
|
if (!v.yes) {
|
|
646
|
-
const declined = confirm(ctx, name, preview, count, v.
|
|
544
|
+
const declined = confirm(ctx, name, preview, count, v.format === 'json', deps);
|
|
647
545
|
if (declined !== null)
|
|
648
546
|
return declined;
|
|
649
547
|
}
|
|
@@ -652,11 +550,6 @@ function runApply(argv, ctx, deps, status) {
|
|
|
652
550
|
}
|
|
653
551
|
if (live)
|
|
654
552
|
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
553
|
const size = deps.terminalSize ??
|
|
661
554
|
(() => ({
|
|
662
555
|
width: ctx.terminal?.columns || process.stdout.columns || 80,
|
|
@@ -665,11 +558,16 @@ function runApply(argv, ctx, deps, status) {
|
|
|
665
558
|
let board;
|
|
666
559
|
let receipt;
|
|
667
560
|
try {
|
|
668
|
-
receipt =
|
|
669
|
-
...
|
|
670
|
-
|
|
561
|
+
receipt = run(preview, {
|
|
562
|
+
...shared,
|
|
563
|
+
source,
|
|
671
564
|
receiptPath,
|
|
672
|
-
|
|
565
|
+
now: () => new Date(),
|
|
566
|
+
onNotes: (notes) => {
|
|
567
|
+
// The run's re-check draws its progress on the status line.
|
|
568
|
+
status.clear();
|
|
569
|
+
notesOnce(notes);
|
|
570
|
+
},
|
|
673
571
|
...(live
|
|
674
572
|
? {
|
|
675
573
|
// The board carries the run's progress; the status line stays clear.
|
|
@@ -682,16 +580,20 @@ function runApply(argv, ctx, deps, status) {
|
|
|
682
580
|
board?.draw(null);
|
|
683
581
|
// Here rather than after the run: a receipt that then cannot be
|
|
684
582
|
// saved must not take the step's own error with it.
|
|
685
|
-
if (step.result === 'failed')
|
|
583
|
+
if (step.result === 'failed')
|
|
686
584
|
ctx.io.stdout(`\n${renderStepFailure(step, style)}\n`);
|
|
687
|
-
}
|
|
688
585
|
},
|
|
689
586
|
}
|
|
690
|
-
:
|
|
587
|
+
: v.progress
|
|
588
|
+
? {
|
|
589
|
+
onStep: (i, total, step) => status.update(describeStep(i, total, step)),
|
|
590
|
+
}
|
|
591
|
+
: {}),
|
|
691
592
|
});
|
|
692
593
|
}
|
|
693
594
|
catch (err) {
|
|
694
|
-
|
|
595
|
+
status.clear();
|
|
596
|
+
return refusal(ctx, name, err);
|
|
695
597
|
}
|
|
696
598
|
status.clear();
|
|
697
599
|
if (live) {
|
|
@@ -700,11 +602,16 @@ function runApply(argv, ctx, deps, status) {
|
|
|
700
602
|
const seen = new Set(preview.dropped.map(dropKey));
|
|
701
603
|
const late = receipt.dropped.filter((d) => !seen.has(dropKey(d)));
|
|
702
604
|
if (late.length > 0)
|
|
703
|
-
ctx.io.stdout(`\n${
|
|
605
|
+
ctx.io.stdout(`\n${renderLateDrops(late, style)}`);
|
|
704
606
|
ctx.io.stdout('\n');
|
|
705
607
|
}
|
|
706
|
-
|
|
707
|
-
|
|
608
|
+
if (v.format === 'json') {
|
|
609
|
+
ctx.io.stdout(`${JSON.stringify({ receiptPath: receiptPath ?? null, summary: summarizeRun(receipt), receipt }, null, 2)}\n`);
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
ctx.io.stdout(renderReceipt(receipt, receiptPath ?? null, style, { steps: !live }));
|
|
613
|
+
}
|
|
614
|
+
if (receiptPath === undefined) {
|
|
708
615
|
ctx.io.stderr(`${name}: no receipt written; pass --receipt <file> to keep one\n`);
|
|
709
616
|
}
|
|
710
617
|
return receipt.status === 'failed' ? EXIT_STEP_FAILED : ok();
|
|
@@ -745,31 +652,6 @@ function cacheLine(m) {
|
|
|
745
652
|
return (stripControls(`${sizeText(m.bytes).padStart(9)} ${m.url ?? '(no origin)'} ${m.dir}${note}`) +
|
|
746
653
|
'\n');
|
|
747
654
|
}
|
|
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
655
|
function cacheList(ctx, dir, json) {
|
|
774
656
|
const mirrors = listMirrors(dir);
|
|
775
657
|
if (json) {
|
|
@@ -841,17 +723,28 @@ function cacheRemove(ctx, name, dir, urls, json) {
|
|
|
841
723
|
}
|
|
842
724
|
return missing.length > 0 || failed.length > 0 ? EXIT.error : EXIT.ok;
|
|
843
725
|
}
|
|
844
|
-
function
|
|
845
|
-
const
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
726
|
+
function runCache(argv, ctx) {
|
|
727
|
+
const name = `${NAME} cache`;
|
|
728
|
+
const parsed = parseWith(cacheCli(), name, argv, ctx);
|
|
729
|
+
if (parsed.status === 'done')
|
|
730
|
+
return parsed.code;
|
|
731
|
+
const v = parsed.values;
|
|
732
|
+
const [verb = 'list', ...urls] = parsed.positionals;
|
|
733
|
+
const dir = defaultCacheDir(ctx.env);
|
|
734
|
+
const json = v.format === 'json';
|
|
735
|
+
if (verb === 'list') {
|
|
736
|
+
if (urls.length > 0 || v.all)
|
|
737
|
+
return usageError(ctx, name, 'list takes no URLs and no --all');
|
|
738
|
+
return cacheList(ctx, dir, json);
|
|
739
|
+
}
|
|
740
|
+
if (verb === 'remove') {
|
|
741
|
+
const hasUrls = urls.length > 0;
|
|
742
|
+
if (hasUrls === v.all) {
|
|
743
|
+
return usageError(ctx, name, 'remove takes URLs or --all, not both and not neither');
|
|
744
|
+
}
|
|
745
|
+
return cacheRemove(ctx, name, dir, v.all ? null : urls, json);
|
|
746
|
+
}
|
|
747
|
+
return usageError(ctx, name, `unknown verb ${JSON.stringify(verb)}; use list or remove`);
|
|
855
748
|
}
|
|
856
749
|
/** `main(argv, ctx)`: returns an exit code, never exits. */
|
|
857
750
|
export function main(argv, ctx, deps = {}) {
|
|
@@ -873,17 +766,13 @@ export function main(argv, ctx, deps = {}) {
|
|
|
873
766
|
const c = { ...ctx, io };
|
|
874
767
|
const [sub, ...rest] = argv;
|
|
875
768
|
try {
|
|
876
|
-
if (sub === 'summary')
|
|
877
|
-
return runSummary(rest, c, status);
|
|
878
769
|
if (sub === 'plan')
|
|
879
770
|
return runPlan(rest, c, status);
|
|
880
|
-
if (sub === '
|
|
881
|
-
return
|
|
771
|
+
if (sub === 'clean')
|
|
772
|
+
return runClean(rest, c, deps, status);
|
|
882
773
|
if (sub === 'cache')
|
|
883
774
|
return runCache(rest, c);
|
|
884
|
-
|
|
885
|
-
return runStatus(rest, c, status);
|
|
886
|
-
return runStatus(argv, c, status);
|
|
775
|
+
return runReport(argv, c, status);
|
|
887
776
|
}
|
|
888
777
|
finally {
|
|
889
778
|
status.clear();
|