@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/clean.js
ADDED
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cleaning up: `check` a plan against an approval, then `run` what it shows.
|
|
3
|
+
*
|
|
4
|
+
* `check` resolves the approval (the rows picked, the rows spared, the risks
|
|
5
|
+
* allowed), refuses one that contradicts itself, sweeps every plan repository
|
|
6
|
+
* again and drops each selected row that is gone, cannot be deleted, moved
|
|
7
|
+
* since the plan, or carries a risk not allowed. It returns the steps a run
|
|
8
|
+
* would take. `run` sweeps once more and takes those steps in a fixed order,
|
|
9
|
+
* stopping at the first failure:
|
|
10
|
+
*
|
|
11
|
+
* 1. spared remote branches, each tip checked with `ls-remote`;
|
|
12
|
+
* 2. worktrees, each unlocked first when its lock's owner is dead, and each
|
|
13
|
+
* checked again for live processes just before it is unlocked or removed;
|
|
14
|
+
* one with submodules is removed with `--force` after its files and every
|
|
15
|
+
* submodule are checked again;
|
|
16
|
+
* 3. local branches;
|
|
17
|
+
* 4. remote branches.
|
|
18
|
+
*
|
|
19
|
+
* A worktree whose directory is gone goes by its own record. One left without
|
|
20
|
+
* its `.git` takes the repository-wide prune, last, and only while that prune
|
|
21
|
+
* would drop nothing the run does not select.
|
|
22
|
+
*
|
|
23
|
+
* Branch deletes are compare-and-swap on the planned sha. Every step is
|
|
24
|
+
* verified, and the receipt is rewritten after each.
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
27
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
28
|
+
import { shellJoin } from '@osdlc/command-seam';
|
|
29
|
+
import { nodeGuard } from '@osdlc/path-guard/node';
|
|
30
|
+
import { Git, PUSH_REJECTIONS } from '@osdlc/typed-git';
|
|
31
|
+
import { localDeleteCommand, remoteDeleteCommand } from "./branches.js";
|
|
32
|
+
import { fingerprint, runnable, sameFingerprint } from "./plan.js";
|
|
33
|
+
import { busyHolders, defaultProcessProbe, parseLockOwner } from "./processes.js";
|
|
34
|
+
import { RECEIPT_VERSION } from "./receipt.js";
|
|
35
|
+
import { defaultCacheDir, mirrorProblem } from "./remote-mirror.js";
|
|
36
|
+
import { inspectSubmodules, submoduleHazards } from "./submodules.js";
|
|
37
|
+
import { holdFreedBranches, sweepRepo } from "./sweep.js";
|
|
38
|
+
import { KINDS, RISKS, RepoCleanerError, parseRowId, sweepNotes, } from "./types.js";
|
|
39
|
+
import { errorText } from "./util.js";
|
|
40
|
+
import { removeIsForced, worktreeCommands } from "./worktrees.js";
|
|
41
|
+
import { writeJsonAtomic } from "./write-json.js";
|
|
42
|
+
/**
|
|
43
|
+
* `kind:name`, or a bare name. A word before a colon that is not a kind is an
|
|
44
|
+
* error (a typo such as `wt:x`), but a path is a bare name whatever colons it
|
|
45
|
+
* holds: a prefix with a path separator in it is never a kind.
|
|
46
|
+
*/
|
|
47
|
+
export function parseRowRef(text) {
|
|
48
|
+
if (text === '')
|
|
49
|
+
throw new RepoCleanerError('bad-input', 'an empty row');
|
|
50
|
+
const id = parseRowId(text);
|
|
51
|
+
if (id !== null) {
|
|
52
|
+
if (id.name === '')
|
|
53
|
+
throw new RepoCleanerError('bad-input', `bad row ${JSON.stringify(text)}: no name`);
|
|
54
|
+
return id;
|
|
55
|
+
}
|
|
56
|
+
const colon = text.indexOf(':');
|
|
57
|
+
if (colon > 1 && !/[\\/]/.test(text.slice(0, colon))) {
|
|
58
|
+
throw new RepoCleanerError('bad-input', `bad row ${JSON.stringify(text)}: expected <kind>:<name> with kind one of ${KINDS.join(', ')}, or a bare name`);
|
|
59
|
+
}
|
|
60
|
+
return { kind: null, name: text };
|
|
61
|
+
}
|
|
62
|
+
/** `<risk>` for every row, or `<risk>=<row>` for one. */
|
|
63
|
+
export function parseAllow(text) {
|
|
64
|
+
const eq = text.indexOf('=');
|
|
65
|
+
const risk = eq < 0 ? text : text.slice(0, eq);
|
|
66
|
+
checkRisk(risk);
|
|
67
|
+
if (eq < 0)
|
|
68
|
+
return { risk };
|
|
69
|
+
const row = text.slice(eq + 1);
|
|
70
|
+
if (row === '')
|
|
71
|
+
throw new RepoCleanerError('bad-input', `--allow ${text} names no row`);
|
|
72
|
+
return { risk, row };
|
|
73
|
+
}
|
|
74
|
+
function checkRisk(risk) {
|
|
75
|
+
if (!RISKS.includes(risk)) {
|
|
76
|
+
throw new RepoCleanerError('bad-input', `unknown risk ${JSON.stringify(risk)}: expected one of ${RISKS.join(', ')}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** A conflict for a reference no sweep with `options` could hold: a kind it left out, or a row outside its `--branch`. */
|
|
80
|
+
function checkScope(options, ref, flag, text) {
|
|
81
|
+
const { only, branch } = options;
|
|
82
|
+
if (ref.kind !== null && !only.includes(ref.kind)) {
|
|
83
|
+
throw new RepoCleanerError('conflict', `${flag} ${text}: the sweep has no ${ref.kind} rows (it sweeps ${only.join(', ')})`);
|
|
84
|
+
}
|
|
85
|
+
const outside = branch !== null &&
|
|
86
|
+
((ref.kind === 'branch' && ref.name !== branch) ||
|
|
87
|
+
(ref.kind === 'remote' && !ref.name.endsWith(`/${branch}`)));
|
|
88
|
+
if (outside) {
|
|
89
|
+
throw new RepoCleanerError('conflict', `${flag} ${text} is not a row of --branch ${branch}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* What can be refused of `approval` before anything is swept: a malformed
|
|
94
|
+
* row or risk, a row a sweep with `options` could never hold, and a row
|
|
95
|
+
* picked and spared as written. A row named two ways, and an `allow` for a
|
|
96
|
+
* spared or unpicked row, are found when the approval is resolved against
|
|
97
|
+
* the plan.
|
|
98
|
+
*/
|
|
99
|
+
export function checkApproval(options, approval) {
|
|
100
|
+
const refs = [
|
|
101
|
+
...(approval.pick ?? []).map((t) => ['--pick', t]),
|
|
102
|
+
...(approval.spare ?? []).map((t) => ['--spare', t]),
|
|
103
|
+
];
|
|
104
|
+
for (const a of approval.allow ?? []) {
|
|
105
|
+
const { risk, row } = typeof a === 'string' ? { risk: a, row: undefined } : a;
|
|
106
|
+
checkRisk(risk);
|
|
107
|
+
if (row !== undefined)
|
|
108
|
+
refs.push(['--allow', row]);
|
|
109
|
+
}
|
|
110
|
+
for (const [flag, text] of refs)
|
|
111
|
+
checkScope(options, parseRowRef(text), flag, text);
|
|
112
|
+
const both = (approval.pick ?? []).find((p) => (approval.spare ?? []).includes(p));
|
|
113
|
+
if (both !== undefined)
|
|
114
|
+
throw new RepoCleanerError('conflict', `${both} is both picked and spared`);
|
|
115
|
+
}
|
|
116
|
+
/** The names a worktree reference may match: as written, against `cwd`, and resolved through symlinks. */
|
|
117
|
+
function pathForms(name, cwd) {
|
|
118
|
+
const abs = isAbsolute(name) ? name : resolve(cwd, name);
|
|
119
|
+
const forms = new Set([name, abs]);
|
|
120
|
+
try {
|
|
121
|
+
forms.add(realpathSync(abs));
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// A gone worktree has no real path; the plan names it as git did.
|
|
125
|
+
}
|
|
126
|
+
return forms;
|
|
127
|
+
}
|
|
128
|
+
/** The plan row `text` names, its scope checked first so a row the plan could never hold is a conflict, not a miss. */
|
|
129
|
+
function resolveRef(plan, text, flag, cwd) {
|
|
130
|
+
const ref = parseRowRef(text);
|
|
131
|
+
checkScope(plan.options, ref, flag, text);
|
|
132
|
+
const paths = ref.kind === null || ref.kind === 'worktree' ? pathForms(ref.name, cwd) : null;
|
|
133
|
+
const hits = [];
|
|
134
|
+
for (const repo of plan.repos) {
|
|
135
|
+
for (const row of repo.rows) {
|
|
136
|
+
const { kind, name } = parseRowId(row.id);
|
|
137
|
+
if (ref.kind !== null && ref.kind !== kind)
|
|
138
|
+
continue;
|
|
139
|
+
if (kind === 'worktree' ? paths.has(name) : ref.name === name) {
|
|
140
|
+
hits.push({ repo: repo.root, id: row.id });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (hits.length === 0) {
|
|
145
|
+
throw new RepoCleanerError('not-found', `${flag} ${text}: no such row in the plan`);
|
|
146
|
+
}
|
|
147
|
+
if (hits.length > 1) {
|
|
148
|
+
const where = [...new Set(hits.map((h) => `${h.id} in ${h.repo}`))].join(', ');
|
|
149
|
+
throw new RepoCleanerError('ambiguous', `${flag} ${text} matches ${hits.length} rows (${where}); sweep or plan only the repository it is in${ref.kind === null ? ', or name it by its full id' : ''}`);
|
|
150
|
+
}
|
|
151
|
+
return hits[0];
|
|
152
|
+
}
|
|
153
|
+
const keyOf = (r) => `${r.repo}\0${r.id}`;
|
|
154
|
+
const same = (a, b) => a.repo === b.repo && a.id === b.id;
|
|
155
|
+
const uniq = (rows) => [...new Map(rows.map((r) => [keyOf(r), r])).values()];
|
|
156
|
+
/**
|
|
157
|
+
* `approval` resolved against `plan`, or a {@link RepoCleanerError} for one
|
|
158
|
+
* that contradicts itself or names rows the plan does not have.
|
|
159
|
+
*/
|
|
160
|
+
export function resolveApproval(plan, approval, cwd) {
|
|
161
|
+
checkApproval(plan.options, approval);
|
|
162
|
+
const pick = uniq((approval.pick ?? []).map((t) => resolveRef(plan, t, '--pick', cwd)));
|
|
163
|
+
const spare = uniq((approval.spare ?? []).map((t) => resolveRef(plan, t, '--spare', cwd)));
|
|
164
|
+
const picked = new Set(pick.map(keyOf));
|
|
165
|
+
const spared = new Set(spare.map(keyOf));
|
|
166
|
+
const both = pick.find((p) => spared.has(keyOf(p)));
|
|
167
|
+
if (both !== undefined)
|
|
168
|
+
throw new RepoCleanerError('conflict', `${both.id} is both picked and spared`);
|
|
169
|
+
const allow = (approval.allow ?? []).map((a) => {
|
|
170
|
+
const { risk, row } = typeof a === 'string' ? { risk: a, row: undefined } : a;
|
|
171
|
+
if (row === undefined)
|
|
172
|
+
return { risk, row: null };
|
|
173
|
+
const at = resolveRef(plan, row, '--allow', cwd);
|
|
174
|
+
if (spared.has(keyOf(at))) {
|
|
175
|
+
throw new RepoCleanerError('conflict', `--allow ${risk}=${row}: ${at.id} is spared`);
|
|
176
|
+
}
|
|
177
|
+
if (pick.length > 0 && !picked.has(keyOf(at))) {
|
|
178
|
+
throw new RepoCleanerError('conflict', `--allow ${risk}=${row}: ${at.id} is not picked`);
|
|
179
|
+
}
|
|
180
|
+
return { risk, row: at };
|
|
181
|
+
});
|
|
182
|
+
return { pick, spare, allow };
|
|
183
|
+
}
|
|
184
|
+
function allowed(allow, at, risk) {
|
|
185
|
+
return allow.some((a) => a.risk === risk && (a.row === null || same(a.row, at)));
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The plan repository's rows as they stand now, annotated as the sweep
|
|
189
|
+
* annotated them, and, when it had to sweep again, that sweep's notes on what
|
|
190
|
+
* it judged without. A sweep passed in reports its notes whole, in `recheck`.
|
|
191
|
+
*/
|
|
192
|
+
function resweep(repo, plan, kinds, opts) {
|
|
193
|
+
const branchScoped = plan.options.branch !== null;
|
|
194
|
+
const given = opts.sweep?.repos.find((r) => r.repo?.root === repo.root);
|
|
195
|
+
// An offline sweep read remote tips from disk, so it cannot stand in for
|
|
196
|
+
// the re-check of remote rows: sweep again and ask the remote, so the
|
|
197
|
+
// preview shows what the run will do.
|
|
198
|
+
const readFromDisk = kinds.includes('remote') && given?.repo?.fetched !== true;
|
|
199
|
+
if (given !== undefined && given.error === null && !readFromDisk) {
|
|
200
|
+
// Its notes are reported with the rest of that sweep's, by `recheck`.
|
|
201
|
+
return { rows: holdFreedBranches([...given.rows], branchScoped), notes: [] };
|
|
202
|
+
}
|
|
203
|
+
const swept = sweepRepo(repo.root, {
|
|
204
|
+
...opts,
|
|
205
|
+
only: kinds,
|
|
206
|
+
against: plan.options.against ?? undefined,
|
|
207
|
+
provider: plan.options.provider,
|
|
208
|
+
fetch: repo.fetched,
|
|
209
|
+
// Offline, the plan read tracking refs that can be stale; before anything
|
|
210
|
+
// is deleted on the remote, ask the remote itself.
|
|
211
|
+
liveRemoteTips: kinds.includes('remote'),
|
|
212
|
+
branch: plan.options.branch ?? undefined,
|
|
213
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
214
|
+
});
|
|
215
|
+
if (swept.repo === null || swept.error !== null) {
|
|
216
|
+
throw new Error(`${repo.root}: ${swept.error ?? 'could not read the repository'}`);
|
|
217
|
+
}
|
|
218
|
+
return { rows: swept.rows, notes: swept.notes };
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Re-sweep, then split the selection into rows to run and rows dropped. With
|
|
222
|
+
* no pick every plan row is selected. `safeOnly` drops each one the re-sweep
|
|
223
|
+
* does not judge `delete`.
|
|
224
|
+
*/
|
|
225
|
+
function recheck(plan, approval, opts, safeOnly) {
|
|
226
|
+
const spared = new Set(approval.spare.map(keyOf));
|
|
227
|
+
// With no pick, every row that could run: a plan also holds the blocked and
|
|
228
|
+
// structural rows, so a pick of one can be skipped with its reason.
|
|
229
|
+
const selected = approval.pick.length === 0
|
|
230
|
+
? plan.repos.flatMap((r) => r.rows
|
|
231
|
+
.filter(runnable)
|
|
232
|
+
.map((row) => ({ repo: r.root, id: row.id }))
|
|
233
|
+
.filter((s) => !spared.has(keyOf(s))))
|
|
234
|
+
: approval.pick;
|
|
235
|
+
const all = [...selected, ...approval.spare];
|
|
236
|
+
const plannedRows = new Map(plan.repos.flatMap((r) => r.rows.map((row) => [keyOf({ repo: r.root, id: row.id }), row])));
|
|
237
|
+
const kindsIn = new Map();
|
|
238
|
+
for (const s of all) {
|
|
239
|
+
const kinds = kindsIn.get(s.repo) ?? new Set();
|
|
240
|
+
kinds.add(parseRowId(s.id).kind);
|
|
241
|
+
kindsIn.set(s.repo, kinds);
|
|
242
|
+
}
|
|
243
|
+
const currentRows = new Map();
|
|
244
|
+
const notes = [];
|
|
245
|
+
for (const [root, wanted] of kindsIn) {
|
|
246
|
+
const repo = plan.repos.find((r) => r.root === root);
|
|
247
|
+
// Only the kinds the selection is in: a plan of local rows need not
|
|
248
|
+
// compare every remote branch again. A branch brings its worktrees, which
|
|
249
|
+
// decide whether it is free to go or held.
|
|
250
|
+
if (wanted.has('branch') && plan.options.only.includes('worktree'))
|
|
251
|
+
wanted.add('worktree');
|
|
252
|
+
const now = resweep(repo, plan, KINDS.filter((k) => wanted.has(k)), opts);
|
|
253
|
+
for (const row of now.rows)
|
|
254
|
+
currentRows.set(keyOf({ repo: root, id: row.id }), row);
|
|
255
|
+
for (const note of now.notes)
|
|
256
|
+
notes.push({ repo: root, note });
|
|
257
|
+
}
|
|
258
|
+
const planned = (s) => plannedRows.get(keyOf(s));
|
|
259
|
+
const current = (s) => currentRows.get(keyOf(s));
|
|
260
|
+
// A refusal ends the check before its notes are shown, so it carries them.
|
|
261
|
+
const alsoNoted = (root) => {
|
|
262
|
+
const said = notes.filter((n) => n.repo === root).map((n) => n.note);
|
|
263
|
+
return said.length === 0 ? '' : ` (${said.join(' ')})`;
|
|
264
|
+
};
|
|
265
|
+
// A spared remote branch is checked, not dropped: one that moved refuses the run.
|
|
266
|
+
const verified = [];
|
|
267
|
+
for (const s of approval.spare) {
|
|
268
|
+
if (parseRowId(s.id).kind !== 'remote')
|
|
269
|
+
continue;
|
|
270
|
+
const row = current(s);
|
|
271
|
+
const plannedSha = planned(s).fingerprint['sha'];
|
|
272
|
+
if (row === undefined || row.kind !== 'remote') {
|
|
273
|
+
throw new RepoCleanerError('spared-changed', `spared ${s.id} is no longer on the remote; refusing to run${alsoNoted(s.repo)}`);
|
|
274
|
+
}
|
|
275
|
+
if (typeof plannedSha !== 'string' || row.evidence.sha !== plannedSha) {
|
|
276
|
+
throw new RepoCleanerError('spared-changed', `spared ${s.id} moved since the plan (planned ${String(plannedSha)}, now ${row.evidence.sha}); refusing to run`);
|
|
277
|
+
}
|
|
278
|
+
verified.push({ repo: s.repo, row, discard: false, abandon: false });
|
|
279
|
+
}
|
|
280
|
+
const accepted = [];
|
|
281
|
+
const dropped = [];
|
|
282
|
+
for (const s of selected) {
|
|
283
|
+
const drop = (reason, detail) => {
|
|
284
|
+
dropped.push({ repo: s.repo, row: s.id, reason, detail });
|
|
285
|
+
};
|
|
286
|
+
const row = current(s);
|
|
287
|
+
if (row === undefined) {
|
|
288
|
+
drop('gone', 'no longer in the sweep');
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (!runnable(row)) {
|
|
292
|
+
drop(row.verdict === 'blocked' ? 'blocked' : 'keep', row.why);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (safeOnly && row.verdict !== 'delete') {
|
|
296
|
+
drop('needs-pick', `${row.verdict}: ${row.why}; pick it to run it`);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const now = fingerprint(row);
|
|
300
|
+
const was = planned(s).fingerprint;
|
|
301
|
+
if (!sameFingerprint(was, now)) {
|
|
302
|
+
drop('changed', `planned ${JSON.stringify(was)}, now ${JSON.stringify(now)}`);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const missing = row.risks.filter((risk) => !allowed(approval.allow, s, risk));
|
|
306
|
+
if (missing.length > 0) {
|
|
307
|
+
drop('not-allowed', `needs ${missing.join(', ')}`);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
accepted.push({
|
|
311
|
+
repo: s.repo,
|
|
312
|
+
row,
|
|
313
|
+
discard: allowed(approval.allow, s, 'discard-changes'),
|
|
314
|
+
abandon: allowed(approval.allow, s, 'abandon-unmerged'),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
// A branch freed by its worktree's removal goes only with that worktree.
|
|
318
|
+
const leaving = new Set(accepted.filter((a) => a.row.kind === 'worktree').map((a) => a.row.name));
|
|
319
|
+
const kept = [];
|
|
320
|
+
for (const a of accepted) {
|
|
321
|
+
const worktree = a.row.kind === 'branch' ? a.row.evidence.worktree : undefined;
|
|
322
|
+
if (worktree === undefined || leaving.has(worktree)) {
|
|
323
|
+
kept.push(a);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
dropped.push({
|
|
327
|
+
repo: a.repo,
|
|
328
|
+
row: a.row.id,
|
|
329
|
+
reason: 'held',
|
|
330
|
+
detail: `checked out in ${worktree}, which this run does not remove`,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
// A sweep passed in speaks for every repository it read, selected or not: a
|
|
334
|
+
// repository with nothing to run is where a missing listing matters most.
|
|
335
|
+
return {
|
|
336
|
+
accepted: kept,
|
|
337
|
+
dropped,
|
|
338
|
+
verified,
|
|
339
|
+
notes: opts.sweep === undefined ? notes : uniqueNotes([...sweepNotes(opts.sweep), ...notes]),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
/** Notes once each: a repository the given sweep read offline is swept again, and can repeat one. */
|
|
343
|
+
function uniqueNotes(notes) {
|
|
344
|
+
const seen = new Set();
|
|
345
|
+
return notes.filter((n) => {
|
|
346
|
+
const key = `${n.repo}\0${n.note}`;
|
|
347
|
+
if (seen.has(key))
|
|
348
|
+
return false;
|
|
349
|
+
seen.add(key);
|
|
350
|
+
return true;
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
// ── one row per remote branch ──────────────────────────────────────────────
|
|
354
|
+
/**
|
|
355
|
+
* A network URL as `host[:port]/path`, the same for `git@host:o/r.git`,
|
|
356
|
+
* `ssh://git@host/o/r` and `https://host/o/r/`; the whole path is kept, so
|
|
357
|
+
* nested groups stay apart. Null for a path on this machine, including
|
|
358
|
+
* `file://` and a Windows drive path.
|
|
359
|
+
*/
|
|
360
|
+
export function networkRemoteKey(url) {
|
|
361
|
+
const u = url.trim();
|
|
362
|
+
if (/^[a-z]:[\\/]/i.test(u) || /^file:/i.test(u))
|
|
363
|
+
return null;
|
|
364
|
+
const uri = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::(\d+))?(\/.*)?$/i.exec(u);
|
|
365
|
+
const scp = uri === null ? /^(?:[^@/:]+@)?([^/:]+):(?!\/\/)(.+)$/.exec(u) : null;
|
|
366
|
+
const host = uri?.[1] ?? scp?.[1];
|
|
367
|
+
if (host === undefined)
|
|
368
|
+
return null;
|
|
369
|
+
const port = uri?.[2] === undefined ? '' : `:${uri[2]}`;
|
|
370
|
+
const path = (uri?.[3] ?? scp?.[2] ?? '').replace(/^\/+|\/+$/g, '').replace(/\.git$/, '');
|
|
371
|
+
return `${host.toLowerCase()}${port}/${path}`;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* One string per remote repository, so two clones that push to the same one
|
|
375
|
+
* agree. Built from the push URLs git would use (`remote get-url --push
|
|
376
|
+
* --all`, which applies `url.*.insteadOf` and `pushInsteadOf`). Null when git
|
|
377
|
+
* reports no URL; such a row is never treated as a duplicate.
|
|
378
|
+
*/
|
|
379
|
+
function remoteKey(git, repo, remote) {
|
|
380
|
+
const urls = git.at(repo).try.remoteGetUrls(remote, { push: true });
|
|
381
|
+
if (!urls.ok || urls.value === null || urls.value.length === 0)
|
|
382
|
+
return null;
|
|
383
|
+
const keys = urls.value.map((url) => networkRemoteKey(url) ?? resolve(repo, url.trim().replace(/^file:\/\/(localhost)?/i, '')));
|
|
384
|
+
return [...new Set(keys)].sort().join('\n');
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Keep the first row for each remote branch. Two clones of one remote each
|
|
388
|
+
* list its branches; deleting one twice fails the second push and stops the
|
|
389
|
+
* run, so the later row is dropped as `duplicate`, and one spared through
|
|
390
|
+
* another clone as `spared`.
|
|
391
|
+
*/
|
|
392
|
+
function onePerRemoteBranch(r, git) {
|
|
393
|
+
// One `remote get-url` per repository and remote, not per row.
|
|
394
|
+
const remotes = new Map();
|
|
395
|
+
const branchKey = (a) => {
|
|
396
|
+
if (a.row.kind !== 'remote')
|
|
397
|
+
return null;
|
|
398
|
+
const at = `${a.repo}\0${a.row.evidence.remote}`;
|
|
399
|
+
if (!remotes.has(at))
|
|
400
|
+
remotes.set(at, remoteKey(git, a.repo, a.row.evidence.remote));
|
|
401
|
+
const remote = remotes.get(at);
|
|
402
|
+
return remote === null ? null : `${remote}\0${a.row.evidence.branch}`;
|
|
403
|
+
};
|
|
404
|
+
const sparedAt = new Map();
|
|
405
|
+
for (const a of r.verified) {
|
|
406
|
+
const key = branchKey(a);
|
|
407
|
+
if (key !== null)
|
|
408
|
+
sparedAt.set(key, a.repo);
|
|
409
|
+
}
|
|
410
|
+
const seen = new Map();
|
|
411
|
+
const kept = [];
|
|
412
|
+
const extra = [];
|
|
413
|
+
for (const a of r.accepted) {
|
|
414
|
+
if (a.row.kind !== 'remote') {
|
|
415
|
+
kept.push(a);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const key = branchKey(a);
|
|
419
|
+
const spared = key === null ? undefined : sparedAt.get(key);
|
|
420
|
+
if (spared !== undefined) {
|
|
421
|
+
extra.push({
|
|
422
|
+
repo: a.repo,
|
|
423
|
+
row: a.row.id,
|
|
424
|
+
reason: 'spared',
|
|
425
|
+
detail: `the same remote branch is spared through ${spared}`,
|
|
426
|
+
});
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
const first = key === null ? undefined : seen.get(key);
|
|
430
|
+
if (first !== undefined) {
|
|
431
|
+
extra.push({
|
|
432
|
+
repo: a.repo,
|
|
433
|
+
row: a.row.id,
|
|
434
|
+
reason: 'duplicate',
|
|
435
|
+
detail: `the same remote branch is deleted from ${first}`,
|
|
436
|
+
});
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (key !== null)
|
|
440
|
+
seen.set(key, a.repo);
|
|
441
|
+
kept.push(a);
|
|
442
|
+
}
|
|
443
|
+
return { accepted: kept, dropped: [...r.dropped, ...extra] };
|
|
444
|
+
}
|
|
445
|
+
// ── steps ──────────────────────────────────────────────────────────────────
|
|
446
|
+
function pending(repo, row, action, command, expectedSha = null) {
|
|
447
|
+
return {
|
|
448
|
+
repo,
|
|
449
|
+
row,
|
|
450
|
+
action,
|
|
451
|
+
command,
|
|
452
|
+
expectedSha,
|
|
453
|
+
result: 'pending',
|
|
454
|
+
error: null,
|
|
455
|
+
verification: null,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
function verifyStep({ repo, row }) {
|
|
459
|
+
const r = row;
|
|
460
|
+
return pending(repo, r.id, 'verify-remote', shellJoin(['git', 'ls-remote', r.evidence.remote, `refs/heads/${r.evidence.branch}`]), r.evidence.sha);
|
|
461
|
+
}
|
|
462
|
+
function stepsFor({ repo, row }) {
|
|
463
|
+
switch (row.kind) {
|
|
464
|
+
case 'worktree': {
|
|
465
|
+
// The row's own commands: a stale lock's unlock, then the remove or prune.
|
|
466
|
+
const commands = worktreeCommands(repo, row.evidence, row.action === 'prune' ? 'prune' : 'remove');
|
|
467
|
+
const last = commands.at(-1);
|
|
468
|
+
return [
|
|
469
|
+
...(commands.length > 1 ? [pending(repo, row.id, 'unlock-worktree', commands[0])] : []),
|
|
470
|
+
pending(repo, row.id, row.action === 'prune' ? 'prune-worktrees' : 'remove-worktree', last),
|
|
471
|
+
];
|
|
472
|
+
}
|
|
473
|
+
case 'branch':
|
|
474
|
+
return [
|
|
475
|
+
pending(repo, row.id, 'delete-branch', localDeleteCommand(row.name, row.evidence.sha), row.evidence.sha),
|
|
476
|
+
];
|
|
477
|
+
case 'remote':
|
|
478
|
+
return [
|
|
479
|
+
pending(repo, row.id, 'delete-remote', remoteDeleteCommand(row.evidence.remote, row.evidence.branch, row.evidence.sha), row.evidence.sha),
|
|
480
|
+
];
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
function planSteps(plan, approval, opts, safeOnly) {
|
|
484
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
485
|
+
const git = new Git(cwd, { runner: opts.git });
|
|
486
|
+
const rechecked = recheck(plan, approval, opts, safeOnly);
|
|
487
|
+
const unique = onePerRemoteBranch(rechecked, git);
|
|
488
|
+
const dropped = [...unique.dropped];
|
|
489
|
+
// A worktree whose directory is left without its `.git` goes only by the
|
|
490
|
+
// repository-wide `git worktree prune`, which drops every gone worktree.
|
|
491
|
+
// It runs last, and only while every other gone worktree it would drop goes
|
|
492
|
+
// in this run anyway: a prune must not take a row the run did not select.
|
|
493
|
+
const removedHere = new Set(unique.accepted.filter((a) => a.row.kind === 'worktree').map((a) => resolve(a.row.name)));
|
|
494
|
+
const accepted = unique.accepted.filter((a) => {
|
|
495
|
+
if (!prunesAll(a.row))
|
|
496
|
+
return true;
|
|
497
|
+
const others = pruneCollateral(git.at(a.repo), removedHere);
|
|
498
|
+
if (others.length === 0)
|
|
499
|
+
return true;
|
|
500
|
+
dropped.push({
|
|
501
|
+
repo: a.repo,
|
|
502
|
+
row: a.row.id,
|
|
503
|
+
reason: 'blocked',
|
|
504
|
+
detail: `pruning it would also drop ${others.join(', ')}, which this run does not remove`,
|
|
505
|
+
});
|
|
506
|
+
return false;
|
|
507
|
+
});
|
|
508
|
+
const ordered = KINDS.flatMap((kind) => {
|
|
509
|
+
const of = accepted.filter((a) => a.row.kind === kind);
|
|
510
|
+
return [...of.filter((a) => !prunesAll(a.row)), ...of.filter((a) => prunesAll(a.row))];
|
|
511
|
+
});
|
|
512
|
+
const steps = [
|
|
513
|
+
// Spared tips first: one that moved stops the run before anything is deleted.
|
|
514
|
+
...rechecked.verified.map((a) => [verifyStep(a), a]),
|
|
515
|
+
...ordered.flatMap((a) => stepsFor(a).map((step) => [step, a])),
|
|
516
|
+
];
|
|
517
|
+
return { steps, dropped, accepted: ordered, removedHere, notes: rechecked.notes };
|
|
518
|
+
}
|
|
519
|
+
/** A worktree row whose deletion takes the repository-wide prune: its directory is left without its `.git`. */
|
|
520
|
+
function prunesAll(row) {
|
|
521
|
+
return row.kind === 'worktree' && row.action === 'prune' && row.evidence.directoryLeft === true;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* The gone worktrees a `git worktree prune` in `at` would drop besides those
|
|
525
|
+
* in `removed`: every one whose checkout is gone and that is not locked
|
|
526
|
+
* (prune skips a locked one).
|
|
527
|
+
*/
|
|
528
|
+
function pruneCollateral(at, removed) {
|
|
529
|
+
return at
|
|
530
|
+
.worktrees()
|
|
531
|
+
.filter((w) => !w.locked && !existsSync(join(w.path, '.git')) && !removed.has(resolve(w.path)))
|
|
532
|
+
.map((w) => w.path);
|
|
533
|
+
}
|
|
534
|
+
/** Plan repositories kept in the remote URL cache must still push only where they fetch from. */
|
|
535
|
+
function checkMirrors(plan, cacheDir) {
|
|
536
|
+
for (const repo of plan.repos) {
|
|
537
|
+
if (!nodeGuard.contains(cacheDir, repo.root))
|
|
538
|
+
continue;
|
|
539
|
+
const problem = mirrorProblem(repo.root);
|
|
540
|
+
if (problem !== null)
|
|
541
|
+
throw new RepoCleanerError('bad-plan', `${problem}; refusing to run`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* What running `plan` under `approval` would do now: sweeps every repository
|
|
546
|
+
* the selection is in again (or reads `opts.sweep`), and returns the steps
|
|
547
|
+
* and the rows dropped, with why. Deletes nothing.
|
|
548
|
+
*
|
|
549
|
+
* @throws RepoCleanerError for an approval that contradicts itself, names a
|
|
550
|
+
* row the plan does not have or names one ambiguously, or spares a remote
|
|
551
|
+
* branch that moved. A plain `Error`, naming the repository, when the
|
|
552
|
+
* re-check cannot read one: a failed fetch, or a remote `ls-remote` cannot
|
|
553
|
+
* reach. Nothing has been touched.
|
|
554
|
+
*/
|
|
555
|
+
export function check(plan, approval, opts = {}) {
|
|
556
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
557
|
+
checkMirrors(plan, opts.cacheDir ?? defaultCacheDir());
|
|
558
|
+
const resolved = resolveApproval(plan, approval, cwd);
|
|
559
|
+
const { steps, dropped, accepted, notes } = planSteps(plan, resolved, opts, resolved.pick.length === 0);
|
|
560
|
+
for (const [step] of steps)
|
|
561
|
+
step.result = 'would-run';
|
|
562
|
+
const unusedAllows = resolved.allow
|
|
563
|
+
.filter((a) => !accepted.some((row) => row.row.risks.includes(a.risk) &&
|
|
564
|
+
(a.row === null || same(a.row, { repo: row.repo, id: row.row.id }))))
|
|
565
|
+
.map((a) => ({ risk: a.risk, row: a.row?.id ?? null }));
|
|
566
|
+
return { plan, approval: resolved, steps: steps.map(([s]) => s), dropped, unusedAllows, notes };
|
|
567
|
+
}
|
|
568
|
+
// ── the run ────────────────────────────────────────────────────────────────
|
|
569
|
+
/**
|
|
570
|
+
* Refuse while a process other than an idle shell works inside `path`. Reads
|
|
571
|
+
* the process table afresh; a table that cannot be read refuses too.
|
|
572
|
+
*/
|
|
573
|
+
function assertNoLiveProcess(path, probe) {
|
|
574
|
+
let busy;
|
|
575
|
+
try {
|
|
576
|
+
busy = busyHolders(probe.holders(path, { fresh: true }));
|
|
577
|
+
}
|
|
578
|
+
catch (err) {
|
|
579
|
+
throw new Error(`refused: cannot tell whether a process runs in ${path}: ${errorText(err)}`);
|
|
580
|
+
}
|
|
581
|
+
if (busy.length > 0) {
|
|
582
|
+
throw new Error(`refused: running in ${path}: ${busy.map((h) => `${h.command} (pid ${h.pid})`).join(', ')}`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Refuse a forced remove that would now lose something not allowed. The force
|
|
587
|
+
* skips git's own check, so this one stands in for it: the worktree's own
|
|
588
|
+
* files must be clean unless `discard-changes` was allowed, and every
|
|
589
|
+
* submodule clean (or `discard-changes`), pushed (or `abandon-unmerged`) and
|
|
590
|
+
* readable, read afresh.
|
|
591
|
+
*/
|
|
592
|
+
function assertSafeToForce(path, git, a) {
|
|
593
|
+
const reasons = [];
|
|
594
|
+
if (!a.discard) {
|
|
595
|
+
const status = git.at(path).statusPorcelain();
|
|
596
|
+
if (status.length > 0) {
|
|
597
|
+
reasons.push(`${path} has ${status.length} modified or untracked file(s) since the sweep`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const report = inspectSubmodules(git, path);
|
|
601
|
+
reasons.push(...submoduleHazards(report, { discard: a.discard, abandon: a.abandon }));
|
|
602
|
+
if (reasons.length > 0)
|
|
603
|
+
throw new Error(`refused: ${reasons.join('; ')}`);
|
|
604
|
+
}
|
|
605
|
+
/** A remote step that found the branch already gone: its verification, marked so. */
|
|
606
|
+
class Gone {
|
|
607
|
+
verification;
|
|
608
|
+
constructor(verification) {
|
|
609
|
+
this.verification = verification;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Run one step, then confirm it took. Throws with the reason on failure.
|
|
614
|
+
*/
|
|
615
|
+
function execute(step, a, git, probe, removedHere) {
|
|
616
|
+
const at = git.at(step.repo);
|
|
617
|
+
const remoteTip = (remote, ref) => at.lsRemote(remote, ref).find((r) => r.ref === ref);
|
|
618
|
+
const registered = (path) => at.worktrees().some((w) => resolve(w.path) === resolve(path));
|
|
619
|
+
const row = a?.row;
|
|
620
|
+
switch (step.action) {
|
|
621
|
+
case 'unlock-worktree': {
|
|
622
|
+
if (row?.kind !== 'worktree')
|
|
623
|
+
throw new Error('unlock without a worktree');
|
|
624
|
+
const path = row.name;
|
|
625
|
+
// The sweep that accepted this row is behind us; ask again just before
|
|
626
|
+
// acting, so an owner that came back keeps its lock.
|
|
627
|
+
const owner = parseLockOwner(row.evidence.lockReason ?? null);
|
|
628
|
+
if (owner === null)
|
|
629
|
+
throw new Error(`refused: lock on ${path} names no owner`);
|
|
630
|
+
if (probe.alive(owner.pid, owner.startedAt))
|
|
631
|
+
throw new Error(`refused: lock owner pid ${owner.pid} is alive`);
|
|
632
|
+
if (row.evidence.onDisk !== false)
|
|
633
|
+
assertNoLiveProcess(path, probe);
|
|
634
|
+
at.worktreeUnlock(path);
|
|
635
|
+
const entry = at.worktrees().find((w) => resolve(w.path) === resolve(path));
|
|
636
|
+
if (entry?.locked === true)
|
|
637
|
+
throw new Error(`still locked: ${path}`);
|
|
638
|
+
return 'lock owner dead, unlocked';
|
|
639
|
+
}
|
|
640
|
+
case 'prune-worktrees': {
|
|
641
|
+
if (row?.kind !== 'worktree')
|
|
642
|
+
throw new Error('prune without a worktree');
|
|
643
|
+
// A worktree whose directory is gone goes on its own.
|
|
644
|
+
if (row.evidence.directoryLeft !== true) {
|
|
645
|
+
at.worktreeRemove(row.name);
|
|
646
|
+
if (registered(row.name))
|
|
647
|
+
throw new Error('still registered after remove');
|
|
648
|
+
return 'unregistered';
|
|
649
|
+
}
|
|
650
|
+
// `git worktree prune` drops every gone worktree, not just this row's;
|
|
651
|
+
// asked again, so one that went stale since the check is not taken.
|
|
652
|
+
const others = pruneCollateral(at, removedHere);
|
|
653
|
+
if (others.length > 0) {
|
|
654
|
+
throw new Error(`refused: pruning would also drop ${others.join(', ')}`);
|
|
655
|
+
}
|
|
656
|
+
at.worktreePrune();
|
|
657
|
+
if (registered(row.name))
|
|
658
|
+
throw new Error('still registered after prune');
|
|
659
|
+
return 'unregistered';
|
|
660
|
+
}
|
|
661
|
+
case 'remove-worktree': {
|
|
662
|
+
if (row?.kind !== 'worktree')
|
|
663
|
+
throw new Error('remove without a worktree');
|
|
664
|
+
const path = row.name;
|
|
665
|
+
assertNoLiveProcess(path, probe);
|
|
666
|
+
const force = removeIsForced(row.evidence);
|
|
667
|
+
if (force)
|
|
668
|
+
assertSafeToForce(path, git, a);
|
|
669
|
+
at.worktreeRemove(path, { force });
|
|
670
|
+
if (existsSync(path))
|
|
671
|
+
throw new Error(`directory still exists: ${path}`);
|
|
672
|
+
if (registered(path))
|
|
673
|
+
throw new Error(`still registered: ${path}`);
|
|
674
|
+
return 'directory gone and unregistered';
|
|
675
|
+
}
|
|
676
|
+
case 'delete-branch': {
|
|
677
|
+
if (row?.kind !== 'branch')
|
|
678
|
+
throw new Error('delete-branch without a branch');
|
|
679
|
+
const name = row.name;
|
|
680
|
+
const holder = at.worktrees().find((w) => w.branch === name);
|
|
681
|
+
if (holder !== undefined)
|
|
682
|
+
throw new Error(`refused: checked out in ${holder.path}`);
|
|
683
|
+
at.deleteRef(`refs/heads/${name}`, { expected: step.expectedSha });
|
|
684
|
+
// `update-ref -d` does not remove the `branch.<name>` config section.
|
|
685
|
+
at.configRemoveSection(`branch.${name}`);
|
|
686
|
+
if (at.resolveRef(`refs/heads/${name}`) !== null)
|
|
687
|
+
throw new Error('ref still resolves');
|
|
688
|
+
return 'ref does not resolve';
|
|
689
|
+
}
|
|
690
|
+
case 'verify-remote': {
|
|
691
|
+
if (row?.kind !== 'remote')
|
|
692
|
+
throw new Error('verify-remote without a remote branch');
|
|
693
|
+
const ref = `refs/heads/${row.evidence.branch}`;
|
|
694
|
+
const tip = remoteTip(row.evidence.remote, ref);
|
|
695
|
+
if (tip === undefined)
|
|
696
|
+
throw new Error(`refused: spared ${ref} is gone from ${row.evidence.remote}`);
|
|
697
|
+
if (tip.sha !== step.expectedSha) {
|
|
698
|
+
throw new Error(`refused: spared ${ref} moved to ${tip.sha}, planned ${step.expectedSha}`);
|
|
699
|
+
}
|
|
700
|
+
return 'tip unchanged; spared';
|
|
701
|
+
}
|
|
702
|
+
case 'delete-remote': {
|
|
703
|
+
if (row?.kind !== 'remote')
|
|
704
|
+
throw new Error('delete-remote without a remote branch');
|
|
705
|
+
const { remote } = row.evidence;
|
|
706
|
+
const ref = `refs/heads/${row.evidence.branch}`;
|
|
707
|
+
const report = at.push(remote, `:${ref}`, {
|
|
708
|
+
forceWithLease: { ref, expect: step.expectedSha },
|
|
709
|
+
});
|
|
710
|
+
const refused = report.refs.find((r) => r.flag === '!');
|
|
711
|
+
if (refused?.flag === '!') {
|
|
712
|
+
// Someone deleted the branch between the re-check and this push, so
|
|
713
|
+
// the lease refuses; a ref the remote no longer lists is done. Drop
|
|
714
|
+
// the tracking ref, as a delete push would have.
|
|
715
|
+
if (remoteTip(remote, ref) === undefined) {
|
|
716
|
+
// Compare-and-swap: a tracking ref that moved since is left alone.
|
|
717
|
+
at.try.deleteRef(`refs/remotes/${remote}/${row.evidence.branch}`, {
|
|
718
|
+
expected: step.expectedSha,
|
|
719
|
+
});
|
|
720
|
+
return new Gone(`already gone from ${remote}`);
|
|
721
|
+
}
|
|
722
|
+
const why = refused.rawReason === null ? '' : ` (${refused.rawReason})`;
|
|
723
|
+
throw new Error(`${refused.summary}${why}: ${PUSH_REJECTIONS[refused.rejection].message}`);
|
|
724
|
+
}
|
|
725
|
+
if (remoteTip(remote, ref) !== undefined)
|
|
726
|
+
throw new Error(`ls-remote still lists ${ref}`);
|
|
727
|
+
return 'ls-remote lists nothing';
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Take the steps `preview` showed: sweeps again, drops a row that moved since
|
|
733
|
+
* the preview, and runs the rest in order, compare-and-swap, verifying each
|
|
734
|
+
* step and stopping at the first failure. Only what the preview showed runs; a
|
|
735
|
+
* row it dropped stays dropped.
|
|
736
|
+
*
|
|
737
|
+
* @throws RepoCleanerError (`spared-changed`) when a spared remote branch moved
|
|
738
|
+
* since the preview, and a plain `Error` when the re-check cannot read a
|
|
739
|
+
* repository, as {@link check} does. Nothing has been touched.
|
|
740
|
+
*/
|
|
741
|
+
export function run(preview, opts = {}) {
|
|
742
|
+
const now = opts.now ?? (() => new Date());
|
|
743
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
744
|
+
// Only the rows the preview would run, pinned to their repositories. A
|
|
745
|
+
// spared tip check is not a selection; the approval carries it again.
|
|
746
|
+
const shown = preview.steps.flatMap((s) => s.action === 'verify-remote' ? [] : [{ repo: s.repo, id: s.row }]);
|
|
747
|
+
const pick = uniq(shown);
|
|
748
|
+
const approval = {
|
|
749
|
+
pick,
|
|
750
|
+
spare: preview.approval.spare,
|
|
751
|
+
allow: preview.approval.allow.filter((a) => a.row === null || pick.some((p) => same(p, a.row))),
|
|
752
|
+
};
|
|
753
|
+
const receipt = {
|
|
754
|
+
version: RECEIPT_VERSION,
|
|
755
|
+
plan: opts.source ?? '(memory)',
|
|
756
|
+
status: 'running',
|
|
757
|
+
startedAt: now().toISOString(),
|
|
758
|
+
finishedAt: null,
|
|
759
|
+
dropped: [...preview.dropped],
|
|
760
|
+
steps: [],
|
|
761
|
+
};
|
|
762
|
+
const save = () => {
|
|
763
|
+
if (opts.receiptPath !== undefined)
|
|
764
|
+
writeJsonAtomic(opts.receiptPath, receipt);
|
|
765
|
+
};
|
|
766
|
+
const finish = (status) => {
|
|
767
|
+
receipt.status = status;
|
|
768
|
+
receipt.finishedAt = now().toISOString();
|
|
769
|
+
save();
|
|
770
|
+
return receipt;
|
|
771
|
+
};
|
|
772
|
+
if (pick.length === 0)
|
|
773
|
+
return finish('complete');
|
|
774
|
+
// A preview with no pick ran only `delete` rows; the run keeps that guard,
|
|
775
|
+
// so a row the re-check now judges `review` drops rather than runs.
|
|
776
|
+
const safeOnly = preview.approval.pick.length === 0;
|
|
777
|
+
const planned = planSteps(preview.plan, approval, { ...opts, sweep: undefined }, safeOnly);
|
|
778
|
+
opts.onNotes?.(planned.notes);
|
|
779
|
+
const known = new Set(receipt.dropped.map((d) => `${d.repo}\0${d.row}`));
|
|
780
|
+
for (const d of planned.dropped) {
|
|
781
|
+
if (!known.has(`${d.repo}\0${d.row}`))
|
|
782
|
+
receipt.dropped.push(d);
|
|
783
|
+
}
|
|
784
|
+
const plannedSteps = planned.steps;
|
|
785
|
+
receipt.steps = plannedSteps.map(([s]) => s);
|
|
786
|
+
save();
|
|
787
|
+
opts.onStart?.(receipt.steps);
|
|
788
|
+
const git = new Git(cwd, { runner: opts.git });
|
|
789
|
+
const probe = opts.probe ?? defaultProcessProbe();
|
|
790
|
+
const { removedHere } = planned;
|
|
791
|
+
// Worktrees whose lock this run cleared. typed-git has no `worktree lock`
|
|
792
|
+
// verb, so a failed remove cannot put the lock back; the receipt says so.
|
|
793
|
+
const unlocked = new Set();
|
|
794
|
+
for (const [i, [step, a]] of plannedSteps.entries()) {
|
|
795
|
+
opts.onStep?.(i, plannedSteps.length, step);
|
|
796
|
+
try {
|
|
797
|
+
const done = execute(step, a, git, probe, removedHere);
|
|
798
|
+
if (done instanceof Gone) {
|
|
799
|
+
step.verification = done.verification;
|
|
800
|
+
step.gone = true;
|
|
801
|
+
}
|
|
802
|
+
else {
|
|
803
|
+
step.verification = done;
|
|
804
|
+
}
|
|
805
|
+
step.result = 'ok';
|
|
806
|
+
if (step.action === 'unlock-worktree')
|
|
807
|
+
unlocked.add(a.row.name);
|
|
808
|
+
}
|
|
809
|
+
catch (err) {
|
|
810
|
+
step.result = 'failed';
|
|
811
|
+
step.error = errorText(err);
|
|
812
|
+
if (a !== null && step.action !== 'unlock-worktree' && unlocked.has(a.row.name)) {
|
|
813
|
+
step.error += `; the lock on ${a.row.name} was cleared earlier in this run and is not restored`;
|
|
814
|
+
}
|
|
815
|
+
// Told before the save, so a receipt that cannot be written still
|
|
816
|
+
// leaves the step's outcome on screen.
|
|
817
|
+
opts.onStepDone?.(i, plannedSteps.length, step);
|
|
818
|
+
return finish('failed');
|
|
819
|
+
}
|
|
820
|
+
opts.onStepDone?.(i, plannedSteps.length, step);
|
|
821
|
+
save();
|
|
822
|
+
}
|
|
823
|
+
return finish('complete');
|
|
824
|
+
}
|