@signalridge/pi-worktree 0.49.3
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 +13 -0
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/package.json +65 -0
- package/src/command.ts +725 -0
- package/src/git.ts +1250 -0
- package/src/index.ts +1 -0
- package/src/safe-remove.ts +393 -0
- package/src/session.ts +95 -0
- package/src/settings.ts +285 -0
- package/src/worktree.ts +34 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./worktree.js";
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, readdir, rename, rmdir, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { moveWorktree, removeWorktreeMetadata, withWorktreeMutationLock } from "./git.js";
|
|
6
|
+
|
|
7
|
+
interface TreeSnapshot {
|
|
8
|
+
kind: "directory" | "leaf";
|
|
9
|
+
dev: number;
|
|
10
|
+
ino: number;
|
|
11
|
+
size: number;
|
|
12
|
+
mtimeMs: number;
|
|
13
|
+
ctimeMs: number;
|
|
14
|
+
children?: Map<string, TreeSnapshot>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class QuarantineRetainedError extends Error {
|
|
18
|
+
constructor(
|
|
19
|
+
readonly path: string,
|
|
20
|
+
message: string,
|
|
21
|
+
readonly outcomeUnknown = false,
|
|
22
|
+
) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "QuarantineRetainedError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sameIdentity(
|
|
29
|
+
actual: { dev: number; ino: number; size: number; mtimeMs: number; ctimeMs: number },
|
|
30
|
+
expected: TreeSnapshot,
|
|
31
|
+
): boolean {
|
|
32
|
+
if (actual.dev !== expected.dev || actual.ino !== expected.ino) return false;
|
|
33
|
+
if (expected.kind === "directory") return true;
|
|
34
|
+
return actual.size === expected.size && actual.mtimeMs === expected.mtimeMs && actual.ctimeMs === expected.ctimeMs;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sameIdentityAfterRename(
|
|
38
|
+
actual: { dev: number; ino: number; size: number; mtimeMs: number; ctimeMs: number },
|
|
39
|
+
expected: TreeSnapshot,
|
|
40
|
+
): boolean {
|
|
41
|
+
if (actual.dev !== expected.dev || actual.ino !== expected.ino) return false;
|
|
42
|
+
if (expected.kind === "directory") return true;
|
|
43
|
+
// On macOS, renaming a leaf changes ctime even though its inode and content
|
|
44
|
+
// are unchanged. The pre-rename identity check already covered ctime.
|
|
45
|
+
return actual.size === expected.size && actual.mtimeMs === expected.mtimeMs;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function snapshotTree(path: string): Promise<TreeSnapshot> {
|
|
49
|
+
const stat = await lstat(path);
|
|
50
|
+
const metadata = { dev: stat.dev, ino: stat.ino, size: stat.size, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs };
|
|
51
|
+
if (!stat.isDirectory()) return { kind: "leaf", ...metadata };
|
|
52
|
+
const children = new Map<string, TreeSnapshot>();
|
|
53
|
+
for (const name of await readdir(path)) {
|
|
54
|
+
children.set(name, await snapshotTree(join(path, name)));
|
|
55
|
+
}
|
|
56
|
+
return { kind: "directory", ...metadata, children };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sameSnapshotAfterGitMove(actual: TreeSnapshot, expected: TreeSnapshot, name?: string): boolean {
|
|
60
|
+
if (actual.kind !== expected.kind) return false;
|
|
61
|
+
if (actual.kind === "leaf") {
|
|
62
|
+
if (name === ".git") {
|
|
63
|
+
// Git updates this linked-worktree metadata file while moving the worktree.
|
|
64
|
+
return actual.dev === expected.dev && actual.ino === expected.ino && actual.size === expected.size;
|
|
65
|
+
}
|
|
66
|
+
return sameIdentity(actual, expected);
|
|
67
|
+
}
|
|
68
|
+
if (!sameIdentity(actual, expected)) return false;
|
|
69
|
+
const actualChildren = actual.children ?? new Map<string, TreeSnapshot>();
|
|
70
|
+
const expectedChildren = expected.children ?? new Map<string, TreeSnapshot>();
|
|
71
|
+
if (actualChildren.size !== expectedChildren.size) return false;
|
|
72
|
+
for (const [childName, expectedChild] of expectedChildren) {
|
|
73
|
+
const actualChild = actualChildren.get(childName);
|
|
74
|
+
if (!actualChild || !sameSnapshotAfterGitMove(actualChild, expectedChild, childName)) return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
async function claimFinalDeletion(path: string, expected: TreeSnapshot): Promise<string> {
|
|
79
|
+
const claimed = join(dirname(path), `.${randomUUID()}.pi-worktree-final-delete`);
|
|
80
|
+
try {
|
|
81
|
+
await rename(path, claimed);
|
|
82
|
+
const stat = await lstat(claimed);
|
|
83
|
+
if (!sameIdentityAfterRename(stat, expected)) {
|
|
84
|
+
throw new QuarantineRetainedError(claimed, `quarantine entry changed before final deletion: ${path}`);
|
|
85
|
+
}
|
|
86
|
+
return claimed;
|
|
87
|
+
} catch (error: unknown) {
|
|
88
|
+
if (error instanceof QuarantineRetainedError) throw error;
|
|
89
|
+
if (isNotFound(error)) {
|
|
90
|
+
throw new QuarantineRetainedError(
|
|
91
|
+
claimed,
|
|
92
|
+
`quarantine entry disappeared before final deletion; removal outcome is unknown: ${path}`,
|
|
93
|
+
true,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function removeSnapshot(
|
|
101
|
+
path: string,
|
|
102
|
+
expected: TreeSnapshot,
|
|
103
|
+
beforeDeleteEntry?: (entryPath: string) => Promise<void>,
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
let stat: Awaited<ReturnType<typeof lstat>>;
|
|
106
|
+
try {
|
|
107
|
+
stat = await lstat(path);
|
|
108
|
+
} catch (error: unknown) {
|
|
109
|
+
if (isNotFound(error)) {
|
|
110
|
+
throw new QuarantineRetainedError(
|
|
111
|
+
path,
|
|
112
|
+
`quarantine entry disappeared before deletion; removal outcome is unknown: ${path}`,
|
|
113
|
+
true,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
if (!sameIdentity(stat, expected)) throw new Error(`quarantine entry changed: ${path}`);
|
|
119
|
+
|
|
120
|
+
// Claim every entry, including directories, under a private tombstone before
|
|
121
|
+
// deleting descendants. A late writer can recreate the public path, but it
|
|
122
|
+
// can no longer cause this remover to delete a replacement tree there.
|
|
123
|
+
await beforeDeleteEntry?.(path);
|
|
124
|
+
const tombstone = join(dirname(path), `.${randomUUID()}.pi-worktree-delete`);
|
|
125
|
+
try {
|
|
126
|
+
await rename(path, tombstone);
|
|
127
|
+
} catch (error: unknown) {
|
|
128
|
+
if (isNotFound(error)) {
|
|
129
|
+
throw new QuarantineRetainedError(
|
|
130
|
+
path,
|
|
131
|
+
`quarantine entry disappeared before it could be claimed; removal outcome is unknown: ${path}`,
|
|
132
|
+
true,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const tombstoneStat = await lstat(tombstone);
|
|
139
|
+
if (!sameIdentityAfterRename(tombstoneStat, expected)) {
|
|
140
|
+
throw new Error(`quarantine entry changed while deleting: ${path}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (expected.kind === "leaf") {
|
|
144
|
+
const claimed = await claimFinalDeletion(tombstone, expected);
|
|
145
|
+
await unlink(claimed);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const children = expected.children ?? new Map<string, TreeSnapshot>();
|
|
149
|
+
const actualNames = await readdir(tombstone);
|
|
150
|
+
const expectedNames = new Set(children.keys());
|
|
151
|
+
const unexpectedNames = actualNames.filter((name) => !expectedNames.has(name));
|
|
152
|
+
const missingNames = [...expectedNames].filter((name) => !actualNames.includes(name) && name !== ".git");
|
|
153
|
+
if (unexpectedNames.length > 0 || missingNames.length > 0) {
|
|
154
|
+
throw new Error(`new quarantine data appeared: ${tombstone}`);
|
|
155
|
+
}
|
|
156
|
+
for (const [name, child] of children) {
|
|
157
|
+
if (name === ".git" && !actualNames.includes(name)) continue;
|
|
158
|
+
// Git may remove or rewrite this administrative pointer while
|
|
159
|
+
// deregistering the linked worktree; it is not user data. Snapshot its
|
|
160
|
+
// post-deregistration identity only for the guarded filesystem delete.
|
|
161
|
+
const deletionSnapshot = name === ".git" ? await snapshotTree(join(tombstone, name)) : child;
|
|
162
|
+
await removeSnapshot(join(tombstone, name), deletionSnapshot, beforeDeleteEntry);
|
|
163
|
+
}
|
|
164
|
+
await rmdir(await claimFinalDeletion(tombstone, expected));
|
|
165
|
+
} catch (error: unknown) {
|
|
166
|
+
if (error instanceof QuarantineRetainedError) throw error;
|
|
167
|
+
if (isNotFound(error)) {
|
|
168
|
+
throw new QuarantineRetainedError(
|
|
169
|
+
tombstone,
|
|
170
|
+
`quarantine entry disappeared during deletion; removal outcome is unknown: ${tombstone}`,
|
|
171
|
+
true,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
throw new QuarantineRetainedError(tombstone, errorDetail(error));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isNotFound(error: unknown): boolean {
|
|
179
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
183
|
+
try {
|
|
184
|
+
await lstat(path);
|
|
185
|
+
return true;
|
|
186
|
+
} catch (error: unknown) {
|
|
187
|
+
if (isNotFound(error)) return false;
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function reserveQuarantinePath(path: string): Promise<TreeSnapshot> {
|
|
193
|
+
// Keep a non-directory entry at the registered Git path. Creating it with
|
|
194
|
+
// the exclusive wx flag means a late writer that wins the brief rename
|
|
195
|
+
// window makes this operation fail closed instead of giving Git a recursive
|
|
196
|
+
// target.
|
|
197
|
+
await writeFile(path, `${randomUUID()}\n`, { flag: "wx", mode: 0o600 });
|
|
198
|
+
return snapshotTree(path);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function releaseQuarantineReservation(path: string, expected: TreeSnapshot): Promise<void> {
|
|
202
|
+
// Move the name to a private unique path before checking identity again. If a
|
|
203
|
+
// writer replaces the reservation after the first check, rename moves that
|
|
204
|
+
// replacement into retention instead of unlinking it through the public name.
|
|
205
|
+
const releasePath = join(dirname(path), `.${randomUUID()}.pi-worktree-reservation`);
|
|
206
|
+
try {
|
|
207
|
+
await rename(path, releasePath);
|
|
208
|
+
} catch (error: unknown) {
|
|
209
|
+
if (isNotFound(error)) return;
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
const stat = await lstat(releasePath);
|
|
213
|
+
if (!sameIdentityAfterRename(stat, expected)) {
|
|
214
|
+
throw new QuarantineRetainedError(releasePath, `quarantine reservation changed: ${path}`);
|
|
215
|
+
}
|
|
216
|
+
await unlink(releasePath);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function restoreQuarantine(
|
|
220
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
221
|
+
cwd: string,
|
|
222
|
+
quarantinePath: string,
|
|
223
|
+
originalPath: string,
|
|
224
|
+
): Promise<boolean> {
|
|
225
|
+
if (await pathExists(originalPath)) return false;
|
|
226
|
+
await moveWorktree(pi, cwd, quarantinePath, originalPath);
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
async function classifyMoveOutcome(
|
|
230
|
+
sourcePath: string,
|
|
231
|
+
quarantinePath: string,
|
|
232
|
+
): Promise<"not-moved" | "moved" | "unknown"> {
|
|
233
|
+
const sourceExists = await pathExists(sourcePath);
|
|
234
|
+
const quarantineExists = await pathExists(quarantinePath);
|
|
235
|
+
if (sourceExists && !quarantineExists) return "not-moved";
|
|
236
|
+
if (!sourceExists && quarantineExists) return "moved";
|
|
237
|
+
return "unknown";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function errorDetail(error: unknown): string {
|
|
241
|
+
return error instanceof Error ? error.message : String(error);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Remove Git metadata without giving Git a recursive path that may have changed
|
|
246
|
+
* since the last inventory. Git first moves the registered worktree to a quarantine
|
|
247
|
+
* path; the real tree then moves to a private tombstone before metadata-only
|
|
248
|
+
* deregistration. Any late-created or replaced entry leaves the tombstone intact.
|
|
249
|
+
*/
|
|
250
|
+
export type QuarantineValidator = (quarantinePath: string) => Promise<void>;
|
|
251
|
+
export type QuarantineDeleteObserver = (entryPath: string) => Promise<void>;
|
|
252
|
+
|
|
253
|
+
export async function removeWorktreeSafely(
|
|
254
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
255
|
+
cwd: string,
|
|
256
|
+
path: string,
|
|
257
|
+
signal: AbortSignal | undefined,
|
|
258
|
+
validateQuarantine: QuarantineValidator,
|
|
259
|
+
beforeDeleteEntry?: QuarantineDeleteObserver,
|
|
260
|
+
validateRegisteredWorktree?: () => Promise<void>,
|
|
261
|
+
): Promise<void> {
|
|
262
|
+
return withWorktreeMutationLock(
|
|
263
|
+
cwd,
|
|
264
|
+
async () => {
|
|
265
|
+
if (signal?.aborted) throw new Error("worktree removal aborted");
|
|
266
|
+
const quarantinePath = join(dirname(path), `.${randomUUID()}.pi-worktree-quarantine`);
|
|
267
|
+
let metadataRemoved = false;
|
|
268
|
+
let moved = false;
|
|
269
|
+
let tombstonePath: string | undefined;
|
|
270
|
+
let moveOutcomeUnknown = false;
|
|
271
|
+
let quarantineReservation: TreeSnapshot | undefined;
|
|
272
|
+
try {
|
|
273
|
+
await validateRegisteredWorktree?.();
|
|
274
|
+
const beforeSnapshot = await snapshotTree(path);
|
|
275
|
+
await moveWorktree(pi, cwd, path, quarantinePath, signal);
|
|
276
|
+
moved = true;
|
|
277
|
+
const snapshot = await snapshotTree(quarantinePath);
|
|
278
|
+
if (!sameSnapshotAfterGitMove(snapshot, beforeSnapshot)) {
|
|
279
|
+
throw new Error("worktree changed while entering quarantine");
|
|
280
|
+
}
|
|
281
|
+
await validateQuarantine(quarantinePath);
|
|
282
|
+
|
|
283
|
+
// Keep the real tree on a guarded tombstone. The registered path is reserved
|
|
284
|
+
// with an exclusive non-directory entry so Git never receives an unprotected
|
|
285
|
+
// absent path that a late writer can turn into a recursive target.
|
|
286
|
+
tombstonePath = `${quarantinePath}.pi-worktree-tombstone`;
|
|
287
|
+
await rename(quarantinePath, tombstonePath);
|
|
288
|
+
quarantineReservation = await reserveQuarantinePath(quarantinePath);
|
|
289
|
+
const reservedPath = await lstat(quarantinePath);
|
|
290
|
+
if (!sameIdentity(reservedPath, quarantineReservation)) {
|
|
291
|
+
throw new QuarantineRetainedError(quarantinePath, `quarantine reservation changed: ${quarantinePath}`);
|
|
292
|
+
}
|
|
293
|
+
await removeWorktreeMetadata(
|
|
294
|
+
pi,
|
|
295
|
+
cwd,
|
|
296
|
+
quarantinePath,
|
|
297
|
+
signal,
|
|
298
|
+
() => {
|
|
299
|
+
metadataRemoved = true;
|
|
300
|
+
},
|
|
301
|
+
true,
|
|
302
|
+
);
|
|
303
|
+
await releaseQuarantineReservation(quarantinePath, quarantineReservation);
|
|
304
|
+
quarantineReservation = undefined;
|
|
305
|
+
if (!tombstonePath || !(await pathExists(tombstonePath))) {
|
|
306
|
+
throw new QuarantineRetainedError(
|
|
307
|
+
tombstonePath ?? quarantinePath,
|
|
308
|
+
`quarantine tombstone disappeared before deletion; removal outcome is unknown: ${tombstonePath ?? quarantinePath}`,
|
|
309
|
+
true,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
await removeSnapshot(tombstonePath, snapshot, beforeDeleteEntry);
|
|
313
|
+
} catch (error: unknown) {
|
|
314
|
+
if (!tombstonePath && !metadataRemoved) {
|
|
315
|
+
try {
|
|
316
|
+
const outcome = await classifyMoveOutcome(path, quarantinePath);
|
|
317
|
+
if (outcome === "moved") moved = true;
|
|
318
|
+
else if (outcome === "not-moved") moved = false;
|
|
319
|
+
else moveOutcomeUnknown = true;
|
|
320
|
+
} catch {
|
|
321
|
+
moveOutcomeUnknown = true;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
let reservationError: unknown;
|
|
325
|
+
if (quarantineReservation) {
|
|
326
|
+
try {
|
|
327
|
+
await releaseQuarantineReservation(quarantinePath, quarantineReservation);
|
|
328
|
+
quarantineReservation = undefined;
|
|
329
|
+
} catch (candidateError: unknown) {
|
|
330
|
+
reservationError = candidateError;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const primaryError = reservationError ?? error;
|
|
334
|
+
if (moveOutcomeUnknown) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`Git worktree move outcome is unknown; inspect ${path} and ${quarantinePath} before retrying: ${errorDetail(primaryError)}`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
if (moved && !metadataRemoved) {
|
|
340
|
+
let restoreError: unknown = reservationError;
|
|
341
|
+
let restored = false;
|
|
342
|
+
if (!reservationError) {
|
|
343
|
+
try {
|
|
344
|
+
if (!(await pathExists(path))) {
|
|
345
|
+
if (tombstonePath && (await pathExists(tombstonePath))) {
|
|
346
|
+
if (await pathExists(quarantinePath)) {
|
|
347
|
+
throw new Error(`quarantine path was recreated before restoration: ${quarantinePath}`);
|
|
348
|
+
}
|
|
349
|
+
await rename(tombstonePath, quarantinePath);
|
|
350
|
+
}
|
|
351
|
+
restored = await restoreQuarantine(pi, cwd, quarantinePath, path);
|
|
352
|
+
}
|
|
353
|
+
} catch (candidateError: unknown) {
|
|
354
|
+
restoreError = candidateError;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (restored) throw primaryError;
|
|
358
|
+
const retainedPath =
|
|
359
|
+
primaryError instanceof QuarantineRetainedError
|
|
360
|
+
? primaryError.path
|
|
361
|
+
: tombstonePath && (await pathExists(tombstonePath))
|
|
362
|
+
? tombstonePath
|
|
363
|
+
: quarantinePath;
|
|
364
|
+
throw new Error(
|
|
365
|
+
`Git worktree removal failed: ${errorDetail(primaryError)}; quarantine retained at ${retainedPath}${
|
|
366
|
+
restoreError ? ` (${errorDetail(restoreError)})` : ". The original path was recreated."
|
|
367
|
+
}`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
if (metadataRemoved && tombstonePath) {
|
|
371
|
+
if (primaryError instanceof QuarantineRetainedError && primaryError.outcomeUnknown) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
`Worktree metadata was removed, but quarantine outcome is unknown: ${errorDetail(primaryError)}`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
const retainedPath =
|
|
377
|
+
primaryError instanceof QuarantineRetainedError
|
|
378
|
+
? primaryError.path
|
|
379
|
+
: (await pathExists(tombstonePath))
|
|
380
|
+
? tombstonePath
|
|
381
|
+
: undefined;
|
|
382
|
+
if (retainedPath) {
|
|
383
|
+
throw new Error(
|
|
384
|
+
`Worktree metadata was removed, but quarantine was retained at ${retainedPath}: ${errorDetail(primaryError)}`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
throw primaryError;
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
signal,
|
|
392
|
+
);
|
|
393
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
2
|
+
import type { ExtensionCommandContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { stripTerminalControls } from "./git.js";
|
|
5
|
+
|
|
6
|
+
export type WorktreeSwitchResult = "switched" | "cancelled" | "failed";
|
|
7
|
+
|
|
8
|
+
export async function switchToWorktree(
|
|
9
|
+
ctx: ExtensionCommandContext,
|
|
10
|
+
targetPath: string,
|
|
11
|
+
): Promise<WorktreeSwitchResult> {
|
|
12
|
+
let sessionPath: string | undefined;
|
|
13
|
+
try {
|
|
14
|
+
sessionPath = createTargetSession(ctx, targetPath);
|
|
15
|
+
const result = await ctx.switchSession(sessionPath, {
|
|
16
|
+
withSession: async (replacementCtx) => {
|
|
17
|
+
replacementCtx.ui.notify(stripTerminalControls(`Switched Pi workspace to ${targetPath}.`), "info");
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
if (result.cancelled) {
|
|
21
|
+
ctx.ui.notify(
|
|
22
|
+
stripTerminalControls(
|
|
23
|
+
`Workspace switch was cancelled. The prepared target session was retained at ${sessionPath}.`,
|
|
24
|
+
),
|
|
25
|
+
"info",
|
|
26
|
+
);
|
|
27
|
+
return "cancelled";
|
|
28
|
+
}
|
|
29
|
+
return "switched";
|
|
30
|
+
} catch (error) {
|
|
31
|
+
const retained = sessionPath ? " The prepared target session was retained." : "";
|
|
32
|
+
const message = stripTerminalControls(
|
|
33
|
+
`Could not switch Pi workspace to ${targetPath}.${retained} The worktree was retained. Retry from /worktree. ${formatError(error)}`,
|
|
34
|
+
);
|
|
35
|
+
try {
|
|
36
|
+
ctx.ui.notify(message, "error");
|
|
37
|
+
} catch {
|
|
38
|
+
console.error(message);
|
|
39
|
+
}
|
|
40
|
+
return "failed";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createTargetSession(ctx: ExtensionCommandContext, targetPath: string): string {
|
|
45
|
+
const sourceFile = ctx.sessionManager.getSessionFile();
|
|
46
|
+
if (sourceFile && existsSync(sourceFile)) {
|
|
47
|
+
const persisted = SessionManager.open(sourceFile);
|
|
48
|
+
const activeLeaf = ctx.sessionManager.getLeafId();
|
|
49
|
+
if (persisted.getLeafId() === activeLeaf) {
|
|
50
|
+
const forked = SessionManager.forkFrom(sourceFile, targetPath);
|
|
51
|
+
const targetFile = forked.getSessionFile();
|
|
52
|
+
if (!targetFile || !existsSync(targetFile)) {
|
|
53
|
+
throw new Error("Pi did not create the target worktree session file.");
|
|
54
|
+
}
|
|
55
|
+
return targetFile;
|
|
56
|
+
}
|
|
57
|
+
if (activeLeaf !== null && !persisted.getEntry(activeLeaf)) {
|
|
58
|
+
throw new Error("The active Pi session branch is not present in the persisted source file.");
|
|
59
|
+
}
|
|
60
|
+
return writeTargetSession(targetPath, ctx.sessionManager.getBranch(), sourceFile, activeLeaf);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (ctx.sessionManager.getEntries().length > 0) {
|
|
64
|
+
return writeTargetSession(targetPath, ctx.sessionManager.getBranch(), undefined, ctx.sessionManager.getLeafId());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return writeTargetSession(targetPath, [], undefined, null);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function writeTargetSession(
|
|
71
|
+
targetPath: string,
|
|
72
|
+
entries: readonly SessionEntry[],
|
|
73
|
+
parentSession: string | undefined,
|
|
74
|
+
expectedLeaf: string | null,
|
|
75
|
+
): string {
|
|
76
|
+
const target = SessionManager.create(targetPath, undefined, { parentSession });
|
|
77
|
+
const targetFile = target.getSessionFile();
|
|
78
|
+
const header = target.getHeader();
|
|
79
|
+
if (!targetFile || !header) throw new Error("Pi could not prepare a target session.");
|
|
80
|
+
const document = [header, ...entries].map((entry) => JSON.stringify(entry)).join("\n");
|
|
81
|
+
writeFileSync(targetFile, `${document}\n`, {
|
|
82
|
+
encoding: "utf8",
|
|
83
|
+
flag: "wx",
|
|
84
|
+
mode: 0o600,
|
|
85
|
+
});
|
|
86
|
+
const verified = SessionManager.open(targetFile);
|
|
87
|
+
if (verified.getCwd() !== targetPath || verified.getLeafId() !== expectedLeaf) {
|
|
88
|
+
throw new Error("Pi could not verify the target session cwd and active branch.");
|
|
89
|
+
}
|
|
90
|
+
return targetFile;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatError(error: unknown): string {
|
|
94
|
+
return error instanceof Error ? error.message : String(error);
|
|
95
|
+
}
|