@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/command.ts
ADDED
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
import { existsSync, lstatSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
5
|
+
import {
|
|
6
|
+
type AdministrativePruneCandidate,
|
|
7
|
+
addWorktree,
|
|
8
|
+
administrativeHistoryOids,
|
|
9
|
+
administrativePruneCandidates,
|
|
10
|
+
currentWorktreePath,
|
|
11
|
+
defaultWorktreePath,
|
|
12
|
+
durableRefExists,
|
|
13
|
+
durableRefsContaining,
|
|
14
|
+
formatWorktree,
|
|
15
|
+
listWorktrees,
|
|
16
|
+
localBranchExists,
|
|
17
|
+
pathEntryExists,
|
|
18
|
+
pathIdentity,
|
|
19
|
+
pathsEqual,
|
|
20
|
+
prunePreview,
|
|
21
|
+
pruneWorktrees,
|
|
22
|
+
resolveCommit,
|
|
23
|
+
sameWorktreeIdentity,
|
|
24
|
+
stripTerminalControls,
|
|
25
|
+
symbolicBranch,
|
|
26
|
+
unresolvableSymlinkAncestor,
|
|
27
|
+
validateBranch,
|
|
28
|
+
type WorktreeRecord,
|
|
29
|
+
withWorktreeMutationLock,
|
|
30
|
+
worktreeAdministrativeDirectory,
|
|
31
|
+
worktreeForBranch,
|
|
32
|
+
worktreeInventory,
|
|
33
|
+
} from "./git.js";
|
|
34
|
+
import { removeWorktreeSafely } from "./safe-remove.js";
|
|
35
|
+
import { switchToWorktree } from "./session.js";
|
|
36
|
+
import type { WorktreeSettingsRuntime } from "./settings.js";
|
|
37
|
+
|
|
38
|
+
const ACTION_ADD = "Add worktree";
|
|
39
|
+
const ACTION_SWITCH = "Switch worktree";
|
|
40
|
+
const ACTION_REMOVE = "Remove worktree";
|
|
41
|
+
const ACTION_PRUNE = "Prune stale metadata";
|
|
42
|
+
const ACTION_CONFIGURE_ROOT = "Configure worktree root";
|
|
43
|
+
const ACTIONS = {
|
|
44
|
+
add: ACTION_ADD,
|
|
45
|
+
switch: ACTION_SWITCH,
|
|
46
|
+
remove: ACTION_REMOVE,
|
|
47
|
+
prune: ACTION_PRUNE,
|
|
48
|
+
configure: ACTION_CONFIGURE_ROOT,
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
interface WorktreeMenuOwner {
|
|
52
|
+
signal: AbortSignal;
|
|
53
|
+
isCurrent(): boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface AdministrativeHistoryRisk {
|
|
57
|
+
label: string;
|
|
58
|
+
oids: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function registerWorktreeCommand(
|
|
62
|
+
pi: ExtensionAPI,
|
|
63
|
+
settings: WorktreeSettingsRuntime,
|
|
64
|
+
getMenuOwner: () => WorktreeMenuOwner,
|
|
65
|
+
): void {
|
|
66
|
+
pi.registerCommand("worktree", {
|
|
67
|
+
description: "Interactively manage Git worktrees and their default root",
|
|
68
|
+
handler: async (args, ctx) => {
|
|
69
|
+
if (args.trim()) {
|
|
70
|
+
safeNotify(ctx, "/worktree does not accept arguments; run it without arguments to open the menu.", "warning");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (!ctx.hasUI) {
|
|
74
|
+
safeNotify(ctx, "/worktree requires TUI or RPC mode.", "error");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
await ctx.waitForIdle();
|
|
80
|
+
const records = await listWorktrees(pi, ctx.cwd, ctx.signal);
|
|
81
|
+
const currentPath = await currentWorktreePath(pi, ctx.cwd, ctx.signal);
|
|
82
|
+
const root = settings.get();
|
|
83
|
+
const warning = root.warning ? " — settings warning" : "";
|
|
84
|
+
const owner = getMenuOwner();
|
|
85
|
+
if (owner.signal.aborted || !owner.isCurrent()) return;
|
|
86
|
+
const runFlow = async (flow: () => Promise<void>) => {
|
|
87
|
+
try {
|
|
88
|
+
await flow();
|
|
89
|
+
} catch (error) {
|
|
90
|
+
safeNotify(ctx, formatError(error), "error");
|
|
91
|
+
}
|
|
92
|
+
return { kind: "close" } as const;
|
|
93
|
+
};
|
|
94
|
+
type Screen = "main";
|
|
95
|
+
type Action = keyof typeof ACTIONS;
|
|
96
|
+
const menu = defineMenu<undefined, Screen, Action, ExtensionCommandContext>({
|
|
97
|
+
start: "main",
|
|
98
|
+
screens: {
|
|
99
|
+
main: () => ({
|
|
100
|
+
kind: "actions",
|
|
101
|
+
title: "Git worktrees",
|
|
102
|
+
lines: [
|
|
103
|
+
`Registered: ${records.length}`,
|
|
104
|
+
`Current: ${currentPath}`,
|
|
105
|
+
`Worktree root: ${root.effectiveRoot} (${root.source})${warning}`,
|
|
106
|
+
],
|
|
107
|
+
items: Object.entries(ACTIONS).map(([id, label]) => ({
|
|
108
|
+
id,
|
|
109
|
+
label,
|
|
110
|
+
action: id as Action,
|
|
111
|
+
})),
|
|
112
|
+
hint: "close",
|
|
113
|
+
}),
|
|
114
|
+
},
|
|
115
|
+
actions: {
|
|
116
|
+
add: async () => runFlow(() => addFlow(pi, ctx, records, root.effectiveRoot)),
|
|
117
|
+
switch: async ({ signal }) => runFlow(() => switchFlow(pi, ctx, records, currentPath, signal)),
|
|
118
|
+
remove: async ({ signal }) => runFlow(() => removeFlow(pi, ctx, records, currentPath, signal)),
|
|
119
|
+
prune: async () => runFlow(() => pruneFlow(pi, ctx, records)),
|
|
120
|
+
configure: async () => runFlow(() => configureRootFlow(ctx, settings)),
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
await runMenu(ctx, menu, {
|
|
124
|
+
getState: () => undefined,
|
|
125
|
+
signal: owner.signal,
|
|
126
|
+
isCurrent: owner.isCurrent,
|
|
127
|
+
});
|
|
128
|
+
} catch (error) {
|
|
129
|
+
safeNotify(ctx, formatError(error), "error");
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function configureRootFlow(ctx: ExtensionCommandContext, settings: WorktreeSettingsRuntime): Promise<void> {
|
|
136
|
+
const current = await settings.reload();
|
|
137
|
+
if (!current.canSave) {
|
|
138
|
+
throw new Error(current.warning ?? `Fix ${settings.getPath()} before changing pi-worktree settings.`);
|
|
139
|
+
}
|
|
140
|
+
const requested = await ctx.ui.input(
|
|
141
|
+
"Worktree root (blank restores ~/.worktrees)",
|
|
142
|
+
stripTerminalControls(current.configuredRoot ?? current.effectiveRoot),
|
|
143
|
+
);
|
|
144
|
+
if (requested === undefined) return;
|
|
145
|
+
const configuredRoot = requested.trim() || undefined;
|
|
146
|
+
const updated = await settings.save(configuredRoot);
|
|
147
|
+
safeNotify(
|
|
148
|
+
ctx,
|
|
149
|
+
configuredRoot === undefined
|
|
150
|
+
? `Worktree root reset to ${updated.effectiveRoot}.`
|
|
151
|
+
: `Worktree root saved as ${updated.effectiveRoot}.`,
|
|
152
|
+
"info",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function addFlow(
|
|
157
|
+
pi: ExtensionAPI,
|
|
158
|
+
ctx: ExtensionCommandContext,
|
|
159
|
+
records: readonly WorktreeRecord[],
|
|
160
|
+
worktreeRoot: string,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
const main = records[0];
|
|
163
|
+
if (!main) throw new Error("Git returned no registered worktrees.");
|
|
164
|
+
if (main.bare) {
|
|
165
|
+
throw new Error("The main worktree is bare; pi-worktree cannot derive a safe default path.");
|
|
166
|
+
}
|
|
167
|
+
if (!existsSync(main.path)) {
|
|
168
|
+
throw new Error(`The registered main worktree path is stale: ${main.path}. Repair it with Git first.`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const requestedBranch = await ctx.ui.input("Branch for the new worktree", "feat/my-change");
|
|
172
|
+
if (requestedBranch === undefined) return;
|
|
173
|
+
const branchInput = requestedBranch.trim();
|
|
174
|
+
if (!branchInput) throw new Error("Branch name is required.");
|
|
175
|
+
const branch = await validateBranch(pi, ctx.cwd, branchInput, ctx.signal);
|
|
176
|
+
const branchExists = await localBranchExists(pi, ctx.cwd, branch, ctx.signal);
|
|
177
|
+
const occupied = worktreeForBranch(records, branch);
|
|
178
|
+
if (occupied) {
|
|
179
|
+
throw new Error(`Branch ${branch} is already checked out at ${occupied.path}.`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let startOid: string | undefined;
|
|
183
|
+
let startLabel: string | undefined;
|
|
184
|
+
if (!branchExists) {
|
|
185
|
+
const defaultStart = await symbolicBranch(pi, ctx.cwd, ctx.signal);
|
|
186
|
+
const requestedStart = await ctx.ui.input(
|
|
187
|
+
stripTerminalControls(
|
|
188
|
+
defaultStart
|
|
189
|
+
? `Start point for ${branch} (blank uses ${defaultStart})`
|
|
190
|
+
: `Start point for ${branch} (required because HEAD is detached)`,
|
|
191
|
+
),
|
|
192
|
+
stripTerminalControls(defaultStart ?? "commit-ish"),
|
|
193
|
+
);
|
|
194
|
+
if (requestedStart === undefined) return;
|
|
195
|
+
startLabel = requestedStart.trim() || defaultStart;
|
|
196
|
+
if (!startLabel) throw new Error("An explicit start point is required from detached HEAD.");
|
|
197
|
+
startOid = await resolveCommit(pi, ctx.cwd, startLabel, ctx.signal);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const suggestedPath = defaultWorktreePath(main.path, branch, worktreeRoot);
|
|
201
|
+
const requestedPath = await ctx.ui.input(
|
|
202
|
+
stripTerminalControls(`Worktree path (blank uses ${suggestedPath})`),
|
|
203
|
+
stripTerminalControls(suggestedPath),
|
|
204
|
+
);
|
|
205
|
+
if (requestedPath === undefined) return;
|
|
206
|
+
const targetPath = pathIdentity(requestedPath.trim() ? resolve(ctx.cwd, requestedPath.trim()) : suggestedPath);
|
|
207
|
+
assertTargetFilesystemAvailable(targetPath);
|
|
208
|
+
const pathCollision = records.find((record) => pathsEqual(record.path, targetPath));
|
|
209
|
+
if (pathCollision) {
|
|
210
|
+
throw new Error(`The target path is already registered as a worktree: ${pathCollision.path}.`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const summary = branchExists
|
|
214
|
+
? `Attach existing branch ${branch} at ${targetPath}?`
|
|
215
|
+
: `Create branch ${branch} from ${startLabel} at ${targetPath}?`;
|
|
216
|
+
if (!(await ctx.ui.confirm("Create Git worktree", stripTerminalControls(summary)))) return;
|
|
217
|
+
|
|
218
|
+
assertTargetFilesystemAvailable(targetPath);
|
|
219
|
+
await addWorktree(pi, ctx.cwd, { path: targetPath, branch, startOid }, ctx.signal);
|
|
220
|
+
let created: WorktreeRecord;
|
|
221
|
+
try {
|
|
222
|
+
const updated = await listWorktrees(pi, ctx.cwd, ctx.signal);
|
|
223
|
+
const verified = updated.find((record) => pathsEqual(record.path, targetPath));
|
|
224
|
+
if (!verified || verified.branch !== branch) {
|
|
225
|
+
throw new Error("the expected path and branch were not present in Git porcelain output");
|
|
226
|
+
}
|
|
227
|
+
created = verified;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Git add completed, so the worktree was retained at ${targetPath}, but verification failed: ${formatError(error)}. Inspect git worktree list before retrying.`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
safeNotify(ctx, `Created worktree ${targetPath} on branch ${branch}.`, "info");
|
|
234
|
+
|
|
235
|
+
if (
|
|
236
|
+
await ctx.ui.confirm("Switch Pi workspace?", stripTerminalControls(`Continue this conversation in ${targetPath}?`))
|
|
237
|
+
) {
|
|
238
|
+
const latest = await revalidateWorktreeIdentity(pi, ctx, created);
|
|
239
|
+
if (latest.prunableReason !== undefined || !existsSync(latest.path)) {
|
|
240
|
+
throw new Error("The newly created worktree became unavailable; select it again.");
|
|
241
|
+
}
|
|
242
|
+
await switchToWorktree(ctx, latest.path);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function assertTargetFilesystemAvailable(targetPath: string): void {
|
|
247
|
+
if (pathEntryExists(targetPath)) {
|
|
248
|
+
throw new Error(`The target path already exists: ${targetPath}.`);
|
|
249
|
+
}
|
|
250
|
+
const unsafeAncestor = unresolvableSymlinkAncestor(targetPath);
|
|
251
|
+
if (unsafeAncestor) {
|
|
252
|
+
throw new Error(`The target path has an unresolvable symbolic-link ancestor: ${unsafeAncestor}.`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function switchFlow(
|
|
257
|
+
pi: ExtensionAPI,
|
|
258
|
+
ctx: ExtensionCommandContext,
|
|
259
|
+
records: readonly WorktreeRecord[],
|
|
260
|
+
currentPath: string,
|
|
261
|
+
signal?: AbortSignal,
|
|
262
|
+
): Promise<void> {
|
|
263
|
+
const candidates = records.filter(
|
|
264
|
+
(record) =>
|
|
265
|
+
!record.bare &&
|
|
266
|
+
record.prunableReason === undefined &&
|
|
267
|
+
existsSync(record.path) &&
|
|
268
|
+
!pathsEqual(record.path, currentPath),
|
|
269
|
+
);
|
|
270
|
+
const selected = await selectWorktree(ctx, "Switch to worktree", candidates, currentPath, signal);
|
|
271
|
+
if (!selected) return;
|
|
272
|
+
const latest = await revalidateWorktreeIdentity(pi, ctx, selected);
|
|
273
|
+
if (
|
|
274
|
+
latest.bare ||
|
|
275
|
+
latest.prunableReason !== undefined ||
|
|
276
|
+
!existsSync(latest.path) ||
|
|
277
|
+
pathsEqual(latest.path, currentPath)
|
|
278
|
+
) {
|
|
279
|
+
throw new Error("The selected worktree changed state; select it again.");
|
|
280
|
+
}
|
|
281
|
+
await switchToWorktree(ctx, latest.path);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function removeFlow(
|
|
285
|
+
pi: ExtensionAPI,
|
|
286
|
+
ctx: ExtensionCommandContext,
|
|
287
|
+
records: readonly WorktreeRecord[],
|
|
288
|
+
currentPath: string,
|
|
289
|
+
signal?: AbortSignal,
|
|
290
|
+
): Promise<void> {
|
|
291
|
+
const candidates = records.filter(
|
|
292
|
+
(record) => !record.isMain && !record.bare && !pathsEqual(record.path, currentPath),
|
|
293
|
+
);
|
|
294
|
+
const selected = await selectWorktree(ctx, "Remove linked worktree", candidates, currentPath, signal);
|
|
295
|
+
if (!selected) return;
|
|
296
|
+
if (selected.lockedReason !== undefined) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
`Worktree is locked${selected.lockedReason ? `: ${selected.lockedReason}` : "."} Unlock it explicitly with Git before removal.`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
if (selected.prunableReason !== undefined || !existsSync(selected.path)) {
|
|
302
|
+
throw new Error("The selected worktree path is stale. Use prune instead of remove.");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const selectedFilesystemIdentity = removableFilesystemIdentity(selected.path);
|
|
306
|
+
const inventory = classifyRemovalInventory(await worktreeInventory(pi, selected.path, ctx.signal));
|
|
307
|
+
if (inventory.protected.length > 0) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`Removal refused because ${selected.path} contains tracked, untracked, index-flagged, or submodule data:\n${inventory.protected.join("\n")}`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
if (inventory.ignored.length > 0) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`Removal refused because ${selected.path} contains ignored local data that would be deleted:\n${inventory.ignored.join("\n")}\nRemove it manually before removing the worktree.`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
await assertDetachedHeadIsDurable(pi, ctx, selected);
|
|
318
|
+
const administrativePath = await worktreeAdministrativeDirectory(pi, selected.path, ctx.signal);
|
|
319
|
+
const approvedHistoryRisks = historyRisks(
|
|
320
|
+
selected.path,
|
|
321
|
+
await unreachableAdministrativeHistoryOids(pi, ctx, administrativePath),
|
|
322
|
+
);
|
|
323
|
+
const recoveryWarning = formatAdministrativeRecoveryWarning(approvedHistoryRisks);
|
|
324
|
+
const removalWarning = recoveryWarning;
|
|
325
|
+
const confirmationTitle = recoveryWarning ? "Remove worktree and discard recovery history" : "Remove Git worktree";
|
|
326
|
+
if (
|
|
327
|
+
!(await ctx.ui.confirm(
|
|
328
|
+
confirmationTitle,
|
|
329
|
+
`Delete the worktree directory ${stripTerminalControls(selected.path)}? The branch will be preserved.${removalWarning}`,
|
|
330
|
+
))
|
|
331
|
+
) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
await assertAdministrativeHistoryUnchanged(pi, ctx, selected.path, administrativePath, approvedHistoryRisks);
|
|
336
|
+
|
|
337
|
+
const beforeRemoval = await listWorktrees(pi, ctx.cwd, ctx.signal);
|
|
338
|
+
const latest = beforeRemoval.find((record) => pathsEqual(record.path, selected.path));
|
|
339
|
+
if (!latest) throw new Error(`Worktree ${selected.path} is no longer registered.`);
|
|
340
|
+
if (!sameWorktreeIdentity(selected, latest)) {
|
|
341
|
+
throw new Error(`Worktree ${selected.path} changed identity; select it again.`);
|
|
342
|
+
}
|
|
343
|
+
if (latest.isMain || latest.lockedReason !== undefined || latest.prunableReason !== undefined) {
|
|
344
|
+
throw new Error(`Worktree ${selected.path} changed state after confirmation; removal was refused.`);
|
|
345
|
+
}
|
|
346
|
+
if (removableFilesystemIdentity(latest.path) !== selectedFilesystemIdentity) {
|
|
347
|
+
throw new Error(`Worktree ${selected.path} changed filesystem identity; select it again.`);
|
|
348
|
+
}
|
|
349
|
+
const latestInventory = classifyRemovalInventory(await worktreeInventory(pi, latest.path, ctx.signal));
|
|
350
|
+
if (latestInventory.protected.length > 0) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`Removal refused because new protected local data appeared after confirmation:\n${latestInventory.protected.join("\n")}`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
if (!sameInventory(inventory.ignored, latestInventory.ignored)) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
`Removal refused because ignored data changed after confirmation:\n${latestInventory.ignored.join("\n") || "(none)"}`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
await assertDetachedHeadIsDurable(pi, ctx, latest);
|
|
361
|
+
await assertAdministrativeHistoryUnchanged(pi, ctx, latest.path, administrativePath, approvedHistoryRisks);
|
|
362
|
+
const finalInventory = classifyRemovalInventory(await worktreeInventory(pi, latest.path, ctx.signal));
|
|
363
|
+
if (finalInventory.protected.length > 0) {
|
|
364
|
+
throw new Error(
|
|
365
|
+
`Removal refused because new protected local data appeared before deletion:\n${finalInventory.protected.join("\n")}`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
if (!sameInventory(inventory.ignored, finalInventory.ignored)) {
|
|
369
|
+
throw new Error(
|
|
370
|
+
`Removal refused because ignored data changed before deletion:\n${finalInventory.ignored.join("\n") || "(none)"}`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
if (removableFilesystemIdentity(latest.path) !== selectedFilesystemIdentity) {
|
|
374
|
+
throw new Error(`Worktree ${selected.path} changed filesystem identity before deletion.`);
|
|
375
|
+
}
|
|
376
|
+
await removeWorktreeSafely(
|
|
377
|
+
pi,
|
|
378
|
+
ctx.cwd,
|
|
379
|
+
latest.path,
|
|
380
|
+
ctx.signal,
|
|
381
|
+
async (quarantinePath) => {
|
|
382
|
+
const quarantineInventory = classifyRemovalInventory(await worktreeInventory(pi, quarantinePath, ctx.signal));
|
|
383
|
+
if (quarantineInventory.protected.length > 0) {
|
|
384
|
+
throw new Error(
|
|
385
|
+
`Removal refused because protected local data appeared after quarantine:\n${quarantineInventory.protected.join("\\n")}`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
if (quarantineInventory.ignored.length > 0) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
`Removal refused because ignored local data appeared after quarantine:\n${quarantineInventory.ignored.join("\\n")}`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
undefined,
|
|
395
|
+
async () => {
|
|
396
|
+
const locked = (await listWorktrees(pi, ctx.cwd, ctx.signal)).find((record) =>
|
|
397
|
+
pathsEqual(record.path, latest.path),
|
|
398
|
+
);
|
|
399
|
+
if (
|
|
400
|
+
!locked ||
|
|
401
|
+
!sameWorktreeIdentity(latest, locked) ||
|
|
402
|
+
locked.isMain ||
|
|
403
|
+
locked.lockedReason !== undefined ||
|
|
404
|
+
locked.prunableReason !== undefined
|
|
405
|
+
) {
|
|
406
|
+
throw new Error(`Worktree ${selected.path} changed identity while waiting for removal; removal was refused.`);
|
|
407
|
+
}
|
|
408
|
+
if (removableFilesystemIdentity(locked.path) !== selectedFilesystemIdentity) {
|
|
409
|
+
throw new Error(`Worktree ${selected.path} changed filesystem identity while waiting for removal.`);
|
|
410
|
+
}
|
|
411
|
+
await assertDetachedHeadIsDurable(pi, ctx, locked);
|
|
412
|
+
await assertAdministrativeHistoryUnchanged(pi, ctx, locked.path, administrativePath, approvedHistoryRisks);
|
|
413
|
+
},
|
|
414
|
+
);
|
|
415
|
+
const updated = await listWorktrees(pi, ctx.cwd, ctx.signal);
|
|
416
|
+
if (updated.some((record) => pathsEqual(record.path, selected.path))) {
|
|
417
|
+
throw new Error(`Git remove returned success, but ${selected.path} is still registered.`);
|
|
418
|
+
}
|
|
419
|
+
safeNotify(ctx, `Removed worktree ${selected.path}. Its branch was preserved.`, "info");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function pruneFlow(
|
|
423
|
+
pi: ExtensionAPI,
|
|
424
|
+
ctx: ExtensionCommandContext,
|
|
425
|
+
records: readonly WorktreeRecord[],
|
|
426
|
+
): Promise<void> {
|
|
427
|
+
for (const record of records.filter((candidate) => candidate.prunableReason !== undefined && candidate.detached)) {
|
|
428
|
+
await assertDetachedHeadIsDurable(pi, ctx, record);
|
|
429
|
+
}
|
|
430
|
+
const approvedAdministrativeCandidates = await administrativePruneCandidates(pi, ctx.cwd, ctx.signal);
|
|
431
|
+
const approvedHistoryRisks = await inspectAdministrativePruneCandidates(pi, ctx, approvedAdministrativeCandidates);
|
|
432
|
+
const preview = await prunePreview(pi, ctx.cwd, ctx.signal);
|
|
433
|
+
if (!preview) {
|
|
434
|
+
ctx.ui.notify("Git found no stale worktree metadata to prune.", "info");
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const safePreview = stripTerminalControls(preview);
|
|
438
|
+
const recoveryWarning = formatAdministrativeRecoveryWarning(approvedHistoryRisks);
|
|
439
|
+
const administrativeSummary = formatAdministrativeCandidateSummary(approvedAdministrativeCandidates);
|
|
440
|
+
ctx.ui.notify(`git worktree prune --dry-run --verbose\n${safePreview}${administrativeSummary}`, "warning");
|
|
441
|
+
if (
|
|
442
|
+
!(await ctx.ui.confirm(
|
|
443
|
+
recoveryWarning ? "Prune metadata and discard recovery history" : "Prune stale worktree metadata",
|
|
444
|
+
stripTerminalControls(`${safePreview}${administrativeSummary}${recoveryWarning}`),
|
|
445
|
+
))
|
|
446
|
+
) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const output = await withWorktreeMutationLock(
|
|
450
|
+
ctx.cwd,
|
|
451
|
+
async () => {
|
|
452
|
+
const latest = await listWorktrees(pi, ctx.cwd, ctx.signal);
|
|
453
|
+
for (const record of latest.filter((candidate) => candidate.prunableReason !== undefined && candidate.detached)) {
|
|
454
|
+
await assertDetachedHeadIsDurable(pi, ctx, record);
|
|
455
|
+
}
|
|
456
|
+
const beforePreviewHistoryRisks = await inspectAdministrativePruneCandidates(pi, ctx);
|
|
457
|
+
if (!sameAdministrativeHistoryRisks(approvedHistoryRisks, beforePreviewHistoryRisks)) {
|
|
458
|
+
throw new Error("Stale worktree metadata changed after confirmation; run prune again.");
|
|
459
|
+
}
|
|
460
|
+
const latestPreview = await prunePreview(pi, ctx.cwd, ctx.signal);
|
|
461
|
+
const finalHistoryRisks = await inspectAdministrativePruneCandidates(pi, ctx);
|
|
462
|
+
if (latestPreview !== preview || !sameAdministrativeHistoryRisks(approvedHistoryRisks, finalHistoryRisks)) {
|
|
463
|
+
throw new Error("Stale worktree metadata changed after confirmation; run prune again.");
|
|
464
|
+
}
|
|
465
|
+
const approvedWorktreePaths = latest
|
|
466
|
+
.filter((record) => record.prunableReason !== undefined)
|
|
467
|
+
.map((record) => record.path);
|
|
468
|
+
const approvedAdministrativePaths = approvedAdministrativeCandidates.map(
|
|
469
|
+
(candidate) => candidate.administrativePath,
|
|
470
|
+
);
|
|
471
|
+
const output = await pruneWorktrees(pi, ctx.cwd, ctx.signal, true, approvedAdministrativeCandidates);
|
|
472
|
+
const remaining = await listWorktrees(pi, ctx.cwd, ctx.signal);
|
|
473
|
+
if (
|
|
474
|
+
remaining.some((record) => approvedWorktreePaths.some((approvedPath) => pathsEqual(approvedPath, record.path)))
|
|
475
|
+
) {
|
|
476
|
+
throw new Error("Git did not remove every approved stale worktree record; no success was reported.");
|
|
477
|
+
}
|
|
478
|
+
if (remaining.some((record) => record.prunableReason !== undefined)) {
|
|
479
|
+
throw new Error("Git left stale worktree metadata after pruning; no success was reported.");
|
|
480
|
+
}
|
|
481
|
+
if (approvedAdministrativePaths.some((path) => existsSync(path))) {
|
|
482
|
+
throw new Error("Git did not remove every approved administrative record; no success was reported.");
|
|
483
|
+
}
|
|
484
|
+
if ((await administrativePruneCandidates(pi, ctx.cwd, ctx.signal)).length > 0) {
|
|
485
|
+
throw new Error("Git left stale administrative records after pruning; no success was reported.");
|
|
486
|
+
}
|
|
487
|
+
return output;
|
|
488
|
+
},
|
|
489
|
+
ctx.signal,
|
|
490
|
+
);
|
|
491
|
+
safeNotify(ctx, output ? `Pruned stale worktree metadata:\n${output}` : "Pruned stale worktree metadata.", "info");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function assertAdministrativeHistoryUnchanged(
|
|
495
|
+
pi: ExtensionAPI,
|
|
496
|
+
ctx: ExtensionCommandContext,
|
|
497
|
+
selectedPath: string,
|
|
498
|
+
approvedAdministrativePath: string,
|
|
499
|
+
approvedHistoryRisks: readonly AdministrativeHistoryRisk[],
|
|
500
|
+
): Promise<void> {
|
|
501
|
+
const latestAdministrativePath = await worktreeAdministrativeDirectory(pi, selectedPath, ctx.signal);
|
|
502
|
+
const latestHistoryRisks = historyRisks(
|
|
503
|
+
selectedPath,
|
|
504
|
+
await unreachableAdministrativeHistoryOids(pi, ctx, latestAdministrativePath),
|
|
505
|
+
);
|
|
506
|
+
if (
|
|
507
|
+
!pathsEqual(approvedAdministrativePath, latestAdministrativePath) ||
|
|
508
|
+
!sameAdministrativeHistoryRisks(approvedHistoryRisks, latestHistoryRisks)
|
|
509
|
+
) {
|
|
510
|
+
throw new Error(
|
|
511
|
+
`Worktree ${selectedPath} administrative recovery history changed after confirmation; select it again.`,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function inspectAdministrativePruneCandidates(
|
|
517
|
+
pi: ExtensionAPI,
|
|
518
|
+
ctx: ExtensionCommandContext,
|
|
519
|
+
candidates?: readonly AdministrativePruneCandidate[],
|
|
520
|
+
): Promise<AdministrativeHistoryRisk[]> {
|
|
521
|
+
const risks: AdministrativeHistoryRisk[] = [];
|
|
522
|
+
for (const candidate of candidates ?? (await administrativePruneCandidates(pi, ctx.cwd, ctx.signal))) {
|
|
523
|
+
if (candidate.indexDirty) {
|
|
524
|
+
throw new Error(
|
|
525
|
+
`Prune refused because administrative worktree ${candidate.id} contains staged-only index changes.`,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
if (candidate.head) {
|
|
529
|
+
const refs = await durableRefsContaining(pi, ctx.cwd, candidate.head, ctx.signal);
|
|
530
|
+
if (refs.length === 0) {
|
|
531
|
+
throw new Error(
|
|
532
|
+
`Prune refused because administrative worktree ${candidate.id} has detached HEAD ${candidate.head}, which is not reachable from a durable ref.`,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
} else if (!candidate.branchRef || !(await durableRefExists(pi, ctx.cwd, candidate.branchRef, ctx.signal))) {
|
|
536
|
+
throw new Error(
|
|
537
|
+
`Prune refused because administrative worktree ${candidate.id} does not resolve to a durable ref.`,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
risks.push(
|
|
541
|
+
...historyRisks(candidate.id, await unreachableAdministrativeHistoryOids(pi, ctx, candidate.administrativePath)),
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
return normalizeAdministrativeHistoryRisks(risks);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function unreachableAdministrativeHistoryOids(
|
|
548
|
+
pi: ExtensionAPI,
|
|
549
|
+
ctx: ExtensionCommandContext,
|
|
550
|
+
administrativePath: string,
|
|
551
|
+
): Promise<string[]> {
|
|
552
|
+
const unreachable: string[] = [];
|
|
553
|
+
for (const oid of await administrativeHistoryOids(pi, ctx.cwd, administrativePath, ctx.signal)) {
|
|
554
|
+
const refs = await durableRefsContaining(pi, ctx.cwd, oid, ctx.signal);
|
|
555
|
+
if (refs.length === 0) unreachable.push(oid);
|
|
556
|
+
}
|
|
557
|
+
return [...new Set(unreachable)].sort();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function historyRisks(label: string, oids: string[]): AdministrativeHistoryRisk[] {
|
|
561
|
+
return oids.length > 0 ? [{ label, oids }] : [];
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function normalizeAdministrativeHistoryRisks(risks: readonly AdministrativeHistoryRisk[]): AdministrativeHistoryRisk[] {
|
|
565
|
+
return risks
|
|
566
|
+
.map((risk) => ({ label: risk.label, oids: [...new Set(risk.oids)].sort() }))
|
|
567
|
+
.filter((risk) => risk.oids.length > 0)
|
|
568
|
+
.sort((left, right) => left.label.localeCompare(right.label));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function sameAdministrativeHistoryRisks(
|
|
572
|
+
left: readonly AdministrativeHistoryRisk[],
|
|
573
|
+
right: readonly AdministrativeHistoryRisk[],
|
|
574
|
+
): boolean {
|
|
575
|
+
return (
|
|
576
|
+
JSON.stringify(normalizeAdministrativeHistoryRisks(left)) ===
|
|
577
|
+
JSON.stringify(normalizeAdministrativeHistoryRisks(right))
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function formatAdministrativeRecoveryWarning(risks: readonly AdministrativeHistoryRisk[]): string {
|
|
582
|
+
if (risks.length === 0) return "";
|
|
583
|
+
const entries = risks
|
|
584
|
+
.map((risk) => `${stripTerminalControls(risk.label)}: ${risk.oids.map(stripTerminalControls).join(", ")}`)
|
|
585
|
+
.join("; ");
|
|
586
|
+
return ` Administrative recovery warning: these commits are not reachable from a branch, tag, or remote ref: ${entries}. Discarding their recovery pointers means they may later be garbage-collected.`;
|
|
587
|
+
}
|
|
588
|
+
function formatAdministrativeCandidateSummary(candidates: readonly AdministrativePruneCandidate[]): string {
|
|
589
|
+
if (candidates.length === 0) return "";
|
|
590
|
+
const ids = candidates.map((candidate) => stripTerminalControls(candidate.id)).join(", ");
|
|
591
|
+
return ` Administrative metadata records selected for deletion: ${ids}.`;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
interface RemovalInventory {
|
|
595
|
+
ignored: string[];
|
|
596
|
+
protected: string[];
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function classifyRemovalInventory(lines: readonly string[]): RemovalInventory {
|
|
600
|
+
const ignored: string[] = [];
|
|
601
|
+
const protectedData: string[] = [];
|
|
602
|
+
for (const line of lines) {
|
|
603
|
+
(line.startsWith("!! ") ? ignored : protectedData).push(line);
|
|
604
|
+
}
|
|
605
|
+
return {
|
|
606
|
+
ignored: normalizeInventory(ignored),
|
|
607
|
+
protected: normalizeInventory(protectedData),
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function removableFilesystemIdentity(path: string): string {
|
|
612
|
+
const identities: string[] = [];
|
|
613
|
+
const original = resolve(path);
|
|
614
|
+
let current = original;
|
|
615
|
+
while (true) {
|
|
616
|
+
let stat: ReturnType<typeof lstatSync>;
|
|
617
|
+
try {
|
|
618
|
+
stat = lstatSync(current);
|
|
619
|
+
} catch (error) {
|
|
620
|
+
throw new Error(`Cannot inspect worktree path ${current}: ${formatError(error)}`);
|
|
621
|
+
}
|
|
622
|
+
if (current === original && stat.isSymbolicLink()) {
|
|
623
|
+
throw new Error(`Refusing to remove worktree through symbolic-link path ${current}.`);
|
|
624
|
+
}
|
|
625
|
+
if (current === original && !stat.isDirectory()) {
|
|
626
|
+
throw new Error(`The selected worktree path is not a directory: ${current}.`);
|
|
627
|
+
}
|
|
628
|
+
identities.push(`${current}:${stat.dev}:${stat.ino}:${stat.mode}`);
|
|
629
|
+
const parent = resolve(current, "..");
|
|
630
|
+
if (parent === current) break;
|
|
631
|
+
current = parent;
|
|
632
|
+
}
|
|
633
|
+
return identities.join("|");
|
|
634
|
+
}
|
|
635
|
+
function normalizeInventory(lines: readonly string[]): string[] {
|
|
636
|
+
return [...new Set(lines)].sort();
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function sameInventory(left: readonly string[], right: readonly string[]): boolean {
|
|
640
|
+
return JSON.stringify(normalizeInventory(left)) === JSON.stringify(normalizeInventory(right));
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async function assertDetachedHeadIsDurable(
|
|
644
|
+
pi: ExtensionAPI,
|
|
645
|
+
ctx: ExtensionCommandContext,
|
|
646
|
+
record: WorktreeRecord,
|
|
647
|
+
): Promise<void> {
|
|
648
|
+
if (!record.detached) return;
|
|
649
|
+
if (!record.head) throw new Error(`Detached worktree ${record.path} has no HEAD object; refusing.`);
|
|
650
|
+
const refs = await durableRefsContaining(pi, ctx.cwd, record.head, ctx.signal);
|
|
651
|
+
if (refs.length === 0) {
|
|
652
|
+
throw new Error(
|
|
653
|
+
`Detached HEAD ${record.head} at ${record.path} is not reachable from a local branch, tag, or remote ref. Preserve it before continuing.`,
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function revalidateWorktreeIdentity(
|
|
659
|
+
pi: ExtensionAPI,
|
|
660
|
+
ctx: ExtensionCommandContext,
|
|
661
|
+
selected: WorktreeRecord,
|
|
662
|
+
): Promise<WorktreeRecord> {
|
|
663
|
+
const latest = (await listWorktrees(pi, ctx.cwd, ctx.signal)).find((record) =>
|
|
664
|
+
pathsEqual(record.path, selected.path),
|
|
665
|
+
);
|
|
666
|
+
if (!latest) throw new Error(`Worktree ${selected.path} is no longer registered.`);
|
|
667
|
+
if (!sameWorktreeIdentity(selected, latest)) {
|
|
668
|
+
throw new Error(`Worktree ${selected.path} changed identity; select it again.`);
|
|
669
|
+
}
|
|
670
|
+
return latest;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async function selectWorktree(
|
|
674
|
+
ctx: ExtensionCommandContext,
|
|
675
|
+
title: string,
|
|
676
|
+
records: readonly WorktreeRecord[],
|
|
677
|
+
currentPath: string,
|
|
678
|
+
signal?: AbortSignal,
|
|
679
|
+
): Promise<WorktreeRecord | undefined> {
|
|
680
|
+
if (records.length === 0) {
|
|
681
|
+
ctx.ui.notify("No eligible worktrees are available for this action.", "info");
|
|
682
|
+
return undefined;
|
|
683
|
+
}
|
|
684
|
+
if (signal?.aborted || ctx.signal?.aborted) return undefined;
|
|
685
|
+
let selected: WorktreeRecord | undefined;
|
|
686
|
+
const menu = defineMenu<undefined, "worktrees", "choose", ExtensionCommandContext>({
|
|
687
|
+
start: "worktrees",
|
|
688
|
+
screens: {
|
|
689
|
+
worktrees: () => ({
|
|
690
|
+
kind: "choice",
|
|
691
|
+
title,
|
|
692
|
+
items: records.map((record, index) => ({
|
|
693
|
+
id: record.path,
|
|
694
|
+
label: `${index + 1}. ${formatWorktree(record, currentPath)}`,
|
|
695
|
+
})),
|
|
696
|
+
action: "choose",
|
|
697
|
+
hint: "close",
|
|
698
|
+
}),
|
|
699
|
+
},
|
|
700
|
+
actions: {
|
|
701
|
+
choose: async ({ itemId }) => {
|
|
702
|
+
selected = records.find((record) => record.path === itemId);
|
|
703
|
+
return selected ? { kind: "close" } : { kind: "rejected" };
|
|
704
|
+
},
|
|
705
|
+
},
|
|
706
|
+
});
|
|
707
|
+
await runMenu(ctx, menu, {
|
|
708
|
+
getState: () => undefined,
|
|
709
|
+
signal,
|
|
710
|
+
isCurrent: () => !signal?.aborted,
|
|
711
|
+
});
|
|
712
|
+
return selected;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function safeNotify(ctx: ExtensionCommandContext, message: string, level: "info" | "warning" | "error"): void {
|
|
716
|
+
try {
|
|
717
|
+
ctx.ui.notify(stripTerminalControls(message), level);
|
|
718
|
+
} catch {
|
|
719
|
+
console.error(message);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function formatError(error: unknown): string {
|
|
724
|
+
return error instanceof Error ? error.message : String(error);
|
|
725
|
+
}
|