@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 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",
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": "^0.84.0 || ^0.85.0 || ^0.86.0 || ^0.87.0 || ^0.99.1"
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.99.1",
43
- "@earendil-works/pi-tui": "0.99.1",
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.0.18"
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.2",
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
- const owner = getMenuOwner();
100
- if (owner.signal.aborted || !owner.isCurrent()) return;
101
- const runFlow = async (flow: () => Promise<void>) => {
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
- await flow();
105
+ assertOwner(actionOwner);
106
+ await flow(ownedContext(originalCtx, actionOwner));
104
107
  } catch (error) {
105
- safeNotify(ctx, formatError(error), "error");
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, ctx, records, root.effectiveRoot)),
132
- switch: async ({ signal }) => runFlow(() => switchFlow(pi, ctx, records, currentPath, signal)),
133
- remove: async ({ signal }) => runFlow(() => removeFlow(pi, ctx, records, currentPath, signal)),
134
- status: async ({ signal }) => runFlow(() => statusFlow(pi, ctx, records, currentPath, signal)),
135
- prune: async () => runFlow(() => pruneFlow(pi, ctx, records)),
136
- configure: async () => runFlow(() => configureRootFlow(ctx, settings)),
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
- const updated = await settings.save(configuredRoot);
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 Error(
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(stripTerminalControls(message), level);
883
+ ctx.ui.notify(sanitized, level);
864
884
  } catch {
865
- console.error(message);
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>): 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
- if (previous) await previous;
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
- release();
391
- if (metadataPruneLocks.get(key) === current) metadataPruneLocks.delete(key);
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(key, async () => {
402
- const release = await acquireFilesystemMutationLock(key, signal);
403
- try {
404
- return await operation();
405
- } finally {
406
- await release();
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
- await runGit(pi, ["worktree", "prune", "--expire", "now"], cwd, signal, GIT_MUTATION_TIMEOUT_MS);
521
- }
522
- const after = await listWorktrees(pi, cwd, signal);
523
- if (after.some((record) => pathsEqual(record.path, path))) {
524
- throw new GitWorktreeError(`Git did not remove worktree metadata for ${path}.`);
525
- }
526
- const administrativeAfter = await administrativePruneCandidatesIfPresent(pi, cwd, signal);
527
- if (administrativeAfter.length !== 0) {
528
- throw new GitWorktreeError(`Git left stale administrative records after pruning ${path}.`);
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(candidate: AdministrativePruneCandidate): Promise<void> {
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
- throw new GitWorktreeError(
848
- `Git administrative metadata removal failed for ${candidate.id}${retainedPath ? `; retained at ${retainedPath}` : ""}.${outcomeWarning} ${formatError(error)}`,
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
- return await pi.exec("git", args, { cwd, signal, timeout });
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)) {
@@ -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
- await removeWorktreeMetadata(
294
- pi,
295
- cwd,
296
- quarantinePath,
297
- signal,
298
- () => {
299
- metadataRemoved = true;
300
- },
301
- true,
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 Error(
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 Error(
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 Error(
373
- `Worktree metadata was removed, but quarantine outcome is unknown: ${errorDetail(primaryError)}`,
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 Error(
384
- `Worktree metadata was removed, but quarantine was retained at ${retainedPath}: ${errorDetail(primaryError)}`,
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"));