@osdlc/repo-cleaner 0.9.2 → 0.10.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 +38 -0
- package/README.md +75 -62
- package/dist/apply.d.ts +24 -7
- package/dist/apply.js +44 -13
- package/dist/branches.d.ts +2 -2
- package/dist/branches.js +24 -8
- package/dist/cli.d.ts +26 -7
- package/dist/cli.js +423 -183
- package/dist/devlink.d.ts +5 -0
- package/dist/devlink.js +3 -1
- package/dist/index.d.ts +3 -4
- package/dist/index.js +1 -2
- package/dist/plan.d.ts +7 -3
- package/dist/plan.js +24 -11
- package/dist/receipt.d.ts +2 -0
- package/dist/receipt.js +1 -1
- package/dist/render.d.ts +9 -0
- package/dist/render.js +63 -0
- package/dist/status-line.d.ts +24 -0
- package/dist/status-line.js +54 -0
- package/dist/sweep.d.ts +8 -1
- package/dist/sweep.js +17 -4
- package/dist/types.d.ts +15 -0
- package/dist/types.js +3 -1
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worktrees.d.ts +1 -1
- package/dist/worktrees.js +18 -4
- package/package.json +4 -4
- package/dist/config.d.ts +0 -53
- package/dist/config.js +0 -67
package/dist/cli.js
CHANGED
|
@@ -1,203 +1,236 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `repo-cleaner` — what has been merged, and what can go away.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Four verbs over one set of sweep filters:
|
|
5
|
+
*
|
|
6
|
+
* status (the default) inventory and classify; read-only but for the fetch
|
|
7
|
+
* summary the same sweep as counts: totals, merged, delete, review, keep
|
|
8
|
+
* plan write the deletable rows as a plan, to stdout or a file
|
|
9
|
+
* apply re-check, show the steps, ask, then delete
|
|
10
|
+
*
|
|
11
|
+
* `apply` works from a saved plan (`--plan`) or sweeps first. Either way it
|
|
12
|
+
* re-checks every row, prints the steps it would run and asks before deleting;
|
|
13
|
+
* `--yes` skips the question. Without a terminal and without `--yes` it
|
|
14
|
+
* refuses rather than deleting. Scope is opt-in: the sweep covers the
|
|
15
|
+
* repository you are standing in unless a path says otherwise.
|
|
9
16
|
*
|
|
10
17
|
* Exit codes:
|
|
11
|
-
* 0 swept; or
|
|
18
|
+
* 0 swept; or apply ran, was declined, or had nothing to run
|
|
12
19
|
* 1 failed against the state it found
|
|
13
20
|
* 2 cannot run as typed
|
|
14
21
|
* 10 nothing to sweep: no entry named a repository
|
|
15
22
|
* 11 apply stopped at a failed step; the receipt says which
|
|
16
23
|
*/
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
24
|
+
import { readSync } from 'node:fs';
|
|
25
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
26
|
+
import { isatty } from 'node:tty';
|
|
27
|
+
import { EXIT, askYesNo, canPrompt, defineCli } from '@osdlc/cli-tool';
|
|
19
28
|
import { Git } from '@osdlc/typed-git';
|
|
20
29
|
import { resolveBranch } from "./branches.js";
|
|
21
30
|
import { ApplyArgumentError, applyPlan, parseGrant, parseRowRef } from "./apply.js";
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
31
|
+
import { PLAN_VERSION, fileStamp, parsePlan, planRepo, readPlan, writePlan, } from "./plan.js";
|
|
32
|
+
import { proposedCommands, renderCountsJson, renderCountsText, renderJson, renderReceipt, renderSummary, renderText, } from "./render.js";
|
|
33
|
+
import { NO_STATUS_LINE, describeStep, describeSweep, statusLine, } from "./status-line.js";
|
|
25
34
|
import { sweepRepos } from "./sweep.js";
|
|
26
|
-
import { ALL_SCOPES } from "./types.js";
|
|
27
35
|
const EXIT_NOTHING_TO_SWEEP = 10;
|
|
28
36
|
const EXIT_STEP_FAILED = 11;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
const NAME = 'repo-cleaner';
|
|
38
|
+
/** The `--only` names, and the sweep scope each selects. */
|
|
39
|
+
const ONLY = {
|
|
40
|
+
worktrees: 'worktrees',
|
|
41
|
+
branches: 'local-branches',
|
|
42
|
+
remotes: 'remote-branches',
|
|
43
|
+
};
|
|
44
|
+
/** The flags every verb that sweeps takes. */
|
|
45
|
+
const SWEEP_FLAGS = {
|
|
46
|
+
only: {
|
|
47
|
+
kind: 'list',
|
|
48
|
+
valueName: 'list',
|
|
49
|
+
help: `comma-separated: ${Object.keys(ONLY).join(', ')} (default: all)`,
|
|
50
|
+
},
|
|
51
|
+
branch: {
|
|
52
|
+
kind: 'string',
|
|
53
|
+
valueName: 'name|HEAD',
|
|
54
|
+
help: 'only this branch of this repository: its worktree, local and remote branch',
|
|
55
|
+
},
|
|
56
|
+
remote: {
|
|
57
|
+
kind: 'string',
|
|
58
|
+
valueName: 'name',
|
|
59
|
+
help: 'remote to measure against (default: origin)',
|
|
60
|
+
},
|
|
61
|
+
gh: { kind: 'boolean', negatable: true, help: 'classify on git evidence alone' },
|
|
62
|
+
fetch: {
|
|
63
|
+
kind: 'boolean',
|
|
64
|
+
negatable: true,
|
|
65
|
+
help: 'classify on the remote-tracking refs already on disk',
|
|
66
|
+
},
|
|
67
|
+
progress: {
|
|
68
|
+
kind: 'boolean',
|
|
69
|
+
negatable: true,
|
|
70
|
+
help: 'no status line on stderr (drawn only when stderr is a terminal)',
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
const PATHS = [
|
|
74
|
+
{
|
|
75
|
+
name: 'path',
|
|
76
|
+
variadic: true,
|
|
77
|
+
help: 'a checkout, or a directory containing checkouts (default: the one you are in)',
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
const OUTPUT = {
|
|
81
|
+
kind: 'enum',
|
|
82
|
+
choices: ['text', 'json'],
|
|
83
|
+
valueName: 'form',
|
|
84
|
+
default: 'text',
|
|
85
|
+
help: 'report format',
|
|
86
|
+
};
|
|
87
|
+
const statusCli = () => defineCli({
|
|
88
|
+
name: NAME,
|
|
38
89
|
summary: 'Inventory branches, worktrees and remote branches, and say which have been merged into ' +
|
|
39
|
-
'the default branch. Fetches each remote first;
|
|
40
|
-
positionals:
|
|
41
|
-
{
|
|
42
|
-
name: 'path',
|
|
43
|
-
variadic: true,
|
|
44
|
-
help: 'a checkout, or a directory containing checkouts (default: the one you are in)',
|
|
45
|
-
},
|
|
46
|
-
],
|
|
90
|
+
'the default branch. Fetches each remote first; deletes nothing.',
|
|
91
|
+
positionals: PATHS,
|
|
47
92
|
flags: {
|
|
48
|
-
|
|
49
|
-
config: { kind: 'string', valueName: 'path', help: 'config file to read' },
|
|
50
|
-
scope: {
|
|
51
|
-
kind: 'list',
|
|
52
|
-
valueName: 'list',
|
|
53
|
-
help: `comma-separated: ${ALL_SCOPES.join(', ')}`,
|
|
54
|
-
},
|
|
55
|
-
remote: {
|
|
56
|
-
kind: 'string',
|
|
57
|
-
valueName: 'name',
|
|
58
|
-
help: 'remote to measure against (default: origin)',
|
|
59
|
-
},
|
|
60
|
-
gh: { kind: 'boolean', negatable: true, help: 'classify on git evidence alone' },
|
|
61
|
-
fetch: {
|
|
62
|
-
kind: 'boolean',
|
|
63
|
-
negatable: true,
|
|
64
|
-
help: 'classify on the remote-tracking refs already on disk',
|
|
65
|
-
},
|
|
93
|
+
...SWEEP_FLAGS,
|
|
66
94
|
keep: { kind: 'boolean', help: 'include safe-to-keep rows in the report' },
|
|
67
95
|
commands: { kind: 'boolean', help: 'print the proposed commands only' },
|
|
68
|
-
|
|
69
|
-
kind: 'string',
|
|
70
|
-
valueName: 'name|HEAD',
|
|
71
|
-
help: 'sweep only this branch of this repository: its worktree, local and remote branch',
|
|
72
|
-
},
|
|
73
|
-
writePlan: { kind: 'boolean', help: 'write the deletable rows as a plan for `apply`' },
|
|
74
|
-
planOut: {
|
|
75
|
-
kind: 'string',
|
|
76
|
-
valueName: 'path',
|
|
77
|
-
help: 'write the plan here; implies --write-plan',
|
|
78
|
-
},
|
|
79
|
-
output: {
|
|
80
|
-
kind: 'enum',
|
|
81
|
-
choices: ['text', 'json'],
|
|
82
|
-
valueName: 'form',
|
|
83
|
-
default: 'text',
|
|
84
|
-
help: 'report format',
|
|
85
|
-
},
|
|
96
|
+
output: OUTPUT,
|
|
86
97
|
},
|
|
87
98
|
epilogue: `commands:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
99
|
+
${NAME} [status] this report (the default)
|
|
100
|
+
${NAME} summary counts per repository: total, merged, delete, review, keep
|
|
101
|
+
${NAME} plan write the deletable rows as a plan
|
|
102
|
+
${NAME} apply re-check, show the steps, ask, delete
|
|
103
|
+
${NAME} dev run ${NAME} from a development checkout
|
|
92
104
|
|
|
93
|
-
|
|
94
|
-
|
|
105
|
+
Each command takes --help. A checkout directory named like a command is
|
|
106
|
+
swept as ./<name>.`,
|
|
107
|
+
});
|
|
108
|
+
const summaryCli = () => defineCli({
|
|
109
|
+
name: `${NAME} summary`,
|
|
110
|
+
summary: 'Count local branches, worktrees and remote branches per repository: how many there are, ' +
|
|
111
|
+
'how many are merged into the default branch, and how many would be deleted, reviewed or kept.',
|
|
112
|
+
positionals: PATHS,
|
|
113
|
+
flags: { ...SWEEP_FLAGS, output: OUTPUT },
|
|
114
|
+
epilogue: 'merged: the work is on the default branch. Rows kept for structure (the default ' +
|
|
115
|
+
'branch, the primary worktree) are counted in total but not judged merged.',
|
|
116
|
+
});
|
|
117
|
+
const planCli = () => defineCli({
|
|
118
|
+
name: `${NAME} plan`,
|
|
119
|
+
summary: 'Sweep, and write the deletable rows as a plan for `apply`: each row with the facts its ' +
|
|
120
|
+
'verdict rests on, so `apply` can tell what moved since.',
|
|
121
|
+
positionals: PATHS,
|
|
122
|
+
flags: {
|
|
123
|
+
...SWEEP_FLAGS,
|
|
124
|
+
out: {
|
|
125
|
+
kind: 'string',
|
|
126
|
+
short: 'o',
|
|
127
|
+
valueName: 'file',
|
|
128
|
+
default: '-',
|
|
129
|
+
help: 'write the plan here; - is stdout (the default)',
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
epilogue: 'The one-line summary goes to stderr, so stdout carries only the plan.',
|
|
95
133
|
});
|
|
96
134
|
const applyCli = () => defineCli({
|
|
97
|
-
name:
|
|
98
|
-
summary: '
|
|
99
|
-
'and
|
|
135
|
+
name: `${NAME} apply`,
|
|
136
|
+
summary: 'Delete what a sweep or a saved plan found deletable: re-check each row, show the steps, ' +
|
|
137
|
+
'ask, then delete compare-and-swap, verifying each step.',
|
|
138
|
+
positionals: PATHS,
|
|
100
139
|
flags: {
|
|
140
|
+
...SWEEP_FLAGS,
|
|
101
141
|
plan: {
|
|
102
142
|
kind: 'string',
|
|
103
|
-
valueName: '
|
|
104
|
-
|
|
105
|
-
help: 'plan file from `repo-cleaner --write-plan`',
|
|
143
|
+
valueName: 'file',
|
|
144
|
+
help: 'apply this plan (- reads stdin) instead of sweeping',
|
|
106
145
|
},
|
|
107
146
|
row: {
|
|
108
147
|
kind: 'list',
|
|
109
148
|
valueName: 'category:id',
|
|
110
149
|
default: [],
|
|
111
150
|
commaSeparated: false,
|
|
112
|
-
help: '
|
|
151
|
+
help: 'apply only this row, e.g. local_branch:feat/x; repeatable (default: every row)',
|
|
113
152
|
},
|
|
114
|
-
|
|
153
|
+
allow: {
|
|
115
154
|
kind: 'list',
|
|
116
|
-
valueName: '
|
|
155
|
+
valueName: 'risk[=category:id]',
|
|
117
156
|
default: [],
|
|
118
157
|
commaSeparated: false,
|
|
119
|
-
help: '
|
|
158
|
+
help: 'accept a risk for every row, or for one row; repeatable',
|
|
120
159
|
},
|
|
121
160
|
repoRoot: {
|
|
122
161
|
kind: 'string',
|
|
123
162
|
valueName: 'path',
|
|
124
163
|
help: 'apply only the plan repository rooted here',
|
|
125
164
|
},
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
choices: ['text', 'json'],
|
|
131
|
-
valueName: 'form',
|
|
132
|
-
default: 'text',
|
|
133
|
-
help: 'report format',
|
|
165
|
+
receipt: {
|
|
166
|
+
kind: 'string',
|
|
167
|
+
valueName: 'file',
|
|
168
|
+
help: 'write the receipt here (default: beside a --plan file; none otherwise)',
|
|
134
169
|
},
|
|
170
|
+
yes: { kind: 'boolean', short: 'y', help: 'delete without asking' },
|
|
171
|
+
dryRun: { kind: 'boolean', help: 're-check and print the steps; delete nothing' },
|
|
172
|
+
output: OUTPUT,
|
|
135
173
|
},
|
|
136
|
-
epilogue:
|
|
174
|
+
epilogue: `risks: abandon-unmerged, discard-changes, confirm-open-pr
|
|
175
|
+
A row that carries a risk you did not --allow is dropped, never deleted.`,
|
|
137
176
|
});
|
|
177
|
+
const terminalConfirm = (question) => (canPrompt() ? askYesNo(question) : null);
|
|
178
|
+
const PAUSE = new Int32Array(new SharedArrayBuffer(4));
|
|
179
|
+
/**
|
|
180
|
+
* All of stdin, synchronously. A pipe can be non-blocking and still empty
|
|
181
|
+
* when the writer upstream has not produced anything yet; `readFileSync(0)`
|
|
182
|
+
* fails with EAGAIN there, so this waits and retries until end of input.
|
|
183
|
+
*/
|
|
184
|
+
function readAllStdin() {
|
|
185
|
+
const chunks = [];
|
|
186
|
+
const buf = Buffer.alloc(65536);
|
|
187
|
+
for (;;) {
|
|
188
|
+
let n;
|
|
189
|
+
try {
|
|
190
|
+
n = readSync(0, buf, 0, buf.length, null);
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
if (e.code !== 'EAGAIN')
|
|
194
|
+
throw e;
|
|
195
|
+
Atomics.wait(PAUSE, 0, 0, 50);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (n === 0)
|
|
199
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
200
|
+
chunks.push(Buffer.from(buf.subarray(0, n)));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
138
203
|
function usageError(ctx, name, message) {
|
|
139
204
|
ctx.io.stderr(`${name}: ${message}\n`);
|
|
140
205
|
return EXIT.usage;
|
|
141
206
|
}
|
|
142
207
|
function failure(ctx, err) {
|
|
143
|
-
ctx.io.stderr(
|
|
208
|
+
ctx.io.stderr(`${NAME}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
144
209
|
return EXIT.error;
|
|
145
210
|
}
|
|
146
|
-
/**
|
|
147
|
-
function
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const prior = process.env[CONFIG_ENV];
|
|
151
|
-
process.env[CONFIG_ENV] = path;
|
|
152
|
-
clearConfigCache();
|
|
153
|
-
try {
|
|
154
|
-
return fn();
|
|
155
|
-
}
|
|
156
|
-
finally {
|
|
157
|
-
if (prior === undefined)
|
|
158
|
-
delete process.env[CONFIG_ENV];
|
|
159
|
-
else
|
|
160
|
-
process.env[CONFIG_ENV] = prior;
|
|
161
|
-
clearConfigCache();
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
function runSweep(argv, ctx) {
|
|
165
|
-
const parsed = sweepCli().parse(argv, ctx.io);
|
|
166
|
-
if (parsed.status === 'help')
|
|
167
|
-
return EXIT.ok;
|
|
168
|
-
if (parsed.status === 'error')
|
|
169
|
-
return usageError(ctx, 'repo-cleaner', parsed.message);
|
|
170
|
-
const v = parsed.values;
|
|
171
|
-
const paths = parsed.positionals;
|
|
172
|
-
const scope = v.scope?.filter((s) => s !== '');
|
|
173
|
-
const unknown = (scope ?? []).filter((s) => !ALL_SCOPES.includes(s));
|
|
211
|
+
/** Validate the sweep flags and run the sweep, or answer with the exit code. */
|
|
212
|
+
function sweep(name, v, paths, ctx, render, status) {
|
|
213
|
+
const only = v.only?.filter((s) => s !== '');
|
|
214
|
+
const unknown = (only ?? []).filter((s) => !(s in ONLY));
|
|
174
215
|
if (unknown.length > 0) {
|
|
175
|
-
return
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
code: usageError(ctx, name, `unknown --only value(s): ${unknown.join(', ')}`),
|
|
219
|
+
};
|
|
179
220
|
}
|
|
180
|
-
if (v.
|
|
181
|
-
return
|
|
221
|
+
if (v.branch !== undefined && paths.length > 0) {
|
|
222
|
+
return {
|
|
223
|
+
ok: false,
|
|
224
|
+
code: usageError(ctx, name, '--branch sweeps this repository only; drop the paths'),
|
|
225
|
+
};
|
|
182
226
|
}
|
|
183
227
|
let branch;
|
|
184
228
|
try {
|
|
185
229
|
branch = v.branch === undefined ? undefined : resolveBranch(v.branch, new Git(ctx.cwd));
|
|
186
230
|
}
|
|
187
231
|
catch (err) {
|
|
188
|
-
return failure(ctx, err);
|
|
232
|
+
return { ok: false, code: failure(ctx, err) };
|
|
189
233
|
}
|
|
190
|
-
const entries = [...paths];
|
|
191
|
-
if (v.all) {
|
|
192
|
-
try {
|
|
193
|
-
entries.push(...withConfigFile(v.config, () => configuredRepos(undefined, ctx.cwd)));
|
|
194
|
-
}
|
|
195
|
-
catch (err) {
|
|
196
|
-
return failure(ctx, err);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
if (entries.length === 0)
|
|
200
|
-
entries.push(ctx.cwd);
|
|
201
234
|
const options = {
|
|
202
235
|
baseDir: ctx.cwd,
|
|
203
236
|
cwd: ctx.cwd,
|
|
@@ -206,94 +239,301 @@ function runSweep(argv, ctx) {
|
|
|
206
239
|
branch,
|
|
207
240
|
// Mid-walk, while the wait is happening — the summary comes too late to
|
|
208
241
|
// explain why the tool appeared to hang.
|
|
209
|
-
onWarn: (message) => ctx.io.stderr(
|
|
210
|
-
...(
|
|
242
|
+
onWarn: (message) => ctx.io.stderr(`${NAME}: ${message}\n`),
|
|
243
|
+
...(v.progress ? { onProgress: (p) => status.update(describeSweep(p)) } : {}),
|
|
244
|
+
...(only === undefined ? {} : { scope: only.map((s) => ONLY[s]) }),
|
|
211
245
|
...(v.remote === undefined ? {} : { remote: v.remote }),
|
|
212
246
|
};
|
|
213
|
-
|
|
247
|
+
if (v.progress)
|
|
248
|
+
status.update(`${NAME}: finding repositories`);
|
|
249
|
+
const result = sweepRepos(paths.length > 0 ? paths : [ctx.cwd], options);
|
|
250
|
+
status.clear();
|
|
214
251
|
if (branch !== undefined && result.repos.length > 1) {
|
|
215
|
-
|
|
252
|
+
const err = new Error(`--branch sweeps one repository; ${result.repos.length} found under ${ctx.cwd}`);
|
|
253
|
+
return { ok: false, code: failure(ctx, err) };
|
|
216
254
|
}
|
|
217
255
|
const refused = branch === undefined ? null : (result.repos[0]?.error ?? null);
|
|
218
256
|
if (refused !== null)
|
|
219
|
-
return failure(ctx, new Error(refused));
|
|
257
|
+
return { ok: false, code: failure(ctx, new Error(refused)) };
|
|
220
258
|
if (result.repos.length === 0) {
|
|
221
|
-
ctx.io.stdout(
|
|
222
|
-
return EXIT_NOTHING_TO_SWEEP;
|
|
223
|
-
}
|
|
224
|
-
let planPath = null;
|
|
225
|
-
if (v.writePlan || v.planOut !== undefined) {
|
|
226
|
-
const now = new Date();
|
|
227
|
-
planPath =
|
|
228
|
-
v.planOut !== undefined
|
|
229
|
-
? resolve(ctx.cwd, v.planOut)
|
|
230
|
-
: stampedPath(stateDir(ctx, 'plans'), now);
|
|
231
|
-
const planOpts = { remote: v.remote ?? null, noGh: !v.gh, branch: branch ?? null };
|
|
232
|
-
writePlan(planPath, {
|
|
233
|
-
version: PLAN_VERSION,
|
|
234
|
-
created_at: now.toISOString(),
|
|
235
|
-
repos: result.repos.flatMap((sweep) => sweep.repo === null ? [] : [planRepo(sweep.repo.root, planOpts, sweep.items)]),
|
|
236
|
-
});
|
|
259
|
+
ctx.io.stdout(render(result));
|
|
260
|
+
return { ok: false, code: EXIT_NOTHING_TO_SWEEP };
|
|
237
261
|
}
|
|
262
|
+
return { ok: true, result, branch, values: v };
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Name each repository the sweep could not read, on stderr. True when there
|
|
266
|
+
* was one: a verb that acts on the sweep must not pass an unread repository
|
|
267
|
+
* off as an empty one.
|
|
268
|
+
*/
|
|
269
|
+
function reportUnreadable(ctx, name, result) {
|
|
270
|
+
const failed = result.repos.filter((r) => r.error !== null);
|
|
271
|
+
for (const r of failed)
|
|
272
|
+
ctx.io.stderr(`${name}: ${r.repo?.root ?? r.path}: ${r.error}\n`);
|
|
273
|
+
return failed.length > 0;
|
|
274
|
+
}
|
|
275
|
+
function planOf(swept, now) {
|
|
276
|
+
const opts = {
|
|
277
|
+
remote: swept.values.remote ?? null,
|
|
278
|
+
noGh: !swept.values.gh,
|
|
279
|
+
branch: swept.branch ?? null,
|
|
280
|
+
};
|
|
281
|
+
return {
|
|
282
|
+
version: PLAN_VERSION,
|
|
283
|
+
created_at: now.toISOString(),
|
|
284
|
+
repos: swept.result.repos.flatMap((s) => s.repo === null ? [] : [planRepo(s.repo.root, opts, s.items)]),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function runStatus(argv, ctx, status) {
|
|
288
|
+
const parsed = statusCli().parse(argv, ctx.io);
|
|
289
|
+
if (parsed.status === 'help')
|
|
290
|
+
return EXIT.ok;
|
|
291
|
+
if (parsed.status === 'error')
|
|
292
|
+
return usageError(ctx, NAME, parsed.message);
|
|
293
|
+
const v = parsed.values;
|
|
294
|
+
const render = (r) => v.output === 'json' ? renderJson(r) : renderText(r, { keep: v.keep });
|
|
295
|
+
const swept = sweep(NAME, v, parsed.positionals, ctx, render, status);
|
|
296
|
+
if (!swept.ok)
|
|
297
|
+
return swept.code;
|
|
238
298
|
if (v.commands) {
|
|
239
|
-
const commands = proposedCommands(result);
|
|
299
|
+
const commands = proposedCommands(swept.result);
|
|
240
300
|
if (commands.length > 0)
|
|
241
301
|
ctx.io.stdout(commands.join('\n') + '\n');
|
|
242
302
|
}
|
|
243
|
-
else if (v.output === 'json') {
|
|
244
|
-
ctx.io.stdout(renderJson(result, planPath === null ? {} : { plan_path: planPath }));
|
|
245
|
-
}
|
|
246
303
|
else {
|
|
247
|
-
ctx.io.stdout(
|
|
304
|
+
ctx.io.stdout(render(swept.result));
|
|
248
305
|
}
|
|
249
|
-
if (planPath !== null && v.output !== 'json')
|
|
250
|
-
ctx.io.stderr(`plan: ${planPath}\n`);
|
|
251
306
|
return EXIT.ok;
|
|
252
307
|
}
|
|
253
|
-
function
|
|
254
|
-
const name =
|
|
308
|
+
function runSummary(argv, ctx, status) {
|
|
309
|
+
const name = `${NAME} summary`;
|
|
310
|
+
const parsed = summaryCli().parse(argv, ctx.io);
|
|
311
|
+
if (parsed.status === 'help')
|
|
312
|
+
return EXIT.ok;
|
|
313
|
+
if (parsed.status === 'error')
|
|
314
|
+
return usageError(ctx, name, parsed.message);
|
|
315
|
+
const v = parsed.values;
|
|
316
|
+
const render = (r) => v.output === 'json' ? renderCountsJson(r) : renderCountsText(r);
|
|
317
|
+
const swept = sweep(name, v, parsed.positionals, ctx, render, status);
|
|
318
|
+
if (!swept.ok)
|
|
319
|
+
return swept.code;
|
|
320
|
+
ctx.io.stdout(render(swept.result));
|
|
321
|
+
return EXIT.ok;
|
|
322
|
+
}
|
|
323
|
+
function runPlan(argv, ctx, status) {
|
|
324
|
+
const name = `${NAME} plan`;
|
|
325
|
+
const parsed = planCli().parse(argv, ctx.io);
|
|
326
|
+
if (parsed.status === 'help')
|
|
327
|
+
return EXIT.ok;
|
|
328
|
+
if (parsed.status === 'error')
|
|
329
|
+
return usageError(ctx, name, parsed.message);
|
|
330
|
+
const v = parsed.values;
|
|
331
|
+
const swept = sweep(name, v, parsed.positionals, ctx, (r) => {
|
|
332
|
+
ctx.io.stderr(renderSummary(r) + '\n');
|
|
333
|
+
return '';
|
|
334
|
+
}, status);
|
|
335
|
+
if (!swept.ok)
|
|
336
|
+
return swept.code;
|
|
337
|
+
const plan = planOf(swept, new Date());
|
|
338
|
+
const unreadable = reportUnreadable(ctx, name, swept.result);
|
|
339
|
+
ctx.io.stderr(renderSummary(swept.result) + '\n');
|
|
340
|
+
if (v.out === '-') {
|
|
341
|
+
ctx.io.stdout(`${JSON.stringify(plan, null, 2)}\n`);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
const path = resolve(ctx.cwd, v.out);
|
|
345
|
+
writePlan(path, plan);
|
|
346
|
+
ctx.io.stderr(`plan: ${path}\n`);
|
|
347
|
+
}
|
|
348
|
+
return unreadable ? EXIT.error : EXIT.ok;
|
|
349
|
+
}
|
|
350
|
+
/** `<dir>/<name>.receipt-<stamp>.json` for plan `<dir>/<name>.json`. */
|
|
351
|
+
function receiptBeside(planPath, now) {
|
|
352
|
+
const stem = basename(planPath).replace(/\.json$/i, '');
|
|
353
|
+
return join(dirname(planPath), `${stem}.receipt-${fileStamp(now)}.json`);
|
|
354
|
+
}
|
|
355
|
+
function wouldRun(receipt) {
|
|
356
|
+
return receipt.steps.filter((s) => s.row !== null).length;
|
|
357
|
+
}
|
|
358
|
+
function runApply(argv, ctx, deps, status) {
|
|
359
|
+
const name = `${NAME} apply`;
|
|
255
360
|
const parsed = applyCli().parse(argv, ctx.io);
|
|
256
361
|
if (parsed.status === 'help')
|
|
257
362
|
return EXIT.ok;
|
|
258
363
|
if (parsed.status === 'error')
|
|
259
364
|
return usageError(ctx, name, parsed.message);
|
|
260
365
|
const v = parsed.values;
|
|
366
|
+
const paths = parsed.positionals;
|
|
261
367
|
let rows;
|
|
262
368
|
let grants;
|
|
263
369
|
try {
|
|
264
370
|
rows = v.row.map(parseRowRef);
|
|
265
|
-
grants = v.
|
|
371
|
+
grants = v.allow.map(parseGrant);
|
|
266
372
|
}
|
|
267
373
|
catch (err) {
|
|
268
374
|
if (err instanceof ApplyArgumentError)
|
|
269
375
|
return usageError(ctx, name, err.message);
|
|
270
376
|
throw err;
|
|
271
377
|
}
|
|
272
|
-
const
|
|
378
|
+
const fromStdin = v.plan === '-';
|
|
379
|
+
if (v.plan !== undefined) {
|
|
380
|
+
const sweepFlags = [
|
|
381
|
+
paths.length > 0 ? 'paths' : null,
|
|
382
|
+
v.only !== undefined ? '--only' : null,
|
|
383
|
+
v.branch !== undefined ? '--branch' : null,
|
|
384
|
+
v.remote !== undefined ? '--remote' : null,
|
|
385
|
+
].filter((f) => f !== null);
|
|
386
|
+
if (sweepFlags.length > 0) {
|
|
387
|
+
return usageError(ctx, name, `--plan fixes what is swept; drop ${sweepFlags.join(', ')}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (fromStdin && !v.yes && !v.dryRun) {
|
|
391
|
+
return usageError(ctx, name, '--plan - reads the plan from stdin, so it cannot ask; pass --yes or --dry-run');
|
|
392
|
+
}
|
|
393
|
+
const now = new Date();
|
|
394
|
+
let plan;
|
|
395
|
+
let planSource;
|
|
396
|
+
// A repository the sweep could not read contributes no rows; the run says so and exits 1.
|
|
397
|
+
let unreadable = false;
|
|
398
|
+
const ok = () => (unreadable ? EXIT.error : EXIT.ok);
|
|
399
|
+
let receiptPath = v.receipt === undefined ? null : resolve(ctx.cwd, v.receipt);
|
|
400
|
+
if (v.plan === undefined) {
|
|
401
|
+
const swept = sweep(name, v, paths, ctx, (r) => (v.output === 'json' ? renderJson(r) : renderText(r)), status);
|
|
402
|
+
if (!swept.ok)
|
|
403
|
+
return swept.code;
|
|
404
|
+
unreadable = reportUnreadable(ctx, name, swept.result);
|
|
405
|
+
plan = planOf(swept, now);
|
|
406
|
+
planSource = '(swept)';
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
try {
|
|
410
|
+
if (fromStdin) {
|
|
411
|
+
plan = parsePlan((deps.readStdin ?? readAllStdin)(), 'stdin');
|
|
412
|
+
planSource = '(stdin)';
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
planSource = resolve(ctx.cwd, v.plan);
|
|
416
|
+
plan = readPlan(planSource);
|
|
417
|
+
receiptPath ??= receiptBeside(planSource, now);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
catch (err) {
|
|
421
|
+
return failure(ctx, err);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
273
424
|
const repoRoot = v.repoRoot === undefined ? undefined : resolve(ctx.cwd, v.repoRoot);
|
|
274
|
-
const
|
|
275
|
-
|
|
425
|
+
const base = {
|
|
426
|
+
callerCwd: ctx.cwd,
|
|
427
|
+
planPath: planSource,
|
|
428
|
+
repoRoot,
|
|
429
|
+
...(v.progress
|
|
430
|
+
? {
|
|
431
|
+
onSweepProgress: (p) => status.update(`re-checking ${describeSweep(p)}`),
|
|
432
|
+
onStep: (i, total, step) => status.update(describeStep(i, total, step)),
|
|
433
|
+
}
|
|
434
|
+
: {}),
|
|
435
|
+
};
|
|
436
|
+
const print = (receipt, shownPath) => {
|
|
437
|
+
ctx.io.stdout(v.output === 'json'
|
|
438
|
+
? `${JSON.stringify({ receiptPath: shownPath, receipt }, null, 2)}\n`
|
|
439
|
+
: renderReceipt(receipt, shownPath));
|
|
440
|
+
};
|
|
441
|
+
let preview;
|
|
442
|
+
try {
|
|
443
|
+
preview = applyPlan(plan, rows, grants, { ...base, dryRun: true, receiptPath: null });
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
if (err instanceof ApplyArgumentError) {
|
|
447
|
+
if (rows.length === 0 && plan.repos.every((r) => r.rows.length === 0)) {
|
|
448
|
+
if (v.output === 'json')
|
|
449
|
+
print(emptyReceipt(planSource, now), null);
|
|
450
|
+
else
|
|
451
|
+
ctx.io.stdout('nothing to delete\n');
|
|
452
|
+
return ok();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return failure(ctx, err);
|
|
456
|
+
}
|
|
457
|
+
const count = wouldRun(preview);
|
|
458
|
+
if (v.dryRun || count === 0) {
|
|
459
|
+
print(preview, null);
|
|
460
|
+
return ok();
|
|
461
|
+
}
|
|
462
|
+
if (!v.yes) {
|
|
463
|
+
// The preview is for the person answering, so it goes where the question does.
|
|
464
|
+
const show = v.output === 'json' ? ctx.io.stderr : ctx.io.stdout;
|
|
465
|
+
show(renderReceipt(preview, null));
|
|
466
|
+
const answer = (deps.confirm ?? terminalConfirm)(`Delete ${count} item${count === 1 ? '' : 's'}?`);
|
|
467
|
+
if (answer === null) {
|
|
468
|
+
return usageError(ctx, name, 'refusing to delete without a terminal to ask; pass --yes');
|
|
469
|
+
}
|
|
470
|
+
if (!answer) {
|
|
471
|
+
ctx.io.stderr('nothing deleted\n');
|
|
472
|
+
return EXIT.ok;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// Only what the preview showed: rows it dropped stay dropped.
|
|
476
|
+
// Pinned to their repositories: a branch name two repositories share stays two rows.
|
|
477
|
+
const approved = preview.steps.flatMap((s) => s.row === null ? [] : [{ ...s.row, repo: s.repo }]);
|
|
478
|
+
const approvedGrants = grants.filter((g) => g.row === null || approved.some((r) => r.category === g.row.category && r.id === g.row.id));
|
|
276
479
|
let receipt;
|
|
277
480
|
try {
|
|
278
|
-
receipt = applyPlan(
|
|
279
|
-
|
|
280
|
-
dryRun:
|
|
481
|
+
receipt = applyPlan(plan, approved, approvedGrants, {
|
|
482
|
+
...base,
|
|
483
|
+
dryRun: false,
|
|
281
484
|
receiptPath,
|
|
282
|
-
|
|
283
|
-
repoRoot,
|
|
485
|
+
dropped: preview.dropped,
|
|
284
486
|
});
|
|
285
487
|
}
|
|
286
488
|
catch (err) {
|
|
287
489
|
return failure(ctx, err);
|
|
288
490
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
491
|
+
print(receipt, receiptPath);
|
|
492
|
+
return receipt.status === 'failed' ? EXIT_STEP_FAILED : ok();
|
|
493
|
+
}
|
|
494
|
+
function emptyReceipt(plan, now) {
|
|
495
|
+
const at = now.toISOString();
|
|
496
|
+
return {
|
|
497
|
+
version: 1,
|
|
498
|
+
plan,
|
|
499
|
+
status: 'dry-run',
|
|
500
|
+
started_at: at,
|
|
501
|
+
finished_at: at,
|
|
502
|
+
dropped: [],
|
|
503
|
+
steps: [],
|
|
504
|
+
};
|
|
294
505
|
}
|
|
295
506
|
/** `main(argv, ctx)`: returns an exit code, never exits. */
|
|
296
|
-
export function main(argv, ctx) {
|
|
507
|
+
export function main(argv, ctx, deps = {}) {
|
|
508
|
+
const terminal = deps.stderrIsTerminal ?? isatty(2);
|
|
509
|
+
const status = terminal
|
|
510
|
+
? statusLine(ctx.io.stderr, () => process.stderr.columns || 80)
|
|
511
|
+
: NO_STATUS_LINE;
|
|
512
|
+
// Anything else written erases the status line first, so it never trails into output.
|
|
513
|
+
const io = {
|
|
514
|
+
stdout: (text) => {
|
|
515
|
+
status.clear();
|
|
516
|
+
ctx.io.stdout(text);
|
|
517
|
+
},
|
|
518
|
+
stderr: (text) => {
|
|
519
|
+
status.clear();
|
|
520
|
+
ctx.io.stderr(text);
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
const c = { ...ctx, io };
|
|
297
524
|
const [sub, ...rest] = argv;
|
|
298
|
-
|
|
525
|
+
try {
|
|
526
|
+
if (sub === 'summary')
|
|
527
|
+
return runSummary(rest, c, status);
|
|
528
|
+
if (sub === 'plan')
|
|
529
|
+
return runPlan(rest, c, status);
|
|
530
|
+
if (sub === 'apply')
|
|
531
|
+
return runApply(rest, c, deps, status);
|
|
532
|
+
if (sub === 'status')
|
|
533
|
+
return runStatus(rest, c, status);
|
|
534
|
+
return runStatus(argv, c, status);
|
|
535
|
+
}
|
|
536
|
+
finally {
|
|
537
|
+
status.clear();
|
|
538
|
+
}
|
|
299
539
|
}
|