pi-squire 0.9.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,474 @@
1
+ // Managed worktrees: the nonmutating plan for a new branch's checkout, its
2
+ // creation, and the conservative rollback of a launch that failed before its
3
+ // window opened. Ownership is proved from Git and the disk for every
4
+ // removal; anything uncertain is retained and described, never forced.
5
+ import { lstatSync, mkdirSync, readdirSync, realpathSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { basename, dirname, join, resolve, sep } from "node:path";
8
+
9
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+
11
+ import {
12
+ askGit,
13
+ fields,
14
+ type GitAnswer,
15
+ type GitIdentity,
16
+ type GitLaunch,
17
+ inspectGit,
18
+ onePath,
19
+ type PathSummary,
20
+ pendingPaths,
21
+ summarize,
22
+ } from "./git.ts";
23
+ import type { ResourceFacts } from "./results.ts";
24
+ import { worktreeDestination } from "./worktree-placement.ts";
25
+
26
+ export type WorktreeOptions = { worktree: string; worktree_base?: string; worktree_source?: string };
27
+ /** What a managed launch intends, frozen before anything is created. */
28
+ export type WorktreePlan = {
29
+ sourceCwd: string;
30
+ source: GitIdentity;
31
+ destination: string;
32
+ branch: string;
33
+ requestedBase: string;
34
+ baseCommit: string;
35
+ sourceDirty: PathSummary;
36
+ trusted: boolean;
37
+ };
38
+ /** A created worktree, as the delegate's workspace. */
39
+ export type ManagedWorkspace = {
40
+ mode: "managed";
41
+ cwd: string;
42
+ git: Extract<GitLaunch, { kind: "git" }>;
43
+ sourceCwd: string;
44
+ source: GitIdentity;
45
+ createdBranch: string;
46
+ requestedBase: string;
47
+ baseCommit: string;
48
+ sourceDirty: PathSummary;
49
+ };
50
+
51
+ /** Long enough for a large checkout, still bounded; a killed call's leftovers are observed, not assumed. */
52
+ const MUTATE = { timeout: 120_000, maxBuffer: 1024 * 1024 };
53
+ const OBSERVE = { timeout: 30_000, maxBuffer: 16 * 1024 * 1024 };
54
+ /** The most files rollback will compare with the index before it gives up and retains. */
55
+ const WALK_LIMIT = 200_000;
56
+
57
+ /** The stdout of a Git call that must succeed. */
58
+ function must(cwd: string, args: string[], limits = OBSERVE): string {
59
+ const answer: GitAnswer = askGit(cwd, args, limits);
60
+ if (!answer.ok) throw new Error(`git ${args[0]} failed: ${answer.message}`);
61
+ return answer.out;
62
+ }
63
+
64
+ /** One registered checkout, as `git worktree list --porcelain -z` reports it. */
65
+ type Registered = { path: string; head?: string; branch?: string; locked: boolean; prunable: boolean };
66
+
67
+ function applyRegistration(
68
+ line: string,
69
+ current: Registered | undefined,
70
+ entries: Registered[],
71
+ ): Registered | undefined {
72
+ if (line === "") return undefined;
73
+ if (line.startsWith("worktree ")) {
74
+ const next = { path: line.slice("worktree ".length), locked: false, prunable: false };
75
+ entries.push(next);
76
+ return next;
77
+ }
78
+ if (current === undefined) return current;
79
+ if (line.startsWith("HEAD ")) current.head = line.slice(5);
80
+ else if (line.startsWith("branch ")) current.branch = line.slice(7);
81
+ else if (line === "locked" || line.startsWith("locked ")) current.locked = true;
82
+ else if (line === "prunable" || line.startsWith("prunable ")) current.prunable = true;
83
+ return current;
84
+ }
85
+
86
+ function registered(cwd: string): Registered[] {
87
+ const entries: Registered[] = [];
88
+ let current: Registered | undefined;
89
+ for (const line of must(cwd, ["worktree", "list", "--porcelain", "-z"]).split("\0"))
90
+ current = applyRegistration(line, current, entries);
91
+ return entries;
92
+ }
93
+
94
+ const exists = (path: string): boolean => lstatSync(path, { throwIfNoEntry: false }) !== undefined;
95
+
96
+ /** `path` with its deepest existing ancestor resolved through symlinks; the rest stays as written. */
97
+ function canonical(path: string): string {
98
+ const rest: string[] = [];
99
+ let current = resolve(path);
100
+ while (!exists(current) && dirname(current) !== current) {
101
+ rest.unshift(basename(current));
102
+ current = dirname(current);
103
+ }
104
+ return join(realpathSync(current), ...rest);
105
+ }
106
+
107
+ const inside = (path: string, root: string): boolean => path === root || path.startsWith(`${root}${sep}`);
108
+
109
+ /** Refuse a destination that exists, is registered with Git, or lies in Git's own administrative data. */
110
+ function checkDestination(destination: string, identity: GitIdentity): void {
111
+ if (exists(destination)) throw new Error(`the worktree destination ${destination} already exists`);
112
+ if (canonical(destination) !== destination) {
113
+ throw new Error(`the worktree destination ${destination} is not its canonical path`);
114
+ }
115
+ if (inside(destination, identity.commonDir) || destination.split(sep).includes(".git")) {
116
+ throw new Error(`the worktree destination ${destination} is inside Git's administrative data`);
117
+ }
118
+ for (const entry of registered(identity.checkoutRoot)) {
119
+ if (canonical(entry.path) === destination) {
120
+ throw new Error(`the worktree destination ${destination} is already registered with Git as a worktree`);
121
+ }
122
+ }
123
+ }
124
+
125
+ const branchRef = (branch: string): string => `refs/heads/${branch}`;
126
+
127
+ /** The commit a local ref names, or null when it does not exist. */
128
+ function refTarget(cwd: string, ref: string): string | null {
129
+ const answer = askGit(cwd, ["rev-parse", "--verify", "--quiet", ref]);
130
+ if (answer.ok) return onePath(answer.out);
131
+ if (answer.status === 1) return null;
132
+ throw new Error(`git rev-parse failed: ${answer.message}`);
133
+ }
134
+
135
+ /** Refuse a branch name Git would expand or reject, or that already exists locally. */
136
+ function checkBranch(identity: GitIdentity, branch: string): void {
137
+ const answer = askGit(identity.checkoutRoot, ["check-ref-format", "--branch", branch]);
138
+ // `--branch` expands checkout-history shorthand such as `@{-1}`; only a literal name is accepted.
139
+ if (!answer.ok || onePath(answer.out) !== branch || branch === "HEAD") {
140
+ throw new Error(`worktree ${JSON.stringify(branch)} is not a valid new local branch name`);
141
+ }
142
+ if (refTarget(identity.checkoutRoot, branchRef(branch)) !== null) {
143
+ throw new Error(`the local branch ${branch} already exists; worktree creates a new branch and never reuses one`);
144
+ }
145
+ }
146
+
147
+ /** The source's staged, unstaged and untracked paths, read without writing its index. */
148
+ function dirtyPaths(identity: GitIdentity): PathSummary {
149
+ const out = must(identity.checkoutRoot, ["status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"]);
150
+ const pending = pendingPaths(out);
151
+ return summarize([...new Set([...pending.staged, ...pending.unstaged, ...pending.untracked, ...pending.conflicted])]);
152
+ }
153
+
154
+ /**
155
+ * Plan a new branch's checkout from the parent's checkout: validate the
156
+ * branch, source, destination and base, and freeze the source's committed
157
+ * `HEAD`. Changes nothing. A same-repository creation uses the parent's
158
+ * current project trust.
159
+ */
160
+ export async function prepareWorktree(
161
+ options: WorktreeOptions,
162
+ ctx: ExtensionContext,
163
+ agentDir: string,
164
+ ): Promise<WorktreePlan> {
165
+ let sourceCwd = ctx.cwd;
166
+ if (options.worktree_source !== undefined) {
167
+ const supplied = options.worktree_source;
168
+ let expanded = supplied;
169
+ if (supplied === "~") expanded = homedir();
170
+ else if (supplied.startsWith("~/")) expanded = join(homedir(), supplied.slice(2));
171
+ try {
172
+ sourceCwd = realpathSync(resolve(ctx.cwd, expanded));
173
+ } catch (err) {
174
+ throw new Error(
175
+ `worktree_source ${JSON.stringify(supplied)} is not an accessible checkout: ${(err as Error).message}`,
176
+ );
177
+ }
178
+ }
179
+ const found = inspectGit(sourceCwd);
180
+ if (found.kind === "non-git") throw new Error(`worktree needs a Git checkout, and ${sourceCwd} is not one`);
181
+ if (found.kind === "unknown") throw new Error(`worktree needs a usable Git checkout: ${found.reason}`);
182
+ const { identity } = found;
183
+ const requestedBase = options.worktree_base ?? "HEAD";
184
+ const base = askGit(identity.checkoutRoot, [
185
+ "rev-parse",
186
+ "--verify",
187
+ "--end-of-options",
188
+ `${requestedBase}^{commit}`,
189
+ ]);
190
+ if (!base.ok) {
191
+ throw new Error(
192
+ `worktree_base ${JSON.stringify(requestedBase)} does not resolve to a commit in ${identity.checkoutRoot}: ${base.message}`,
193
+ );
194
+ }
195
+ const baseCommit = onePath(base.out);
196
+ if (baseCommit === "") throw new Error(`worktree_base ${JSON.stringify(requestedBase)} did not resolve to a commit`);
197
+ checkBranch(identity, options.worktree);
198
+ const destination = worktreeDestination(agentDir, identity, options.worktree);
199
+ checkDestination(destination, identity);
200
+ return {
201
+ sourceCwd,
202
+ source: identity,
203
+ destination,
204
+ branch: options.worktree,
205
+ requestedBase,
206
+ baseCommit,
207
+ sourceDirty: dirtyPaths(identity),
208
+ trusted: ctx.isProjectTrusted(),
209
+ };
210
+ }
211
+
212
+ /** Recheck, inside the queue, the facts the plan depends on; a changed one invalidates the plan. */
213
+ export function revalidate(plan: WorktreePlan): void {
214
+ const now = inspectGit(plan.sourceCwd);
215
+ const same =
216
+ now.kind === "git" &&
217
+ now.identity.checkoutRoot === plan.source.checkoutRoot &&
218
+ now.identity.commonDir === plan.source.commonDir &&
219
+ now.identity.mainRoot === plan.source.mainRoot;
220
+ if (!same)
221
+ throw new Error(`the source checkout ${plan.sourceCwd} is no longer the one that was planned; delegate again`);
222
+ checkBranch(plan.source, plan.branch);
223
+ checkDestination(plan.destination, plan.source);
224
+ const commit = askGit(plan.source.checkoutRoot, ["cat-file", "-e", `${plan.baseCommit}^{commit}`]);
225
+ if (!commit.ok) throw new Error(`the frozen base commit ${plan.baseCommit} is no longer usable: ${commit.message}`);
226
+ }
227
+
228
+ /** Prove the created checkout is what the plan describes; throws the first mismatch. */
229
+ function verify(plan: WorktreePlan): ManagedWorkspace {
230
+ const cwd = realpathSync(plan.destination);
231
+ const top = realpathSync(onePath(must(cwd, ["rev-parse", "--show-toplevel"])));
232
+ const common = realpathSync(resolve(cwd, onePath(must(cwd, ["rev-parse", "--git-common-dir"]))));
233
+ if (top !== cwd || common !== plan.source.commonDir) {
234
+ throw new Error(`${cwd} is checkout ${top} of ${common}, not the planned worktree of ${plan.source.commonDir}`);
235
+ }
236
+ const ref = branchRef(plan.branch);
237
+ if (onePath(must(cwd, ["symbolic-ref", "HEAD"])) !== ref) throw new Error(`${cwd} is not on ${plan.branch}`);
238
+ if (onePath(must(cwd, ["rev-parse", "--verify", "HEAD^{commit}"])) !== plan.baseCommit) {
239
+ throw new Error(`${cwd} is not at the frozen base ${plan.baseCommit}`);
240
+ }
241
+ if (refTarget(cwd, ref) !== plan.baseCommit)
242
+ throw new Error(`${plan.branch} is not at the frozen base ${plan.baseCommit}`);
243
+ const users = registered(cwd).filter((entry) => entry.branch === ref);
244
+ if (users.length !== 1 || canonical(users[0].path) !== cwd) {
245
+ throw new Error(`${plan.branch} is checked out in ${users.length} registered worktrees, not only ${cwd}`);
246
+ }
247
+ return {
248
+ mode: "managed",
249
+ cwd,
250
+ git: {
251
+ kind: "git",
252
+ identity: { mainRoot: plan.source.mainRoot, commonDir: plan.source.commonDir, checkoutRoot: cwd },
253
+ position: { branch: plan.branch, detached: false },
254
+ head: plan.baseCommit,
255
+ baseline: plan.baseCommit,
256
+ },
257
+ sourceCwd: plan.sourceCwd,
258
+ source: plan.source,
259
+ createdBranch: plan.branch,
260
+ requestedBase: plan.requestedBase,
261
+ baseCommit: plan.baseCommit,
262
+ sourceDirty: plan.sourceDirty,
263
+ };
264
+ }
265
+
266
+ /** Whether anything of the plan exists now: a checkout at or registered for the destination, and the branch's commit. */
267
+ function observe(plan: WorktreePlan): { registered: boolean; pathExists: boolean; target: string | null } {
268
+ const target = refTarget(plan.source.checkoutRoot, branchRef(plan.branch));
269
+ const listed = registered(plan.source.checkoutRoot).some((entry) => canonical(entry.path) === plan.destination);
270
+ return { registered: listed, pathExists: exists(plan.destination), target };
271
+ }
272
+
273
+ /** What of the plan exists now, as facts: the checkout and branch, the branch alone, or nothing. */
274
+ function remnants(plan: WorktreePlan): ResourceFacts {
275
+ try {
276
+ const { registered: checkoutRegistered, pathExists, target } = observe(plan);
277
+ if (checkoutRegistered && target !== null)
278
+ return { kind: "worktree", cwd: plan.destination, branch: plan.branch, head: target };
279
+ if (pathExists)
280
+ return unknown(
281
+ plan,
282
+ "a destination exists but is not verified as this worktree",
283
+ [plan.destination],
284
+ target === null ? [] : [branchRef(plan.branch)],
285
+ );
286
+ return target === null ? { kind: "none" } : { kind: "branch", branch: plan.branch, head: target };
287
+ } catch (err) {
288
+ return unknown(
289
+ plan,
290
+ `could not observe what exists: ${(err as Error).message}`,
291
+ [plan.destination],
292
+ [branchRef(plan.branch)],
293
+ );
294
+ }
295
+ }
296
+
297
+ const unknown = (
298
+ plan: WorktreePlan,
299
+ reason: string,
300
+ observedPaths: string[],
301
+ observedRefs: string[],
302
+ ): ResourceFacts => ({
303
+ kind: "unknown",
304
+ reason: `${reason} (planned ${plan.destination} and ${branchRef(plan.branch)})`,
305
+ observedPaths,
306
+ observedRefs,
307
+ });
308
+
309
+ export type Creation = { workspace: ManagedWorkspace } | { cause: string; resources: ResourceFacts };
310
+
311
+ /**
312
+ * Create the branch and checkout at the frozen commit. Ownership is proved
313
+ * only by a successful `worktree add` whose result matches the plan; after a
314
+ * failure, anything found at the planned names cannot be shown to be this
315
+ * call's, because another process may have created it.
316
+ */
317
+ export function createWorktree(plan: WorktreePlan): Creation {
318
+ let failure: string | undefined;
319
+ try {
320
+ mkdirSync(dirname(plan.destination), { recursive: true });
321
+ const added = askGit(
322
+ plan.source.checkoutRoot,
323
+ ["worktree", "add", "-b", plan.branch, plan.destination, plan.baseCommit],
324
+ MUTATE,
325
+ );
326
+ if (!added.ok) failure = `git worktree add failed: ${added.message}`;
327
+ } catch (err) {
328
+ failure = (err as Error).message;
329
+ }
330
+ if (failure === undefined) {
331
+ try {
332
+ return { workspace: verify(plan) };
333
+ } catch (err) {
334
+ failure = `the created worktree could not be verified: ${(err as Error).message}`;
335
+ }
336
+ }
337
+ try {
338
+ const { registered: checkoutRegistered, pathExists, target } = observe(plan);
339
+ if (!checkoutRegistered && !pathExists && target === null) return { cause: failure, resources: { kind: "none" } };
340
+ const reason = "something exists at the planned names, and this call cannot prove it created it";
341
+ return {
342
+ cause: failure,
343
+ resources: unknown(
344
+ plan,
345
+ reason,
346
+ pathExists || checkoutRegistered ? [plan.destination] : [],
347
+ target === null ? [] : [branchRef(plan.branch)],
348
+ ),
349
+ };
350
+ } catch (err) {
351
+ const reason = `could not observe what exists: ${(err as Error).message}`;
352
+ return { cause: failure, resources: unknown(plan, reason, [plan.destination], [branchRef(plan.branch)]) };
353
+ }
354
+ }
355
+
356
+ /** Every file under `root` (outside its `.git`) must be tracked; an empty or unknown entry is unaccounted data. */
357
+ function requireOnlyTracked(root: string): void {
358
+ const tracked = new Set(fields(must(root, ["ls-files", "-z", "--full-name"])));
359
+ let seen = 0;
360
+ const inspectEntry = (
361
+ entry: import("node:fs").Dirent,
362
+ dir: string,
363
+ prefix: string,
364
+ walk: (dir: string, prefix: string) => void,
365
+ ): void => {
366
+ const rel = `${prefix}${entry.name}`;
367
+ if (prefix === "" && entry.name === ".git") return;
368
+ if (++seen > WALK_LIMIT) throw new Error(`more than ${WALK_LIMIT} files to compare with the index`);
369
+ if (entry.isDirectory() && !tracked.has(rel)) {
370
+ if (readdirSync(join(dir, entry.name)).length === 0)
371
+ throw new Error(`the untracked empty directory ${rel} exists`);
372
+ walk(join(dir, entry.name), `${rel}/`);
373
+ } else if (!tracked.has(rel)) throw new Error(`the untracked or ignored file ${rel} exists`);
374
+ };
375
+ const walk = (dir: string, prefix: string): void => {
376
+ for (const entry of readdirSync(dir, { withFileTypes: true })) inspectEntry(entry, dir, prefix, walk);
377
+ };
378
+ walk(root, "");
379
+ }
380
+
381
+ /** Prove the checkout still is this call's pristine creation; throws what is not. */
382
+ function proveRemovable(plan: WorktreePlan, workspace: ManagedWorkspace): void {
383
+ if (verify(plan).cwd !== workspace.cwd) throw new Error("the checkout is not the one created");
384
+ const self = registered(workspace.cwd).find((entry) => canonical(entry.path) === workspace.cwd);
385
+ if (self === undefined || self.locked || self.prunable)
386
+ throw new Error("the checkout is locked, prunable or unregistered");
387
+ const status = must(workspace.cwd, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=matching"]);
388
+ if (status !== "") throw new Error("it has staged, unstaged, conflicted, untracked or ignored changes");
389
+ requireOnlyTracked(workspace.cwd);
390
+ }
391
+
392
+ export type Rollback = { kind: "rolled-back" } | { kind: "retained"; resources: ResourceFacts; reason: string };
393
+
394
+ /**
395
+ * Remove a created worktree and its branch when this call's pristine
396
+ * creation is proved, with no force. Anything unproved, or left behind by a
397
+ * partial removal, is retained and reported exactly.
398
+ */
399
+ export function rollbackWorktree(plan: WorktreePlan, workspace: ManagedWorkspace): Rollback {
400
+ const retain = (reason: string): Rollback => ({ kind: "retained", resources: remnants(plan), reason });
401
+ try {
402
+ proveRemovable(plan, workspace);
403
+ } catch (err) {
404
+ return retain(`ownership or state is not proved: ${(err as Error).message}`);
405
+ }
406
+ const removed = askGit(plan.source.checkoutRoot, ["worktree", "remove", workspace.cwd], MUTATE);
407
+ if (!removed.ok) return retain(`git worktree remove refused: ${removed.message}`);
408
+ if (remnants(plan).kind === "worktree") return retain("the worktree is still present after removal");
409
+ try {
410
+ const ref = branchRef(plan.branch);
411
+ if (registered(plan.source.checkoutRoot).some((entry) => entry.branch === ref)) {
412
+ return retain(`${plan.branch} is checked out in another worktree`);
413
+ }
414
+ const deleted = askGit(plan.source.checkoutRoot, ["update-ref", "-d", ref, plan.baseCommit]);
415
+ if (!deleted.ok) return retain(`the branch could not be deleted: ${deleted.message}`);
416
+ } catch (err) {
417
+ return retain(`the branch could not be checked: ${(err as Error).message}`);
418
+ }
419
+ const left = remnants(plan);
420
+ return left.kind === "none"
421
+ ? { kind: "rolled-back" }
422
+ : { kind: "retained", resources: left, reason: "the branch is still present" };
423
+ }
424
+
425
+ /** `text` as one shell word. */
426
+ const quote = (text: string): string => `'${text.replaceAll("'", "'\\''")}'`;
427
+
428
+ /** Non-forced commands that remove exactly the resources known to remain; none when ownership is unknown. */
429
+ export function recoveryCommands(plan: WorktreePlan, resources: ResourceFacts): string[] {
430
+ const source = `git -C ${quote(plan.source.checkoutRoot)}`;
431
+ if (resources.kind === "worktree") return [];
432
+ return resources.kind === "branch" ? [`${source} branch -d ${quote(resources.branch)}`] : [];
433
+ }
434
+
435
+ /** Where a waiting launch stops when its operation is aborted before it may create anything. */
436
+ const ABORTED_WAITING = "the launch was aborted; nothing was created";
437
+ const tails = new Map<string, Promise<void>>();
438
+
439
+ /**
440
+ * Run `work` after every earlier call for the same repository has settled,
441
+ * and release the next whatever `work` does. An abort while waiting skips
442
+ * `work`; once it runs it is never abandoned.
443
+ */
444
+ export function inQueue<T>(commonDir: string, signal: AbortSignal | undefined, work: () => Promise<T>): Promise<T> {
445
+ let started = false;
446
+ const run = async (): Promise<T> => {
447
+ if (signal?.aborted === true) throw new Error(ABORTED_WAITING);
448
+ started = true;
449
+ return work();
450
+ };
451
+ const before = tails.get(commonDir) ?? Promise.resolve();
452
+ const turn = before.then(run);
453
+ const tail = turn.then(
454
+ () => undefined,
455
+ () => undefined,
456
+ );
457
+ tails.set(commonDir, tail);
458
+ void tail.then(() => {
459
+ if (tails.get(commonDir) === tail) tails.delete(commonDir);
460
+ });
461
+ if (signal === undefined) return turn;
462
+ return Promise.race([
463
+ turn,
464
+ new Promise<never>((_, reject) => {
465
+ signal.addEventListener(
466
+ "abort",
467
+ () => {
468
+ if (!started) reject(new Error(ABORTED_WAITING));
469
+ },
470
+ { once: true },
471
+ );
472
+ }),
473
+ ]);
474
+ }