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