@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/apply.js
DELETED
|
@@ -1,541 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Apply an approved cleanup plan.
|
|
3
|
-
*
|
|
4
|
-
* Re-sweeps each repository and drops selected rows that are gone, no longer
|
|
5
|
-
* deletable, changed, or lack a grant. Deletes the rest in a fixed order and
|
|
6
|
-
* stops at the first failure:
|
|
7
|
-
*
|
|
8
|
-
* 1. worktrees, each unlocked first when its lock's owner is dead, and each
|
|
9
|
-
* checked again for live processes just before it is unlocked or removed;
|
|
10
|
-
* 2. local branches;
|
|
11
|
-
* 3. remote branches;
|
|
12
|
-
* 4. one prune per repository.
|
|
13
|
-
*
|
|
14
|
-
* Branch deletes are compare-and-swap on the planned sha. Every step is
|
|
15
|
-
* verified, and the receipt is rewritten after each.
|
|
16
|
-
*/
|
|
17
|
-
import { existsSync } from 'node:fs';
|
|
18
|
-
import { resolve } from 'node:path';
|
|
19
|
-
import { shellJoin } from '@osdlc/command-seam';
|
|
20
|
-
import { CommandFailed, Git, PUSH_REJECTIONS } from '@osdlc/typed-git';
|
|
21
|
-
import { localDeleteCommand, remoteDeleteCommand } from "./branches.js";
|
|
22
|
-
import { busyHolders, defaultProcessProbe, parseLockOwner } from "./processes.js";
|
|
23
|
-
import { PLAN_CATEGORIES, fingerprint, isPlanCategory, sameFingerprint, } from "./plan.js";
|
|
24
|
-
import { RECEIPT_VERSION } from "./receipt.js";
|
|
25
|
-
import { holdFreedBranches, sweepRepo } from "./sweep.js";
|
|
26
|
-
import { AUTHORIZATIONS, } from "./types.js";
|
|
27
|
-
import { worktreePruneCommand, worktreeRemoveCommand, worktreeUnlockCommand } from "./worktrees.js";
|
|
28
|
-
import { writeJsonAtomic } from "./write-json.js";
|
|
29
|
-
/** A selection or grant the plan cannot satisfy. Nothing has been touched. */
|
|
30
|
-
export class ApplyArgumentError extends Error {
|
|
31
|
-
}
|
|
32
|
-
/** A kept row is gone or moved since the plan, so the run refuses. Nothing has been touched. */
|
|
33
|
-
export class ApplyRefused extends Error {
|
|
34
|
-
}
|
|
35
|
-
/** `<category>:<id>`, split at the first colon. */
|
|
36
|
-
export function parseRowRef(text) {
|
|
37
|
-
const colon = text.indexOf(':');
|
|
38
|
-
const category = colon < 0 ? '' : text.slice(0, colon);
|
|
39
|
-
const id = colon < 0 ? '' : text.slice(colon + 1);
|
|
40
|
-
if (!isPlanCategory(category) || id === '') {
|
|
41
|
-
throw new ApplyArgumentError(`bad row ${JSON.stringify(text)}: expected <category>:<id> with category one of ${PLAN_CATEGORIES.join(', ')}`);
|
|
42
|
-
}
|
|
43
|
-
return { category, id };
|
|
44
|
-
}
|
|
45
|
-
/** `<name>` for every row, or `<name>=<category>:<id>` for one. */
|
|
46
|
-
export function parseGrant(text) {
|
|
47
|
-
const eq = text.indexOf('=');
|
|
48
|
-
const name = eq < 0 ? text : text.slice(0, eq);
|
|
49
|
-
if (!AUTHORIZATIONS.includes(name)) {
|
|
50
|
-
throw new ApplyArgumentError(`unknown authorization ${JSON.stringify(name)}: expected one of ${AUTHORIZATIONS.join(', ')}`);
|
|
51
|
-
}
|
|
52
|
-
return { name: name, row: eq < 0 ? null : parseRowRef(text.slice(eq + 1)) };
|
|
53
|
-
}
|
|
54
|
-
const sameRow = (a, b) => a.category === b.category && a.id === b.id;
|
|
55
|
-
const label = (row) => `${row.category}:${row.id}`;
|
|
56
|
-
function errorText(err) {
|
|
57
|
-
if (err instanceof CommandFailed)
|
|
58
|
-
return err.detail.trim() || err.message;
|
|
59
|
-
return err instanceof Error ? err.message : String(err);
|
|
60
|
-
}
|
|
61
|
-
const sameSelection = (a, b) => sameRow(a, b) &&
|
|
62
|
-
(a.repo === undefined ? '' : resolve(a.repo)) === (b.repo === undefined ? '' : resolve(b.repo));
|
|
63
|
-
/** Each selected row's plan repository. Unknown and ambiguous rows are errors. */
|
|
64
|
-
function locate(plan, rows, repoRoot) {
|
|
65
|
-
const repos = repoRoot === undefined
|
|
66
|
-
? plan.repos
|
|
67
|
-
: plan.repos.filter((r) => resolve(r.root) === resolve(repoRoot));
|
|
68
|
-
if (repos.length === 0)
|
|
69
|
-
throw new ApplyArgumentError(`no plan repository at ${repoRoot}`);
|
|
70
|
-
return rows.map(({ repo, ...ref }) => {
|
|
71
|
-
const hits = repos.filter((r) => (repo === undefined || resolve(r.root) === resolve(repo)) &&
|
|
72
|
-
r.rows.some((row) => sameRow(row, ref)));
|
|
73
|
-
if (hits.length === 0)
|
|
74
|
-
throw new ApplyArgumentError(`row ${label(ref)} is not in the plan`);
|
|
75
|
-
if (hits.length > 1) {
|
|
76
|
-
throw new ApplyArgumentError(`row ${label(ref)} is in ${hits.length} plan repositories; pass --repo-root`);
|
|
77
|
-
}
|
|
78
|
-
return { repo: hits[0], ref };
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
function granted(grants, ref, name) {
|
|
82
|
-
return grants.some((g) => g.name === name && (g.row === null || sameRow(g.row, ref)));
|
|
83
|
-
}
|
|
84
|
-
/** The sweep scope that gathers each row category. */
|
|
85
|
-
const SCOPE_OF = {
|
|
86
|
-
worktree: 'worktrees',
|
|
87
|
-
local_branch: 'local-branches',
|
|
88
|
-
remote_branch: 'remote-branches',
|
|
89
|
-
};
|
|
90
|
-
/** The plan repository's rows as they stand now, with the host's overlay applied. */
|
|
91
|
-
function resweep(repo, scope, opts) {
|
|
92
|
-
const branchScoped = repo.branch != null;
|
|
93
|
-
const given = opts.swept?.get(repo.root);
|
|
94
|
-
if (given !== undefined) {
|
|
95
|
-
return holdFreedBranches([...given], { branchScoped });
|
|
96
|
-
}
|
|
97
|
-
const sweep = sweepRepo(repo.root, {
|
|
98
|
-
remote: repo.remote,
|
|
99
|
-
noProvider: repo.no_gh,
|
|
100
|
-
provider: repo.provider,
|
|
101
|
-
branch: repo.branch ?? undefined,
|
|
102
|
-
fetch: opts.fetch ?? repo.fetch,
|
|
103
|
-
scope,
|
|
104
|
-
cwd: opts.callerCwd,
|
|
105
|
-
git: opts.git,
|
|
106
|
-
gh: opts.gh,
|
|
107
|
-
glab: opts.glab,
|
|
108
|
-
probe: opts.probe,
|
|
109
|
-
onProgress: opts.onSweepProgress,
|
|
110
|
-
});
|
|
111
|
-
if (sweep.repo === null || sweep.error !== null) {
|
|
112
|
-
throw new Error(sweep.error ?? `could not read repository: ${repo.root}`);
|
|
113
|
-
}
|
|
114
|
-
return opts.annotate === undefined
|
|
115
|
-
? sweep.items
|
|
116
|
-
: holdFreedBranches(opts.annotate(sweep.repo, sweep.items), { branchScoped });
|
|
117
|
-
}
|
|
118
|
-
/** Re-sweep, then split the selection into rows to run and rows dropped. */
|
|
119
|
-
function recheck(selected, kept, grants, implicit, opts) {
|
|
120
|
-
const swept = new Map();
|
|
121
|
-
const all = [...selected, ...kept];
|
|
122
|
-
for (const { repo } of all) {
|
|
123
|
-
if (swept.has(repo))
|
|
124
|
-
continue;
|
|
125
|
-
// Only the categories the selection is in: a plan of local rows need not
|
|
126
|
-
// compare every remote branch again.
|
|
127
|
-
const scope = new Set(all.filter((s) => s.repo === repo).map((s) => SCOPE_OF[s.ref.category]));
|
|
128
|
-
swept.set(repo, resweep(repo, [...scope], opts));
|
|
129
|
-
}
|
|
130
|
-
// A kept branch is checked, not dropped: one that moved refuses the run.
|
|
131
|
-
const verified = [];
|
|
132
|
-
for (const { repo, ref } of kept) {
|
|
133
|
-
const planned = repo.rows.find((row) => sameRow(row, ref));
|
|
134
|
-
const item = swept.get(repo).find((it) => sameRow(it, ref));
|
|
135
|
-
if (item === undefined) {
|
|
136
|
-
throw new ApplyRefused(`kept ${label(ref)} is no longer in the sweep; refusing to apply`);
|
|
137
|
-
}
|
|
138
|
-
// The tip alone: a PR that closed since the plan does not move the branch.
|
|
139
|
-
const plannedSha = planned.fingerprint['sha'];
|
|
140
|
-
if (typeof plannedSha !== 'string' || shaOf(item) === null) {
|
|
141
|
-
throw new ApplyRefused(`kept ${label(ref)} has no tip to check; refusing to apply`);
|
|
142
|
-
}
|
|
143
|
-
if (shaOf(item) !== plannedSha) {
|
|
144
|
-
throw new ApplyRefused(`kept ${label(ref)} moved since the plan (planned ${String(plannedSha)}, now ${String(item.facts['sha'])}); refusing to apply`);
|
|
145
|
-
}
|
|
146
|
-
verified.push({ repo: repo.root, item, force: false });
|
|
147
|
-
}
|
|
148
|
-
const accepted = [];
|
|
149
|
-
const dropped = [];
|
|
150
|
-
for (const { repo, ref } of selected) {
|
|
151
|
-
const drop = (reason, detail) => {
|
|
152
|
-
dropped.push({ repo: repo.root, ...ref, reason, detail });
|
|
153
|
-
};
|
|
154
|
-
const planned = repo.rows.find((row) => sameRow(row, ref));
|
|
155
|
-
const item = swept.get(repo).find((it) => sameRow(it, ref));
|
|
156
|
-
if (item === undefined) {
|
|
157
|
-
drop('gone', 'no longer in the sweep');
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
if (!item.deletable) {
|
|
161
|
-
drop('not-deletable', item.reason);
|
|
162
|
-
continue;
|
|
163
|
-
}
|
|
164
|
-
if (implicit && item.classification !== 'safe-to-delete') {
|
|
165
|
-
drop('needs-review', `${item.reason}; name it with --row to apply it`);
|
|
166
|
-
continue;
|
|
167
|
-
}
|
|
168
|
-
const now = fingerprint(item);
|
|
169
|
-
if (!sameFingerprint(planned.fingerprint, now)) {
|
|
170
|
-
drop('facts-changed', `planned ${JSON.stringify(planned.fingerprint)}, now ${JSON.stringify(now)}`);
|
|
171
|
-
continue;
|
|
172
|
-
}
|
|
173
|
-
const missing = item.requires.filter((name) => !granted(grants, ref, name));
|
|
174
|
-
if (missing.length > 0) {
|
|
175
|
-
drop('not-authorized', `needs ${missing.join(', ')}`);
|
|
176
|
-
continue;
|
|
177
|
-
}
|
|
178
|
-
accepted.push({ repo: repo.root, item, force: granted(grants, ref, 'discard-changes') });
|
|
179
|
-
}
|
|
180
|
-
// A branch freed by its worktree's removal goes only with that worktree.
|
|
181
|
-
const leaving = new Set(accepted.filter((row) => row.item.category === 'worktree').map((row) => row.item.id));
|
|
182
|
-
const held = accepted.filter((row) => {
|
|
183
|
-
const worktree = row.item.facts['worktree'];
|
|
184
|
-
return typeof worktree === 'string' && !leaving.has(worktree);
|
|
185
|
-
});
|
|
186
|
-
for (const { repo, item } of held) {
|
|
187
|
-
dropped.push({
|
|
188
|
-
repo,
|
|
189
|
-
category: item.category,
|
|
190
|
-
id: item.id,
|
|
191
|
-
reason: 'not-deletable',
|
|
192
|
-
detail: `checked out in ${String(item.facts['worktree'])}, which this run does not remove`,
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
return { accepted: accepted.filter((row) => !held.includes(row)), dropped, verified };
|
|
196
|
-
}
|
|
197
|
-
function pending(repo, row, action, command, expectedSha = null) {
|
|
198
|
-
return {
|
|
199
|
-
repo,
|
|
200
|
-
row,
|
|
201
|
-
action,
|
|
202
|
-
command,
|
|
203
|
-
expected_sha: expectedSha,
|
|
204
|
-
result: 'pending',
|
|
205
|
-
error: null,
|
|
206
|
-
verification: null,
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
const pruneStep = (repo, row) => pending(repo, row, 'prune-worktrees', worktreePruneCommand(repo));
|
|
210
|
-
/** A row's sha fact, or null when it has none. */
|
|
211
|
-
function shaOf(item) {
|
|
212
|
-
return typeof item.facts['sha'] === 'string' ? item.facts['sha'] : null;
|
|
213
|
-
}
|
|
214
|
-
/** A remote branch row's remote, its full ref on that remote, and its branch name. */
|
|
215
|
-
function remoteRefOf(item) {
|
|
216
|
-
const branch = String(item.facts['branch']);
|
|
217
|
-
return { remote: String(item.facts['remote']), ref: `refs/heads/${branch}`, branch };
|
|
218
|
-
}
|
|
219
|
-
/** The step that checks a kept remote branch's tip, just before anything is deleted. */
|
|
220
|
-
function verifyStep({ repo, item }) {
|
|
221
|
-
const sha = shaOf(item);
|
|
222
|
-
const { remote, ref } = remoteRefOf(item);
|
|
223
|
-
return pending(repo, { category: item.category, id: item.id }, 'verify-remote', shellJoin(['git', 'ls-remote', remote, ref]), sha);
|
|
224
|
-
}
|
|
225
|
-
function stepsFor({ repo, item, force }) {
|
|
226
|
-
const ref = { category: item.category, id: item.id };
|
|
227
|
-
const sha = shaOf(item);
|
|
228
|
-
switch (item.category) {
|
|
229
|
-
case 'worktree': {
|
|
230
|
-
// Remove and prune both skip a locked worktree, so a stale lock goes first.
|
|
231
|
-
const unlock = item.facts['stale_lock'] === true
|
|
232
|
-
? [pending(repo, ref, 'unlock-worktree', worktreeUnlockCommand(repo, item.id))]
|
|
233
|
-
: [];
|
|
234
|
-
if (item.facts['on_disk'] === false)
|
|
235
|
-
return [...unlock, pruneStep(repo, ref)];
|
|
236
|
-
return [
|
|
237
|
-
...unlock,
|
|
238
|
-
pending(repo, ref, 'remove-worktree', worktreeRemoveCommand(repo, item.id, { force })),
|
|
239
|
-
];
|
|
240
|
-
}
|
|
241
|
-
case 'local_branch':
|
|
242
|
-
return [pending(repo, ref, 'delete-local', localDeleteCommand(item.id, sha ?? ''), sha)];
|
|
243
|
-
case 'remote_branch':
|
|
244
|
-
return [
|
|
245
|
-
pending(repo, ref, 'delete-remote', remoteDeleteCommand(remoteRefOf(item).remote, remoteRefOf(item).branch, sha ?? ''), sha),
|
|
246
|
-
];
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
/**
|
|
250
|
-
* Refuse while a process other than an idle shell works inside `path`. Reads
|
|
251
|
-
* the process table afresh; a table that cannot be read refuses too.
|
|
252
|
-
*/
|
|
253
|
-
function assertNoLiveProcess(path, probe) {
|
|
254
|
-
let busy;
|
|
255
|
-
try {
|
|
256
|
-
busy = busyHolders(probe.holders(path, { fresh: true }));
|
|
257
|
-
}
|
|
258
|
-
catch (err) {
|
|
259
|
-
throw new Error(`refused: cannot tell whether a process runs in ${path}: ${errorText(err)}`);
|
|
260
|
-
}
|
|
261
|
-
if (busy.length > 0) {
|
|
262
|
-
throw new Error(`refused: running in ${path}: ${busy.map((h) => `${h.command} (pid ${h.pid})`).join(', ')}`);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
/** Run one step, then confirm it took. Throws with the reason on failure. */
|
|
266
|
-
function execute(step, row, git, probe) {
|
|
267
|
-
const at = git.at(step.repo);
|
|
268
|
-
const registered = (path) => at.worktrees().some((w) => resolve(w.path) === resolve(path));
|
|
269
|
-
switch (step.action) {
|
|
270
|
-
case 'unlock-worktree': {
|
|
271
|
-
const path = row.item.id;
|
|
272
|
-
// The sweep that accepted this row is behind us; ask again just before
|
|
273
|
-
// acting, so an owner that came back keeps its lock.
|
|
274
|
-
const lockReason = row.item.facts['lock_reason'];
|
|
275
|
-
const owner = parseLockOwner(typeof lockReason === 'string' ? lockReason : null);
|
|
276
|
-
if (owner === null)
|
|
277
|
-
throw new Error(`refused: lock on ${path} names no owner`);
|
|
278
|
-
if (probe.alive(owner.pid, owner.startedAt)) {
|
|
279
|
-
throw new Error(`refused: lock owner pid ${owner.pid} is alive`);
|
|
280
|
-
}
|
|
281
|
-
if (row.item.facts['on_disk'] !== false)
|
|
282
|
-
assertNoLiveProcess(path, probe);
|
|
283
|
-
at.worktreeUnlock(path);
|
|
284
|
-
const entry = at.worktrees().find((w) => resolve(w.path) === resolve(path));
|
|
285
|
-
if (entry?.locked === true)
|
|
286
|
-
throw new Error(`still locked: ${path}`);
|
|
287
|
-
return 'lock owner dead, unlocked';
|
|
288
|
-
}
|
|
289
|
-
case 'prune-worktrees': {
|
|
290
|
-
at.worktreePrune();
|
|
291
|
-
if (row !== null && registered(row.item.id))
|
|
292
|
-
throw new Error('still registered after prune');
|
|
293
|
-
return row === null ? 'pruned' : 'unregistered';
|
|
294
|
-
}
|
|
295
|
-
case 'remove-worktree': {
|
|
296
|
-
const path = row.item.id;
|
|
297
|
-
assertNoLiveProcess(path, probe);
|
|
298
|
-
at.worktreeRemove(path, { force: row.force });
|
|
299
|
-
if (existsSync(path))
|
|
300
|
-
throw new Error(`directory still exists: ${path}`);
|
|
301
|
-
if (registered(path))
|
|
302
|
-
throw new Error(`still registered: ${path}`);
|
|
303
|
-
return 'directory gone and unregistered';
|
|
304
|
-
}
|
|
305
|
-
case 'delete-local': {
|
|
306
|
-
const name = row.item.id;
|
|
307
|
-
const holder = at.worktrees().find((w) => w.branch === name);
|
|
308
|
-
if (holder !== undefined)
|
|
309
|
-
throw new Error(`refused: checked out in ${holder.path}`);
|
|
310
|
-
at.deleteRef(`refs/heads/${name}`, { expected: step.expected_sha });
|
|
311
|
-
// `update-ref -d` does not remove the `branch.<name>` config section.
|
|
312
|
-
at.configRemoveSection(`branch.${name}`);
|
|
313
|
-
if (at.resolveRef(`refs/heads/${name}`) !== null)
|
|
314
|
-
throw new Error('ref still resolves');
|
|
315
|
-
return 'ref does not resolve';
|
|
316
|
-
}
|
|
317
|
-
case 'verify-remote': {
|
|
318
|
-
const { remote, ref } = remoteRefOf(row.item);
|
|
319
|
-
const tip = at.lsRemote(remote, ref).find((r) => r.ref === ref);
|
|
320
|
-
if (tip === undefined)
|
|
321
|
-
throw new Error(`refused: kept ${ref} is gone from ${remote}`);
|
|
322
|
-
if (tip.sha !== step.expected_sha) {
|
|
323
|
-
throw new Error(`refused: kept ${ref} moved to ${tip.sha}, planned ${step.expected_sha}`);
|
|
324
|
-
}
|
|
325
|
-
return 'tip unchanged; kept';
|
|
326
|
-
}
|
|
327
|
-
case 'delete-remote': {
|
|
328
|
-
const { remote, ref } = remoteRefOf(row.item);
|
|
329
|
-
const report = at.push(remote, `:${ref}`, {
|
|
330
|
-
forceWithLease: { ref, expect: step.expected_sha },
|
|
331
|
-
});
|
|
332
|
-
const refused = report.refs.find((r) => r.flag === '!');
|
|
333
|
-
if (refused?.flag === '!') {
|
|
334
|
-
const why = refused.rawReason === null ? '' : ` (${refused.rawReason})`;
|
|
335
|
-
throw new Error(`${refused.summary}${why}: ${PUSH_REJECTIONS[refused.rejection].message}`);
|
|
336
|
-
}
|
|
337
|
-
if (at.lsRemote(remote, ref).some((r) => r.ref === ref)) {
|
|
338
|
-
throw new Error(`ls-remote still lists ${ref}`);
|
|
339
|
-
}
|
|
340
|
-
return 'ls-remote lists nothing';
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
/**
|
|
345
|
-
* A network URL as `host[:port]/path`, the same for `git@host:o/r.git`,
|
|
346
|
-
* `ssh://git@host/o/r` and `https://host/o/r/`; the whole path is kept, so
|
|
347
|
-
* nested groups stay apart. Null for a path on this machine, including
|
|
348
|
-
* `file://` and a Windows drive path.
|
|
349
|
-
*/
|
|
350
|
-
export function networkRemoteKey(url) {
|
|
351
|
-
const u = url.trim();
|
|
352
|
-
if (/^[a-z]:[\\/]/i.test(u) || /^file:/i.test(u))
|
|
353
|
-
return null;
|
|
354
|
-
const uri = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::(\d+))?(\/.*)?$/i.exec(u);
|
|
355
|
-
const scp = uri === null ? /^(?:[^@/:]+@)?([^/:]+):(?!\/\/)(.+)$/.exec(u) : null;
|
|
356
|
-
const host = uri?.[1] ?? scp?.[1];
|
|
357
|
-
if (host === undefined)
|
|
358
|
-
return null;
|
|
359
|
-
const port = uri?.[2] === undefined ? '' : `:${uri[2]}`;
|
|
360
|
-
const path = (uri?.[3] ?? scp?.[2] ?? '').replace(/^\/+|\/+$/g, '').replace(/\.git$/, '');
|
|
361
|
-
return `${host.toLowerCase()}${port}/${path}`;
|
|
362
|
-
}
|
|
363
|
-
/**
|
|
364
|
-
* One string per remote repository, so two clones that push to the same one
|
|
365
|
-
* agree. Built from the push URLs git would use (`remote get-url --push
|
|
366
|
-
* --all`, which applies `url.*.insteadOf` and `pushInsteadOf`): a network URL
|
|
367
|
-
* by {@link networkRemoteKey}, a local path resolved against the repository
|
|
368
|
-
* and kept as written, `.git` and all. Null when git reports no URL; such a
|
|
369
|
-
* row is never treated as a duplicate.
|
|
370
|
-
*/
|
|
371
|
-
function remoteKey(git, repo, remote) {
|
|
372
|
-
const urls = git.at(repo).try.remoteGetUrls(remote, { push: true });
|
|
373
|
-
if (!urls.ok || urls.value === null || urls.value.length === 0)
|
|
374
|
-
return null;
|
|
375
|
-
const keys = urls.value.map((url) => networkRemoteKey(url) ?? resolve(repo, url.trim().replace(/^file:\/\/(localhost)?/i, '')));
|
|
376
|
-
return [...new Set(keys)].sort().join('\n');
|
|
377
|
-
}
|
|
378
|
-
/**
|
|
379
|
-
* Keep the first row for each remote branch. Two clones of one remote each
|
|
380
|
-
* list its branches; deleting one twice fails the second push and stops the
|
|
381
|
-
* run, so the later row is dropped as `duplicate`.
|
|
382
|
-
*/
|
|
383
|
-
function onePerRemoteBranch({ accepted, dropped, verified, }, git) {
|
|
384
|
-
const keyOf = (row) => {
|
|
385
|
-
const remote = remoteKey(git, row.repo, String(row.item.facts['remote']));
|
|
386
|
-
return remote === null ? null : `${remote}\0${remoteRefOf(row.item).branch}`;
|
|
387
|
-
};
|
|
388
|
-
// A branch kept through one clone stays, whichever clone lists it.
|
|
389
|
-
const keptAt = new Map();
|
|
390
|
-
for (const row of verified) {
|
|
391
|
-
const key = keyOf(row);
|
|
392
|
-
if (key !== null)
|
|
393
|
-
keptAt.set(key, row.repo);
|
|
394
|
-
}
|
|
395
|
-
const seen = new Map();
|
|
396
|
-
const kept = [];
|
|
397
|
-
const extra = [];
|
|
398
|
-
for (const row of accepted) {
|
|
399
|
-
if (row.item.category !== 'remote_branch') {
|
|
400
|
-
kept.push(row);
|
|
401
|
-
continue;
|
|
402
|
-
}
|
|
403
|
-
const key = keyOf(row);
|
|
404
|
-
const keeper = key === null ? undefined : keptAt.get(key);
|
|
405
|
-
if (keeper !== undefined) {
|
|
406
|
-
extra.push({
|
|
407
|
-
repo: row.repo,
|
|
408
|
-
category: row.item.category,
|
|
409
|
-
id: row.item.id,
|
|
410
|
-
reason: 'kept',
|
|
411
|
-
detail: `the same remote branch is kept through ${keeper}`,
|
|
412
|
-
});
|
|
413
|
-
continue;
|
|
414
|
-
}
|
|
415
|
-
const first = key === null ? undefined : seen.get(key);
|
|
416
|
-
if (first !== undefined) {
|
|
417
|
-
extra.push({
|
|
418
|
-
repo: row.repo,
|
|
419
|
-
category: row.item.category,
|
|
420
|
-
id: row.item.id,
|
|
421
|
-
reason: 'duplicate',
|
|
422
|
-
detail: `the same remote branch is deleted from ${first}`,
|
|
423
|
-
});
|
|
424
|
-
continue;
|
|
425
|
-
}
|
|
426
|
-
if (key !== null)
|
|
427
|
-
seen.set(key, row.repo);
|
|
428
|
-
kept.push(row);
|
|
429
|
-
}
|
|
430
|
-
return { accepted: kept, dropped: [...dropped, ...extra] };
|
|
431
|
-
}
|
|
432
|
-
/**
|
|
433
|
-
* Apply the `requested` rows of `plan` under `grants`. An empty `requested`
|
|
434
|
-
* selects every row of the plan (of `repoRoot`'s repository, when given) and
|
|
435
|
-
* then drops each one the re-sweep does not judge `safe-to-delete`: a row to
|
|
436
|
-
* review runs only when it is named. A row named twice runs once. A row whose
|
|
437
|
-
* `requires` was not granted is dropped, never run.
|
|
438
|
-
*
|
|
439
|
-
* @throws ApplyArgumentError before touching anything, when a row or grant does
|
|
440
|
-
* not fit the plan.
|
|
441
|
-
*/
|
|
442
|
-
export function applyPlan(plan, requested, grants, opts) {
|
|
443
|
-
const now = opts.now ?? (() => new Date());
|
|
444
|
-
// Every row of the plan keeps its repository, so a branch name two
|
|
445
|
-
// repositories share is two rows, not an ambiguity.
|
|
446
|
-
const wanted = requested.length > 0
|
|
447
|
-
? requested
|
|
448
|
-
: plan.repos
|
|
449
|
-
.filter((r) => opts.repoRoot === undefined || resolve(r.root) === resolve(opts.repoRoot))
|
|
450
|
-
.flatMap((r) => r.rows.map((row) => ({ category: row.category, id: row.id, repo: r.root })));
|
|
451
|
-
const kept = locate(plan, opts.keep ?? [], opts.repoRoot);
|
|
452
|
-
for (const k of kept) {
|
|
453
|
-
if (k.ref.category !== 'remote_branch') {
|
|
454
|
-
throw new ApplyArgumentError(`--keep takes a remote_branch row, not ${label(k.ref)}`);
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
const isKept = (row) => kept.some((k) => sameRow(k.ref, row) &&
|
|
458
|
-
(row.repo === undefined || resolve(row.repo) === resolve(k.repo.root)));
|
|
459
|
-
for (const row of requested) {
|
|
460
|
-
if (isKept(row))
|
|
461
|
-
throw new ApplyArgumentError(`row ${label(row)} is both selected and kept`);
|
|
462
|
-
}
|
|
463
|
-
const rows = wanted.filter((row, i) => wanted.findIndex((other) => sameSelection(other, row)) === i && !isKept(row));
|
|
464
|
-
if (rows.length === 0)
|
|
465
|
-
throw new ApplyArgumentError('the plan has no rows to apply');
|
|
466
|
-
const selected = locate(plan, rows, opts.repoRoot);
|
|
467
|
-
for (const grant of grants) {
|
|
468
|
-
if (grant.row !== null && !rows.some((r) => sameRow(r, grant.row))) {
|
|
469
|
-
throw new ApplyArgumentError(`--allow names unselected row ${label(grant.row)}`);
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
const receipt = {
|
|
473
|
-
version: RECEIPT_VERSION,
|
|
474
|
-
plan: opts.planPath,
|
|
475
|
-
status: opts.dryRun ? 'dry-run' : 'applying',
|
|
476
|
-
started_at: now().toISOString(),
|
|
477
|
-
finished_at: null,
|
|
478
|
-
dropped: [],
|
|
479
|
-
steps: [],
|
|
480
|
-
};
|
|
481
|
-
const rechecked = recheck(selected, kept, grants, opts.safeOnly ?? requested.length === 0, opts);
|
|
482
|
-
const { accepted, dropped } = onePerRemoteBranch(rechecked, new Git(opts.callerCwd, { runner: opts.git }));
|
|
483
|
-
receipt.dropped = [...(opts.dropped ?? []), ...dropped];
|
|
484
|
-
const ordered = PLAN_CATEGORIES.flatMap((category) => accepted.filter((row) => row.item.category === category));
|
|
485
|
-
const plannedSteps = [
|
|
486
|
-
// Kept tips first: one that moved stops the run before anything is deleted.
|
|
487
|
-
...rechecked.verified.map((row) => [verifyStep(row), row]),
|
|
488
|
-
...ordered.flatMap((row) => stepsFor(row).map((step) => [step, row])),
|
|
489
|
-
];
|
|
490
|
-
for (const repo of new Set(ordered.map((row) => row.repo))) {
|
|
491
|
-
plannedSteps.push([pruneStep(repo, null), null]);
|
|
492
|
-
}
|
|
493
|
-
receipt.steps = plannedSteps.map(([step]) => step);
|
|
494
|
-
if (opts.dryRun) {
|
|
495
|
-
for (const step of receipt.steps)
|
|
496
|
-
step.result = 'would-run';
|
|
497
|
-
receipt.finished_at = now().toISOString();
|
|
498
|
-
return receipt;
|
|
499
|
-
}
|
|
500
|
-
const receiptPath = opts.receiptPath;
|
|
501
|
-
const save = () => {
|
|
502
|
-
if (receiptPath !== null)
|
|
503
|
-
writeJsonAtomic(receiptPath, receipt);
|
|
504
|
-
};
|
|
505
|
-
save();
|
|
506
|
-
opts.onStart?.(receipt.steps);
|
|
507
|
-
const git = new Git(opts.callerCwd, { runner: opts.git });
|
|
508
|
-
const probe = opts.probe ?? defaultProcessProbe();
|
|
509
|
-
// Worktrees whose lock this run cleared. typed-git has no `worktree lock`
|
|
510
|
-
// verb, so a failed remove cannot put the lock back; the receipt says so.
|
|
511
|
-
const unlocked = new Set();
|
|
512
|
-
for (const [i, [step, row]] of plannedSteps.entries()) {
|
|
513
|
-
opts.onStep?.(i, plannedSteps.length, step);
|
|
514
|
-
try {
|
|
515
|
-
step.verification = execute(step, row, git, probe);
|
|
516
|
-
step.result = 'ok';
|
|
517
|
-
if (step.action === 'unlock-worktree')
|
|
518
|
-
unlocked.add(row.item.id);
|
|
519
|
-
}
|
|
520
|
-
catch (err) {
|
|
521
|
-
step.result = 'failed';
|
|
522
|
-
step.error = errorText(err);
|
|
523
|
-
if (row !== null && step.action !== 'unlock-worktree' && unlocked.has(row.item.id)) {
|
|
524
|
-
step.error += `; the lock on ${row.item.id} was cleared earlier in this run and is not restored`;
|
|
525
|
-
}
|
|
526
|
-
receipt.status = 'failed';
|
|
527
|
-
receipt.finished_at = now().toISOString();
|
|
528
|
-
// Told before the save, so a receipt that cannot be written still
|
|
529
|
-
// leaves the step's outcome on screen.
|
|
530
|
-
opts.onStepDone?.(i, plannedSteps.length, step);
|
|
531
|
-
save();
|
|
532
|
-
return receipt;
|
|
533
|
-
}
|
|
534
|
-
opts.onStepDone?.(i, plannedSteps.length, step);
|
|
535
|
-
save();
|
|
536
|
-
}
|
|
537
|
-
receipt.status = 'complete';
|
|
538
|
-
receipt.finished_at = now().toISOString();
|
|
539
|
-
save();
|
|
540
|
-
return receipt;
|
|
541
|
-
}
|