@signalridge/pi-worktree 1.4.3 → 1.4.4
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 +12 -0
- package/package.json +6 -6
- package/src/command-owner.ts +51 -0
- package/src/command.ts +41 -21
- package/src/git.ts +90 -28
- package/src/safe-remove.ts +34 -19
- package/src/session.ts +2 -0
- package/src/settings.ts +15 -5
- package/src/worktree.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.4
|
|
4
|
+
### Patch Changes
|
|
5
|
+
|
|
6
|
+
- 3364cc0: Use patched Vitest 4.1.11 for development and regression testing without moving to a new major version. Refresh compatible transitive dependencies to include fixed source-map and brace-expansion releases.
|
|
7
|
+
- 3364cc0: Adopt Pi 1.0.4's host-provided dependency contract: declare imported Pi and TypeBox modules as `*` peers rather than installing duplicate runtime modules, and align Pi development pins with the repository's tested 1.0.4 baseline. The wildcard identifies host module ownership; it does not promise compatibility with every Pi version.
|
|
8
|
+
- 3364cc0: Cancel credential loaders, settings dialogs, and worktree operations when their owning session or branch retires, and ignore late results. Include boundary-generated usage receipts in statusline polling, mark only subscription-backed OAuth costs, and report headless worktree rejection on stderr.
|
|
9
|
+
- 3364cc0: Keep runtime settings synchronized after successful atomic publication even when the command owner retires. Preserve sanitized recovery diagnostics for retained worktrees and uncertain metadata removal outcomes after cancellation, without notifying obsolete UI contexts or attempting to restore committed Git registrations.
|
|
10
|
+
- Updated dependencies [3364cc0]
|
|
11
|
+
- Updated dependencies [3364cc0]
|
|
12
|
+
- Updated dependencies [3364cc0]
|
|
13
|
+
- @signalridge/pi-ui@1.3.3
|
|
14
|
+
|
|
3
15
|
## 1.4.3
|
|
4
16
|
### Patch Changes
|
|
5
17
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signalridge/pi-worktree",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.4",
|
|
4
4
|
"description": "Pi extension for safe interactive Git worktree management and workspace switching.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -35,16 +35,16 @@
|
|
|
35
35
|
"lint": "biome check ."
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"@earendil-works/pi-coding-agent": "
|
|
38
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@biomejs/biome": "2.5.7",
|
|
42
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
43
|
-
"@earendil-works/pi-tui": "0.
|
|
42
|
+
"@earendil-works/pi-coding-agent": "1.0.4",
|
|
43
|
+
"@earendil-works/pi-tui": "1.0.4",
|
|
44
44
|
"@types/node": "26.1.2",
|
|
45
45
|
"@types/proper-lockfile": "^4.1.4",
|
|
46
46
|
"typescript": "7.0.2",
|
|
47
|
-
"vitest": "4.
|
|
47
|
+
"vitest": "4.1.11"
|
|
48
48
|
},
|
|
49
49
|
"repository": {
|
|
50
50
|
"type": "git",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
62
|
"@narumitw/pi-tui-kit": "^0.54.0",
|
|
63
|
-
"@signalridge/pi-ui": "^1.3.
|
|
63
|
+
"@signalridge/pi-ui": "^1.3.3",
|
|
64
64
|
"proper-lockfile": "^4.1.2"
|
|
65
65
|
}
|
|
66
66
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export interface WorktreeMenuOwner {
|
|
4
|
+
signal: AbortSignal;
|
|
5
|
+
isCurrent(): boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Await host work without retaining abort listeners or requiring an RPC response. */
|
|
9
|
+
export async function awaitOwned<T>(owner: WorktreeMenuOwner, start: () => Promise<T>): Promise<T> {
|
|
10
|
+
assertOwner(owner);
|
|
11
|
+
let cancel!: () => void;
|
|
12
|
+
const cancelled = new Promise<never>((_resolve, reject) => {
|
|
13
|
+
cancel = () => reject(new DOMException("Worktree command cancelled", "AbortError"));
|
|
14
|
+
owner.signal.addEventListener("abort", cancel, { once: true });
|
|
15
|
+
});
|
|
16
|
+
try {
|
|
17
|
+
assertOwner(owner);
|
|
18
|
+
const result = await Promise.race([start(), cancelled]);
|
|
19
|
+
assertOwner(owner);
|
|
20
|
+
return result;
|
|
21
|
+
} finally {
|
|
22
|
+
owner.signal.removeEventListener("abort", cancel);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function assertOwner(owner: WorktreeMenuOwner): void {
|
|
27
|
+
if (owner.signal.aborted || !owner.isCurrent()) {
|
|
28
|
+
throw new DOMException("Worktree command cancelled", "AbortError");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** All flows use the lifecycle/action signal, never the agent-run ctx.signal. */
|
|
33
|
+
export function ownedContext(ctx: ExtensionCommandContext, owner: WorktreeMenuOwner): ExtensionCommandContext {
|
|
34
|
+
return {
|
|
35
|
+
...ctx,
|
|
36
|
+
signal: owner.signal,
|
|
37
|
+
waitForIdle: () => awaitOwned(owner, () => ctx.waitForIdle()),
|
|
38
|
+
ui: {
|
|
39
|
+
...ctx.ui,
|
|
40
|
+
input: (title, placeholder, options) =>
|
|
41
|
+
awaitOwned(owner, () => ctx.ui.input(title, placeholder, { ...options, signal: owner.signal })),
|
|
42
|
+
confirm: (title, message, options) =>
|
|
43
|
+
awaitOwned(owner, () => ctx.ui.confirm(title, message, { ...options, signal: owner.signal })),
|
|
44
|
+
select: (title, choices, options) =>
|
|
45
|
+
awaitOwned(owner, () => ctx.ui.select(title, choices, { ...options, signal: owner.signal })),
|
|
46
|
+
notify: (message, level) => {
|
|
47
|
+
if (!owner.signal.aborted && owner.isCurrent()) ctx.ui.notify(message, level);
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
package/src/command.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
5
5
|
import { withBorderedCustomUi } from "@signalridge/pi-ui";
|
|
6
|
+
import { assertOwner, ownedContext, type WorktreeMenuOwner } from "./command-owner.js";
|
|
6
7
|
import {
|
|
7
8
|
type AdministrativePruneCandidate,
|
|
8
9
|
addWorktree,
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
unresolvableSymlinkAncestor,
|
|
29
30
|
validateBranch,
|
|
30
31
|
type WorktreeRecord,
|
|
32
|
+
WorktreeRecoveryError,
|
|
31
33
|
withWorktreeMutationLock,
|
|
32
34
|
worktreeAdministrativeDirectory,
|
|
33
35
|
worktreeForBranch,
|
|
@@ -54,11 +56,6 @@ const ACTIONS = {
|
|
|
54
56
|
configure: ACTION_CONFIGURE_ROOT,
|
|
55
57
|
} as const;
|
|
56
58
|
|
|
57
|
-
interface WorktreeMenuOwner {
|
|
58
|
-
signal: AbortSignal;
|
|
59
|
-
isCurrent(): boolean;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
59
|
interface AdministrativeHistoryRisk {
|
|
63
60
|
label: string;
|
|
64
61
|
oids: string[];
|
|
@@ -90,19 +87,31 @@ export function registerWorktreeCommand(
|
|
|
90
87
|
return;
|
|
91
88
|
}
|
|
92
89
|
|
|
90
|
+
// Capture ownership before idle/Git preflight, not after it.
|
|
91
|
+
const owner = getMenuOwner();
|
|
92
|
+
const originalCtx = ctx;
|
|
93
|
+
if (owner.signal.aborted || !owner.isCurrent()) return;
|
|
94
|
+
ctx = ownedContext(ctx, owner);
|
|
93
95
|
try {
|
|
94
96
|
await ctx.waitForIdle();
|
|
95
97
|
const records = await listWorktrees(pi, ctx.cwd, ctx.signal);
|
|
96
98
|
const currentPath = await currentWorktreePath(pi, ctx.cwd, ctx.signal);
|
|
97
99
|
const root = settings.get();
|
|
98
100
|
const warning = root.warning ? " — settings warning" : "";
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
assertOwner(owner);
|
|
102
|
+
const runFlow = async (signal: AbortSignal, flow: (flowCtx: ExtensionCommandContext) => Promise<void>) => {
|
|
103
|
+
const actionOwner = { signal: AbortSignal.any([owner.signal, signal]), isCurrent: owner.isCurrent };
|
|
102
104
|
try {
|
|
103
|
-
|
|
105
|
+
assertOwner(actionOwner);
|
|
106
|
+
await flow(ownedContext(originalCtx, actionOwner));
|
|
104
107
|
} catch (error) {
|
|
105
|
-
|
|
108
|
+
if (!actionOwner.signal.aborted && actionOwner.isCurrent()) {
|
|
109
|
+
safeNotify(ctx, formatError(error), "error");
|
|
110
|
+
} else if (error instanceof WorktreeRecoveryError) {
|
|
111
|
+
// Retained trees/unknown mutation outcomes outlive their UI owner.
|
|
112
|
+
// Do not access a retired context, even to check whether it has UI.
|
|
113
|
+
console.error(stripTerminalControls(formatError(error)));
|
|
114
|
+
}
|
|
106
115
|
}
|
|
107
116
|
return { kind: "close" } as const;
|
|
108
117
|
};
|
|
@@ -128,12 +137,15 @@ export function registerWorktreeCommand(
|
|
|
128
137
|
}),
|
|
129
138
|
},
|
|
130
139
|
actions: {
|
|
131
|
-
add: async () => runFlow(() => addFlow(pi,
|
|
132
|
-
switch: async ({ signal }) =>
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
140
|
+
add: async ({ signal }) => runFlow(signal, (flowCtx) => addFlow(pi, flowCtx, records, root.effectiveRoot)),
|
|
141
|
+
switch: async ({ signal }) =>
|
|
142
|
+
runFlow(signal, (flowCtx) => switchFlow(pi, flowCtx, records, currentPath, flowCtx.signal)),
|
|
143
|
+
remove: async ({ signal }) =>
|
|
144
|
+
runFlow(signal, (flowCtx) => removeFlow(pi, flowCtx, records, currentPath, flowCtx.signal)),
|
|
145
|
+
status: async ({ signal }) =>
|
|
146
|
+
runFlow(signal, (flowCtx) => statusFlow(pi, flowCtx, records, currentPath, flowCtx.signal)),
|
|
147
|
+
prune: async ({ signal }) => runFlow(signal, (flowCtx) => pruneFlow(pi, flowCtx, records)),
|
|
148
|
+
configure: async ({ signal }) => runFlow(signal, (flowCtx) => configureRootFlow(flowCtx, settings)),
|
|
137
149
|
},
|
|
138
150
|
});
|
|
139
151
|
await runMenu(withBorderedCustomUi(ctx), menu, {
|
|
@@ -149,7 +161,8 @@ export function registerWorktreeCommand(
|
|
|
149
161
|
}
|
|
150
162
|
|
|
151
163
|
async function configureRootFlow(ctx: ExtensionCommandContext, settings: WorktreeSettingsRuntime): Promise<void> {
|
|
152
|
-
const current = await settings.reload();
|
|
164
|
+
const current = await settings.reload(ctx.signal);
|
|
165
|
+
ctx.signal?.throwIfAborted();
|
|
153
166
|
if (!current.canSave) {
|
|
154
167
|
throw new Error(current.warning ?? `Fix ${settings.getPath()} before changing pi-worktree settings.`);
|
|
155
168
|
}
|
|
@@ -159,7 +172,9 @@ async function configureRootFlow(ctx: ExtensionCommandContext, settings: Worktre
|
|
|
159
172
|
);
|
|
160
173
|
if (requested === undefined) return;
|
|
161
174
|
const configuredRoot = requested.trim() || undefined;
|
|
162
|
-
|
|
175
|
+
ctx.signal?.throwIfAborted();
|
|
176
|
+
const updated = await settings.save(configuredRoot, ctx.signal);
|
|
177
|
+
ctx.signal?.throwIfAborted();
|
|
163
178
|
safeNotify(
|
|
164
179
|
ctx,
|
|
165
180
|
configuredRoot === undefined
|
|
@@ -320,7 +335,7 @@ async function addFlow(
|
|
|
320
335
|
}
|
|
321
336
|
return verified;
|
|
322
337
|
} catch (error) {
|
|
323
|
-
throw new
|
|
338
|
+
throw new WorktreeRecoveryError(
|
|
324
339
|
`Git add completed, so the worktree was retained at ${targetPath}, but verification failed: ${formatError(error)}. Inspect git worktree list before retrying.`,
|
|
325
340
|
);
|
|
326
341
|
}
|
|
@@ -859,10 +874,15 @@ async function selectWorktree(
|
|
|
859
874
|
}
|
|
860
875
|
|
|
861
876
|
function safeNotify(ctx: ExtensionCommandContext, message: string, level: "info" | "warning" | "error"): void {
|
|
877
|
+
const sanitized = stripTerminalControls(message);
|
|
878
|
+
if (!ctx.hasUI) {
|
|
879
|
+
console.error(sanitized);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
862
882
|
try {
|
|
863
|
-
ctx.ui.notify(
|
|
883
|
+
ctx.ui.notify(sanitized, level);
|
|
864
884
|
} catch {
|
|
865
|
-
console.error(
|
|
885
|
+
console.error(sanitized);
|
|
866
886
|
}
|
|
867
887
|
}
|
|
868
888
|
|
package/src/git.ts
CHANGED
|
@@ -70,6 +70,14 @@ export class GitWorktreeError extends Error {
|
|
|
70
70
|
this.args = args;
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
+
/** A mutation left retained data or an outcome that requires recovery inspection. */
|
|
74
|
+
export class WorktreeRecoveryError extends GitWorktreeError {
|
|
75
|
+
constructor(message: string) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.name = "WorktreeRecoveryError";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
class MetadataDeletionRetainedError extends Error {
|
|
74
82
|
readonly retainedPath: string;
|
|
75
83
|
readonly outcomeUnknown: boolean;
|
|
@@ -376,19 +384,35 @@ async function acquireFilesystemMutationLock(key: string, signal?: AbortSignal):
|
|
|
376
384
|
}
|
|
377
385
|
}
|
|
378
386
|
|
|
379
|
-
async function withMetadataPruneLock<T>(key: string, operation: () => Promise<T
|
|
387
|
+
async function withMetadataPruneLock<T>(key: string, operation: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
388
|
+
signal?.throwIfAborted();
|
|
380
389
|
const previous = metadataPruneLocks.get(key);
|
|
381
390
|
let release!: () => void;
|
|
382
391
|
const current = new Promise<void>((resolveLock) => {
|
|
383
392
|
release = resolveLock;
|
|
384
393
|
});
|
|
385
394
|
metadataPruneLocks.set(key, current);
|
|
386
|
-
|
|
395
|
+
let cancel: (() => void) | undefined;
|
|
387
396
|
try {
|
|
397
|
+
if (previous) {
|
|
398
|
+
await Promise.race([
|
|
399
|
+
previous,
|
|
400
|
+
new Promise<never>((_resolve, reject) => {
|
|
401
|
+
cancel = () => reject(new GitWorktreeError("worktree mutation lock wait aborted."));
|
|
402
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
403
|
+
if (signal?.aborted) cancel();
|
|
404
|
+
}),
|
|
405
|
+
]);
|
|
406
|
+
}
|
|
407
|
+
signal?.throwIfAborted();
|
|
388
408
|
return await operation();
|
|
389
409
|
} finally {
|
|
390
|
-
|
|
391
|
-
|
|
410
|
+
if (cancel) signal?.removeEventListener("abort", cancel);
|
|
411
|
+
// A cancelled queue slot must not let its successors overtake the holder.
|
|
412
|
+
void Promise.resolve(previous).then(() => {
|
|
413
|
+
release();
|
|
414
|
+
if (metadataPruneLocks.get(key) === current) metadataPruneLocks.delete(key);
|
|
415
|
+
});
|
|
392
416
|
}
|
|
393
417
|
}
|
|
394
418
|
|
|
@@ -398,14 +422,19 @@ export function withWorktreeMutationLock<T>(
|
|
|
398
422
|
signal?: AbortSignal,
|
|
399
423
|
): Promise<T> {
|
|
400
424
|
const key = worktreeMutationLockKey(cwd);
|
|
401
|
-
return withMetadataPruneLock(
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
425
|
+
return withMetadataPruneLock(
|
|
426
|
+
key,
|
|
427
|
+
async () => {
|
|
428
|
+
const release = await acquireFilesystemMutationLock(key, signal);
|
|
429
|
+
try {
|
|
430
|
+
signal?.throwIfAborted();
|
|
431
|
+
return await operation();
|
|
432
|
+
} finally {
|
|
433
|
+
await release();
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
signal,
|
|
437
|
+
);
|
|
409
438
|
}
|
|
410
439
|
|
|
411
440
|
function commonDirectoryForGitdir(gitdir: string): string {
|
|
@@ -515,19 +544,36 @@ export async function removeWorktreeMetadata(
|
|
|
515
544
|
throw new GitWorktreeError(`Refusing metadata prune because the stale-record preview changed.`);
|
|
516
545
|
}
|
|
517
546
|
if (targetAdministrative) {
|
|
518
|
-
await removeAdministrativeRecord(targetAdministrative);
|
|
547
|
+
await removeAdministrativeRecord(targetAdministrative, signal);
|
|
519
548
|
} else {
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
549
|
+
signal?.throwIfAborted();
|
|
550
|
+
try {
|
|
551
|
+
await runGit(pi, ["worktree", "prune", "--expire", "now"], cwd, signal, GIT_MUTATION_TIMEOUT_MS);
|
|
552
|
+
} catch (error) {
|
|
553
|
+
// An interrupted Git process may already have deregistered the tree.
|
|
554
|
+
throw new WorktreeRecoveryError(
|
|
555
|
+
`Worktree metadata removal outcome is unknown for ${path}: ${formatError(error)}`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
529
558
|
}
|
|
559
|
+
// Deregistration is the commit point, not the subsequent verification.
|
|
560
|
+
// Never attempt registration rollback after successful metadata deletion.
|
|
530
561
|
onMetadataRemoved?.();
|
|
562
|
+
try {
|
|
563
|
+
// Recovery verification must finish independently of the retired UI owner.
|
|
564
|
+
const after = await listWorktrees(pi, cwd);
|
|
565
|
+
if (after.some((record) => pathsEqual(record.path, path))) {
|
|
566
|
+
throw new GitWorktreeError(`Git did not remove worktree metadata for ${path}.`);
|
|
567
|
+
}
|
|
568
|
+
const administrativeAfter = await administrativePruneCandidatesIfPresent(pi, cwd);
|
|
569
|
+
if (administrativeAfter.length !== 0) {
|
|
570
|
+
throw new GitWorktreeError(`Git left stale administrative records after pruning ${path}.`);
|
|
571
|
+
}
|
|
572
|
+
} catch (error) {
|
|
573
|
+
throw new WorktreeRecoveryError(
|
|
574
|
+
`Worktree metadata removal completed for ${path}, but verification failed: ${formatError(error)}`,
|
|
575
|
+
);
|
|
576
|
+
}
|
|
531
577
|
};
|
|
532
578
|
if (lockHeld) await operation();
|
|
533
579
|
else await withWorktreeMutationLock(cwd, operation, signal);
|
|
@@ -817,7 +863,11 @@ async function removeMetadataTree(path: string, expected: MetadataIdentity): Pro
|
|
|
817
863
|
}
|
|
818
864
|
}
|
|
819
865
|
|
|
820
|
-
async function removeAdministrativeRecord(
|
|
866
|
+
async function removeAdministrativeRecord(
|
|
867
|
+
candidate: AdministrativePruneCandidate,
|
|
868
|
+
signal?: AbortSignal,
|
|
869
|
+
): Promise<void> {
|
|
870
|
+
signal?.throwIfAborted();
|
|
821
871
|
const expected = administrativeIdentities.get(candidate);
|
|
822
872
|
if (!expected) throw new GitWorktreeError(`Git metadata identity was not captured for ${candidate.id}.`);
|
|
823
873
|
const source = candidate.administrativePath;
|
|
@@ -829,6 +879,7 @@ async function removeAdministrativeRecord(candidate: AdministrativePruneCandidat
|
|
|
829
879
|
if (worktreeGitFile && existsSync(worktreeGitFile)) {
|
|
830
880
|
throw new Error(`worktree ${worktreePath} became valid before metadata removal`);
|
|
831
881
|
}
|
|
882
|
+
signal?.throwIfAborted();
|
|
832
883
|
await rename(source, tombstone);
|
|
833
884
|
moved = true;
|
|
834
885
|
if (worktreeGitFile && existsSync(worktreeGitFile)) {
|
|
@@ -836,6 +887,14 @@ async function removeAdministrativeRecord(candidate: AdministrativePruneCandidat
|
|
|
836
887
|
moved = false;
|
|
837
888
|
throw new Error(`worktree ${worktreePath} became valid while claiming metadata`);
|
|
838
889
|
}
|
|
890
|
+
if (signal?.aborted) {
|
|
891
|
+
// Claiming metadata is reversible; finish rollback without the cancelled signal.
|
|
892
|
+
if (!existsSync(source)) {
|
|
893
|
+
await rename(tombstone, source);
|
|
894
|
+
moved = false;
|
|
895
|
+
}
|
|
896
|
+
signal.throwIfAborted();
|
|
897
|
+
}
|
|
839
898
|
await removeMetadataTree(tombstone, expected);
|
|
840
899
|
} catch (error: unknown) {
|
|
841
900
|
const retainedPath =
|
|
@@ -844,9 +903,9 @@ async function removeAdministrativeRecord(candidate: AdministrativePruneCandidat
|
|
|
844
903
|
error instanceof MetadataDeletionRetainedError && error.outcomeUnknown
|
|
845
904
|
? " Metadata removal outcome is unknown."
|
|
846
905
|
: "";
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
);
|
|
906
|
+
const message = `Git administrative metadata removal failed for ${candidate.id}${retainedPath ? `; retained at ${retainedPath}` : ""}.${outcomeWarning} ${formatError(error)}`;
|
|
907
|
+
if (retainedPath || outcomeWarning) throw new WorktreeRecoveryError(message);
|
|
908
|
+
throw new GitWorktreeError(message);
|
|
850
909
|
}
|
|
851
910
|
}
|
|
852
911
|
function sameAdministrativeCandidate(left: AdministrativePruneCandidate, right: AdministrativePruneCandidate): boolean {
|
|
@@ -959,7 +1018,7 @@ export async function pruneWorktrees(
|
|
|
959
1018
|
const result = await runGit(pi, ["worktree", "prune", "--verbose"], cwd, signal, GIT_MUTATION_TIMEOUT_MS);
|
|
960
1019
|
return combineOutput(result);
|
|
961
1020
|
}
|
|
962
|
-
for (const candidate of candidates) await removeAdministrativeRecord(candidate);
|
|
1021
|
+
for (const candidate of candidates) await removeAdministrativeRecord(candidate, signal);
|
|
963
1022
|
return candidates.map((candidate) => `Removed ${candidate.id}`).join("\n");
|
|
964
1023
|
};
|
|
965
1024
|
return lockHeld ? operation() : withWorktreeMutationLock(cwd, operation, signal);
|
|
@@ -1050,7 +1109,10 @@ async function runGitAllowFailure(
|
|
|
1050
1109
|
timeout = GIT_TIMEOUT_MS,
|
|
1051
1110
|
): Promise<ExecResult> {
|
|
1052
1111
|
try {
|
|
1053
|
-
|
|
1112
|
+
signal?.throwIfAborted();
|
|
1113
|
+
const result = await pi.exec("git", args, { cwd, signal, timeout });
|
|
1114
|
+
signal?.throwIfAborted();
|
|
1115
|
+
return result;
|
|
1054
1116
|
} catch (error) {
|
|
1055
1117
|
const message = formatError(error);
|
|
1056
1118
|
if (/\bENOENT\b|not found/i.test(message)) {
|
package/src/safe-remove.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { lstat, readdir, rename, rmdir, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { moveWorktree, removeWorktreeMetadata, withWorktreeMutationLock } from "./git.js";
|
|
5
|
+
import { moveWorktree, removeWorktreeMetadata, WorktreeRecoveryError, withWorktreeMutationLock } from "./git.js";
|
|
6
6
|
|
|
7
7
|
interface TreeSnapshot {
|
|
8
8
|
kind: "directory" | "leaf";
|
|
@@ -265,12 +265,14 @@ export async function removeWorktreeSafely(
|
|
|
265
265
|
if (signal?.aborted) throw new Error("worktree removal aborted");
|
|
266
266
|
const quarantinePath = join(dirname(path), `.${randomUUID()}.pi-worktree-quarantine`);
|
|
267
267
|
let metadataRemoved = false;
|
|
268
|
+
let metadataOutcomeUnknown = false;
|
|
268
269
|
let moved = false;
|
|
269
270
|
let tombstonePath: string | undefined;
|
|
270
271
|
let moveOutcomeUnknown = false;
|
|
271
272
|
let quarantineReservation: TreeSnapshot | undefined;
|
|
272
273
|
try {
|
|
273
274
|
await validateRegisteredWorktree?.();
|
|
275
|
+
signal?.throwIfAborted();
|
|
274
276
|
const beforeSnapshot = await snapshotTree(path);
|
|
275
277
|
await moveWorktree(pi, cwd, path, quarantinePath, signal);
|
|
276
278
|
moved = true;
|
|
@@ -279,6 +281,7 @@ export async function removeWorktreeSafely(
|
|
|
279
281
|
throw new Error("worktree changed while entering quarantine");
|
|
280
282
|
}
|
|
281
283
|
await validateQuarantine(quarantinePath);
|
|
284
|
+
signal?.throwIfAborted();
|
|
282
285
|
|
|
283
286
|
// Keep the real tree on a guarded tombstone. The registered path is reserved
|
|
284
287
|
// with an exclusive non-directory entry so Git never receives an unprotected
|
|
@@ -290,16 +293,22 @@ export async function removeWorktreeSafely(
|
|
|
290
293
|
if (!sameIdentity(reservedPath, quarantineReservation)) {
|
|
291
294
|
throw new QuarantineRetainedError(quarantinePath, `quarantine reservation changed: ${quarantinePath}`);
|
|
292
295
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
296
|
+
try {
|
|
297
|
+
await removeWorktreeMetadata(
|
|
298
|
+
pi,
|
|
299
|
+
cwd,
|
|
300
|
+
quarantinePath,
|
|
301
|
+
signal,
|
|
302
|
+
() => {
|
|
303
|
+
metadataRemoved = true;
|
|
304
|
+
},
|
|
305
|
+
true,
|
|
306
|
+
);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
// Partial metadata deletion cannot be rolled back by moving the tree.
|
|
309
|
+
metadataOutcomeUnknown = !metadataRemoved && error instanceof WorktreeRecoveryError;
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
303
312
|
await releaseQuarantineReservation(quarantinePath, quarantineReservation);
|
|
304
313
|
quarantineReservation = undefined;
|
|
305
314
|
if (!tombstonePath || !(await pathExists(tombstonePath))) {
|
|
@@ -309,6 +318,9 @@ export async function removeWorktreeSafely(
|
|
|
309
318
|
true,
|
|
310
319
|
);
|
|
311
320
|
}
|
|
321
|
+
// Once metadata is committed, cancellation retains the recovery tree
|
|
322
|
+
// rather than beginning a fresh filesystem deletion.
|
|
323
|
+
signal?.throwIfAborted();
|
|
312
324
|
await removeSnapshot(tombstonePath, snapshot, beforeDeleteEntry);
|
|
313
325
|
} catch (error: unknown) {
|
|
314
326
|
if (!tombstonePath && !metadataRemoved) {
|
|
@@ -332,11 +344,11 @@ export async function removeWorktreeSafely(
|
|
|
332
344
|
}
|
|
333
345
|
const primaryError = reservationError ?? error;
|
|
334
346
|
if (moveOutcomeUnknown) {
|
|
335
|
-
throw new
|
|
347
|
+
throw new WorktreeRecoveryError(
|
|
336
348
|
`Git worktree move outcome is unknown; inspect ${path} and ${quarantinePath} before retrying: ${errorDetail(primaryError)}`,
|
|
337
349
|
);
|
|
338
350
|
}
|
|
339
|
-
if (moved && !metadataRemoved) {
|
|
351
|
+
if (moved && !metadataRemoved && !metadataOutcomeUnknown) {
|
|
340
352
|
let restoreError: unknown = reservationError;
|
|
341
353
|
let restored = false;
|
|
342
354
|
if (!reservationError) {
|
|
@@ -361,16 +373,19 @@ export async function removeWorktreeSafely(
|
|
|
361
373
|
: tombstonePath && (await pathExists(tombstonePath))
|
|
362
374
|
? tombstonePath
|
|
363
375
|
: quarantinePath;
|
|
364
|
-
throw new
|
|
376
|
+
throw new WorktreeRecoveryError(
|
|
365
377
|
`Git worktree removal failed: ${errorDetail(primaryError)}; quarantine retained at ${retainedPath}${
|
|
366
378
|
restoreError ? ` (${errorDetail(restoreError)})` : ". The original path was recreated."
|
|
367
379
|
}`,
|
|
368
380
|
);
|
|
369
381
|
}
|
|
370
|
-
if (metadataRemoved && tombstonePath) {
|
|
382
|
+
if ((metadataRemoved || metadataOutcomeUnknown) && tombstonePath) {
|
|
383
|
+
const metadataStatus = metadataRemoved
|
|
384
|
+
? "Worktree metadata was removed"
|
|
385
|
+
: "Worktree metadata outcome is unknown";
|
|
371
386
|
if (primaryError instanceof QuarantineRetainedError && primaryError.outcomeUnknown) {
|
|
372
|
-
throw new
|
|
373
|
-
|
|
387
|
+
throw new WorktreeRecoveryError(
|
|
388
|
+
`${metadataStatus}, but quarantine outcome is unknown: ${errorDetail(primaryError)}`,
|
|
374
389
|
);
|
|
375
390
|
}
|
|
376
391
|
const retainedPath =
|
|
@@ -380,8 +395,8 @@ export async function removeWorktreeSafely(
|
|
|
380
395
|
? tombstonePath
|
|
381
396
|
: undefined;
|
|
382
397
|
if (retainedPath) {
|
|
383
|
-
throw new
|
|
384
|
-
|
|
398
|
+
throw new WorktreeRecoveryError(
|
|
399
|
+
`${metadataStatus}, but quarantine was retained at ${retainedPath}: ${errorDetail(primaryError)}`,
|
|
385
400
|
);
|
|
386
401
|
}
|
|
387
402
|
}
|
package/src/session.ts
CHANGED
|
@@ -12,7 +12,9 @@ export async function switchToWorktree(
|
|
|
12
12
|
): Promise<WorktreeSwitchResult> {
|
|
13
13
|
let sessionPath: string | undefined;
|
|
14
14
|
try {
|
|
15
|
+
ctx.signal?.throwIfAborted();
|
|
15
16
|
sessionPath = createTargetSession(ctx, targetPath);
|
|
17
|
+
ctx.signal?.throwIfAborted();
|
|
16
18
|
const result = await ctx.switchSession(sessionPath, {
|
|
17
19
|
withSession: async (replacementCtx) => {
|
|
18
20
|
replacementCtx.ui.notify(stripTerminalControls(`Switched Pi workspace to ${targetPath}.`), "info");
|
package/src/settings.ts
CHANGED
|
@@ -33,8 +33,8 @@ export interface SettingsFileOperations {
|
|
|
33
33
|
export interface WorktreeSettingsRuntime {
|
|
34
34
|
get(): Readonly<WorktreeSettingsState>;
|
|
35
35
|
getPath(): string;
|
|
36
|
-
reload(): Promise<Readonly<WorktreeSettingsState>>;
|
|
37
|
-
save(configuredRoot: string | undefined): Promise<Readonly<WorktreeSettingsState>>;
|
|
36
|
+
reload(signal?: AbortSignal): Promise<Readonly<WorktreeSettingsState>>;
|
|
37
|
+
save(configuredRoot: string | undefined, signal?: AbortSignal): Promise<Readonly<WorktreeSettingsState>>;
|
|
38
38
|
flush?(): Promise<void>;
|
|
39
39
|
}
|
|
40
40
|
|
|
@@ -153,18 +153,22 @@ export async function saveWorktreeSettings(
|
|
|
153
153
|
configuredRoot: string | undefined,
|
|
154
154
|
path = settingsFilePath(),
|
|
155
155
|
operations: Partial<SettingsFileOperations> = {},
|
|
156
|
+
signal?: AbortSignal,
|
|
156
157
|
): Promise<Record<string, unknown>> {
|
|
158
|
+
signal?.throwIfAborted();
|
|
157
159
|
const nextDocument = { ...document };
|
|
158
160
|
if (configuredRoot === undefined) delete nextDocument.worktreeRoot;
|
|
159
161
|
else nextDocument.worktreeRoot = configuredRoot;
|
|
160
162
|
|
|
161
163
|
await mkdir(dirname(path), { recursive: true });
|
|
164
|
+
signal?.throwIfAborted();
|
|
162
165
|
const temporaryPath = temporaryFilePath(path);
|
|
163
166
|
try {
|
|
164
167
|
await (operations.write ?? DEFAULT_FILE_OPERATIONS.write)(
|
|
165
168
|
temporaryPath,
|
|
166
169
|
`${JSON.stringify(nextDocument, null, 2)}\n`,
|
|
167
170
|
);
|
|
171
|
+
signal?.throwIfAborted();
|
|
168
172
|
await (operations.rename ?? DEFAULT_FILE_OPERATIONS.rename)(temporaryPath, path);
|
|
169
173
|
return nextDocument;
|
|
170
174
|
} catch (error) {
|
|
@@ -202,9 +206,11 @@ export function createWorktreeSettingsRuntime(options: RuntimeOptions = {}): Wor
|
|
|
202
206
|
async flush() {
|
|
203
207
|
await operationQueue;
|
|
204
208
|
},
|
|
205
|
-
reload() {
|
|
209
|
+
reload(signal) {
|
|
206
210
|
return enqueue(async () => {
|
|
211
|
+
signal?.throwIfAborted();
|
|
207
212
|
const loaded = await loadWorktreeSettings(getPath(), home, platform);
|
|
213
|
+
signal?.throwIfAborted();
|
|
208
214
|
if (loaded.kind === "invalid") {
|
|
209
215
|
state = { ...state, warning: loaded.warning, canSave: false };
|
|
210
216
|
return Object.freeze({ ...state });
|
|
@@ -213,8 +219,9 @@ export function createWorktreeSettingsRuntime(options: RuntimeOptions = {}): Wor
|
|
|
213
219
|
return Object.freeze({ ...state });
|
|
214
220
|
});
|
|
215
221
|
},
|
|
216
|
-
save(configuredRoot) {
|
|
222
|
+
save(configuredRoot, signal) {
|
|
217
223
|
return enqueue(async () => {
|
|
224
|
+
signal?.throwIfAborted();
|
|
218
225
|
if (!state.canSave) {
|
|
219
226
|
throw new Error(`Fix the pi-worktree settings file at ${getPath()} before changing it.`);
|
|
220
227
|
}
|
|
@@ -223,11 +230,14 @@ export function createWorktreeSettingsRuntime(options: RuntimeOptions = {}): Wor
|
|
|
223
230
|
? defaultWorktreeRoot(home, platform)
|
|
224
231
|
: resolveWorktreeRoot(configuredRoot, home, platform);
|
|
225
232
|
const latest = await loadWorktreeSettings(getPath(), home, platform);
|
|
233
|
+
signal?.throwIfAborted();
|
|
226
234
|
if (latest.kind === "invalid") {
|
|
227
235
|
state = { ...state, warning: latest.warning, canSave: false };
|
|
228
236
|
throw new Error(`Fix the pi-worktree settings file at ${getPath()} before changing it.`);
|
|
229
237
|
}
|
|
230
|
-
await saveWorktreeSettings(latest.document ?? {}, configuredRoot, getPath(), options.operations);
|
|
238
|
+
await saveWorktreeSettings(latest.document ?? {}, configuredRoot, getPath(), options.operations, signal);
|
|
239
|
+
// Successful atomic publication commits the save, even if its UI owner
|
|
240
|
+
// retired while rename was pending. Keep same-session consumers in sync.
|
|
231
241
|
state = {
|
|
232
242
|
effectiveRoot,
|
|
233
243
|
source: configuredRoot === undefined ? "default" : "user",
|
package/src/worktree.ts
CHANGED
|
@@ -18,6 +18,15 @@ export default function worktreeExtension(pi: ExtensionAPI, options: WorktreeExt
|
|
|
18
18
|
};
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
+
const closeCommands = () => {
|
|
22
|
+
sessionGeneration += 1;
|
|
23
|
+
menuController.abort(new DOMException("Worktree navigation started", "AbortError"));
|
|
24
|
+
menuController = new AbortController();
|
|
25
|
+
};
|
|
26
|
+
pi.on("session_before_switch", () => closeCommands());
|
|
27
|
+
pi.on("session_before_tree", () => closeCommands());
|
|
28
|
+
pi.on("session_tree", () => closeCommands());
|
|
29
|
+
|
|
21
30
|
pi.on("session_start", async (_event, ctx) => {
|
|
22
31
|
const generation = ++sessionGeneration;
|
|
23
32
|
menuController.abort(new DOMException("Worktree session replaced", "AbortError"));
|