@signalridge/pi-worktree 1.4.3 → 1.4.5
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 +19 -0
- package/README.md +2 -0
- package/package.json +6 -6
- package/src/command-owner.ts +51 -0
- package/src/command.ts +41 -21
- package/src/git-input.ts +178 -0
- package/src/git.ts +107 -91
- 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,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.5
|
|
4
|
+
### Patch Changes
|
|
5
|
+
|
|
6
|
+
- c2fd867: Bound stdin Git cancellation and timeout cleanup with owned TERM-to-KILL escalation and a final confirmation deadline. Retain every concurrent unconfirmed handle per cwd/argv key and refuse equivalent replacements while any failed owner remains live; one late exit cannot release another owner's lease. Release retained handle leases on late actual exit without requiring a retry, and preserve captured output and signal-failure diagnostics through GitWorktreeError wrapping.
|
|
7
|
+
- Updated dependencies [c2fd867]
|
|
8
|
+
- @signalridge/pi-ui@1.4.0
|
|
9
|
+
|
|
10
|
+
## 1.4.4
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
- 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.
|
|
15
|
+
- 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.
|
|
16
|
+
- 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.
|
|
17
|
+
- Updated dependencies [3364cc0]
|
|
18
|
+
- Updated dependencies [3364cc0]
|
|
19
|
+
- Updated dependencies [3364cc0]
|
|
20
|
+
- @signalridge/pi-ui@1.3.3
|
|
21
|
+
|
|
3
22
|
## 1.4.3
|
|
4
23
|
### Patch Changes
|
|
5
24
|
|
package/README.md
CHANGED
|
@@ -23,6 +23,8 @@ This package is the full interactive worktree manager. It is publishable and sta
|
|
|
23
23
|
- Always previews stale metadata before pruning it and revalidates the preview after confirmation.
|
|
24
24
|
- Runs Git through argv-based subprocess calls, without interpolating user input into shell commands.
|
|
25
25
|
|
|
26
|
+
Stdin Git cancellation and timeout use bounded TERM/KILL escalation on the captured process handle. If termination remains unconfirmed, equivalent cwd/argv invocations are blocked until every failed live handle exits, including when multiple invocations were already concurrent. A late exit releases only its own handle; no saved PID is adopted or signaled. Captured output and signal failure diagnostics remain available for recovery.
|
|
27
|
+
|
|
26
28
|
## Install
|
|
27
29
|
|
|
28
30
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signalridge/pi-worktree",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.5",
|
|
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.
|
|
63
|
+
"@signalridge/pi-ui": "^1.4.0",
|
|
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-input.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
|
2
|
+
import type { ExecResult } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
// An unconfirmed process remains owned by its actual handle. Never signal a
|
|
5
|
+
// saved PID or allow an equivalent invocation to replace it while still alive.
|
|
6
|
+
const unconfirmed = new Map<string, Set<ChildProcessWithoutNullStreams>>();
|
|
7
|
+
const TERM_GRACE_MS = 250;
|
|
8
|
+
const CONFIRMATION_MS = 1_000;
|
|
9
|
+
|
|
10
|
+
type SignalAttempt = { signal: NodeJS.Signals; accepted: boolean; error?: Error };
|
|
11
|
+
|
|
12
|
+
/** Captured diagnostics without inventing an exit code for an unconfirmed process. */
|
|
13
|
+
export class GitInputError extends Error {
|
|
14
|
+
readonly stdout: string;
|
|
15
|
+
readonly stderr: string;
|
|
16
|
+
readonly reason: string;
|
|
17
|
+
readonly terminationConfirmed: boolean;
|
|
18
|
+
readonly signals: readonly SignalAttempt[];
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
message: string,
|
|
22
|
+
stdout: string,
|
|
23
|
+
stderr: string,
|
|
24
|
+
reason: string,
|
|
25
|
+
terminationConfirmed: boolean,
|
|
26
|
+
signals: readonly SignalAttempt[],
|
|
27
|
+
cause?: Error,
|
|
28
|
+
) {
|
|
29
|
+
super(message, { cause });
|
|
30
|
+
this.name = "GitInputError";
|
|
31
|
+
this.stdout = stdout;
|
|
32
|
+
this.stderr = stderr;
|
|
33
|
+
this.reason = reason;
|
|
34
|
+
this.terminationConfirmed = terminationConfirmed;
|
|
35
|
+
this.signals = [...signals];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function retainUntilExit(key: string, child: ChildProcessWithoutNullStreams): void {
|
|
40
|
+
let owners = unconfirmed.get(key);
|
|
41
|
+
if (!owners) {
|
|
42
|
+
owners = new Set();
|
|
43
|
+
unconfirmed.set(key, owners);
|
|
44
|
+
}
|
|
45
|
+
owners.add(child);
|
|
46
|
+
const release = () => {
|
|
47
|
+
owners.delete(child);
|
|
48
|
+
if (owners.size === 0 && unconfirmed.get(key) === owners) unconfirmed.delete(key);
|
|
49
|
+
child.removeListener("exit", release);
|
|
50
|
+
child.removeListener("close", release);
|
|
51
|
+
};
|
|
52
|
+
child.once("exit", release);
|
|
53
|
+
child.once("close", release);
|
|
54
|
+
if (child.exitCode !== null || child.signalCode !== null) release();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function runGitInput(
|
|
58
|
+
args: string[],
|
|
59
|
+
cwd: string,
|
|
60
|
+
input: string,
|
|
61
|
+
signal?: AbortSignal,
|
|
62
|
+
timeout = 15_000,
|
|
63
|
+
): Promise<ExecResult> {
|
|
64
|
+
const key = JSON.stringify([cwd, args]);
|
|
65
|
+
const previous = unconfirmed.get(key);
|
|
66
|
+
if (previous) {
|
|
67
|
+
for (const child of previous) {
|
|
68
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
69
|
+
return Promise.reject(
|
|
70
|
+
new Error("Previous Git termination is unconfirmed. Check the owned Git process and retry after it exits."),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
unconfirmed.delete(key);
|
|
75
|
+
}
|
|
76
|
+
if (signal?.aborted) return Promise.resolve({ stdout: "", stderr: "", code: 1, killed: true });
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const child = spawn("git", args, { cwd, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
79
|
+
let stdout = "";
|
|
80
|
+
let stderr = "";
|
|
81
|
+
let settled = false;
|
|
82
|
+
let stopping = false;
|
|
83
|
+
let reason = "";
|
|
84
|
+
let failure: Error | undefined;
|
|
85
|
+
const signals: SignalAttempt[] = [];
|
|
86
|
+
const capturedFailure = (message: string, confirmed: boolean, cause?: Error) =>
|
|
87
|
+
new GitInputError(message, stdout, stderr, reason, confirmed, signals, cause);
|
|
88
|
+
let escalation: ReturnType<typeof setTimeout> | undefined;
|
|
89
|
+
let confirmation: ReturnType<typeof setTimeout> | undefined;
|
|
90
|
+
const cleanup = () => {
|
|
91
|
+
clearTimeout(deadline);
|
|
92
|
+
clearTimeout(escalation);
|
|
93
|
+
clearTimeout(confirmation);
|
|
94
|
+
signal?.removeEventListener("abort", abort);
|
|
95
|
+
child.removeListener("error", error);
|
|
96
|
+
child.removeListener("close", close);
|
|
97
|
+
child.stdin.removeListener("error", inputError);
|
|
98
|
+
child.stdout.removeListener("data", output);
|
|
99
|
+
child.stderr.removeListener("data", diagnostics);
|
|
100
|
+
child.stdin.destroy();
|
|
101
|
+
child.stdout.destroy();
|
|
102
|
+
child.stderr.destroy();
|
|
103
|
+
};
|
|
104
|
+
const finish = (result?: ExecResult, error?: Error) => {
|
|
105
|
+
if (settled) return;
|
|
106
|
+
settled = true;
|
|
107
|
+
cleanup();
|
|
108
|
+
if (error) reject(error);
|
|
109
|
+
else if (result) resolve(result);
|
|
110
|
+
};
|
|
111
|
+
const sendSignal = (name: NodeJS.Signals) => {
|
|
112
|
+
try {
|
|
113
|
+
const accepted = child.kill(name);
|
|
114
|
+
signals.push({ signal: name, accepted });
|
|
115
|
+
if (!accepted) failure ??= new Error(`Git did not accept ${name}.`);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
118
|
+
signals.push({ signal: name, accepted: false, error: cause });
|
|
119
|
+
failure ??= cause;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const stop = (why: string) => {
|
|
123
|
+
if (settled || stopping) return;
|
|
124
|
+
stopping = true;
|
|
125
|
+
reason = why;
|
|
126
|
+
clearTimeout(deadline);
|
|
127
|
+
sendSignal("SIGTERM");
|
|
128
|
+
escalation = setTimeout(() => {
|
|
129
|
+
if (settled) return;
|
|
130
|
+
sendSignal("SIGKILL");
|
|
131
|
+
confirmation = setTimeout(() => {
|
|
132
|
+
if (settled) return;
|
|
133
|
+
retainUntilExit(key, child);
|
|
134
|
+
finish(
|
|
135
|
+
undefined,
|
|
136
|
+
capturedFailure(
|
|
137
|
+
`Git termination could not be confirmed after ${reason}. Check the owned Git process before retrying.${failure ? ` ${failure.message}` : ""}`,
|
|
138
|
+
false,
|
|
139
|
+
failure,
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
}, CONFIRMATION_MS);
|
|
143
|
+
}, TERM_GRACE_MS);
|
|
144
|
+
};
|
|
145
|
+
const abort = () => stop("cancellation");
|
|
146
|
+
const error = (value: Error) => {
|
|
147
|
+
failure = value;
|
|
148
|
+
if (child.pid === undefined) finish(undefined, capturedFailure(value.message, true, value));
|
|
149
|
+
else stop("process failure");
|
|
150
|
+
};
|
|
151
|
+
const inputError = (value: NodeJS.ErrnoException) => {
|
|
152
|
+
if (value.code !== "EPIPE") error(value);
|
|
153
|
+
};
|
|
154
|
+
const output = (chunk: string) => {
|
|
155
|
+
stdout += chunk;
|
|
156
|
+
};
|
|
157
|
+
const diagnostics = (chunk: string) => {
|
|
158
|
+
stderr += chunk;
|
|
159
|
+
};
|
|
160
|
+
const close = (code: number | null, closeSignal: NodeJS.Signals | null) => {
|
|
161
|
+
finish(
|
|
162
|
+
{ stdout, stderr, code: code ?? 1, killed: stopping || closeSignal !== null },
|
|
163
|
+
failure ? capturedFailure(failure.message, true, failure) : undefined,
|
|
164
|
+
);
|
|
165
|
+
};
|
|
166
|
+
const deadline = setTimeout(() => stop(`${timeout}ms timeout`), timeout);
|
|
167
|
+
child.stdout.setEncoding("utf8");
|
|
168
|
+
child.stderr.setEncoding("utf8");
|
|
169
|
+
child.stdout.on("data", output);
|
|
170
|
+
child.stderr.on("data", diagnostics);
|
|
171
|
+
child.stdin.on("error", inputError);
|
|
172
|
+
child.once("error", error);
|
|
173
|
+
child.once("close", close);
|
|
174
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
175
|
+
if (signal?.aborted) abort();
|
|
176
|
+
child.stdin.end(input);
|
|
177
|
+
});
|
|
178
|
+
}
|
package/src/git.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import { randomUUID } from "node:crypto";
|
|
3
2
|
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
4
3
|
import { lstat, readdir, rename, rmdir, unlink } from "node:fs/promises";
|
|
5
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
6
5
|
import type { ExecResult, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
6
|
import lockfile from "proper-lockfile";
|
|
7
|
+
import { GitInputError, runGitInput } from "./git-input.ts";
|
|
8
8
|
|
|
9
9
|
const GIT_TIMEOUT_MS = 15_000;
|
|
10
10
|
const GIT_MUTATION_TIMEOUT_MS = 60_000;
|
|
@@ -63,13 +63,27 @@ interface GitExecOptions {
|
|
|
63
63
|
|
|
64
64
|
export class GitWorktreeError extends Error {
|
|
65
65
|
readonly args?: readonly string[];
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
readonly stdout?: string;
|
|
67
|
+
readonly stderr?: string;
|
|
68
|
+
|
|
69
|
+
constructor(message: string, args?: readonly string[], cause?: unknown) {
|
|
70
|
+
super(message, { cause });
|
|
71
|
+
if (cause instanceof GitInputError) {
|
|
72
|
+
this.stdout = cause.stdout;
|
|
73
|
+
this.stderr = cause.stderr;
|
|
74
|
+
}
|
|
69
75
|
this.name = "GitWorktreeError";
|
|
70
76
|
this.args = args;
|
|
71
77
|
}
|
|
72
78
|
}
|
|
79
|
+
/** A mutation left retained data or an outcome that requires recovery inspection. */
|
|
80
|
+
export class WorktreeRecoveryError extends GitWorktreeError {
|
|
81
|
+
constructor(message: string) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = "WorktreeRecoveryError";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
73
87
|
class MetadataDeletionRetainedError extends Error {
|
|
74
88
|
readonly retainedPath: string;
|
|
75
89
|
readonly outcomeUnknown: boolean;
|
|
@@ -376,19 +390,35 @@ async function acquireFilesystemMutationLock(key: string, signal?: AbortSignal):
|
|
|
376
390
|
}
|
|
377
391
|
}
|
|
378
392
|
|
|
379
|
-
async function withMetadataPruneLock<T>(key: string, operation: () => Promise<T
|
|
393
|
+
async function withMetadataPruneLock<T>(key: string, operation: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
394
|
+
signal?.throwIfAborted();
|
|
380
395
|
const previous = metadataPruneLocks.get(key);
|
|
381
396
|
let release!: () => void;
|
|
382
397
|
const current = new Promise<void>((resolveLock) => {
|
|
383
398
|
release = resolveLock;
|
|
384
399
|
});
|
|
385
400
|
metadataPruneLocks.set(key, current);
|
|
386
|
-
|
|
401
|
+
let cancel: (() => void) | undefined;
|
|
387
402
|
try {
|
|
403
|
+
if (previous) {
|
|
404
|
+
await Promise.race([
|
|
405
|
+
previous,
|
|
406
|
+
new Promise<never>((_resolve, reject) => {
|
|
407
|
+
cancel = () => reject(new GitWorktreeError("worktree mutation lock wait aborted."));
|
|
408
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
409
|
+
if (signal?.aborted) cancel();
|
|
410
|
+
}),
|
|
411
|
+
]);
|
|
412
|
+
}
|
|
413
|
+
signal?.throwIfAborted();
|
|
388
414
|
return await operation();
|
|
389
415
|
} finally {
|
|
390
|
-
|
|
391
|
-
|
|
416
|
+
if (cancel) signal?.removeEventListener("abort", cancel);
|
|
417
|
+
// A cancelled queue slot must not let its successors overtake the holder.
|
|
418
|
+
void Promise.resolve(previous).then(() => {
|
|
419
|
+
release();
|
|
420
|
+
if (metadataPruneLocks.get(key) === current) metadataPruneLocks.delete(key);
|
|
421
|
+
});
|
|
392
422
|
}
|
|
393
423
|
}
|
|
394
424
|
|
|
@@ -398,14 +428,19 @@ export function withWorktreeMutationLock<T>(
|
|
|
398
428
|
signal?: AbortSignal,
|
|
399
429
|
): Promise<T> {
|
|
400
430
|
const key = worktreeMutationLockKey(cwd);
|
|
401
|
-
return withMetadataPruneLock(
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
431
|
+
return withMetadataPruneLock(
|
|
432
|
+
key,
|
|
433
|
+
async () => {
|
|
434
|
+
const release = await acquireFilesystemMutationLock(key, signal);
|
|
435
|
+
try {
|
|
436
|
+
signal?.throwIfAborted();
|
|
437
|
+
return await operation();
|
|
438
|
+
} finally {
|
|
439
|
+
await release();
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
signal,
|
|
443
|
+
);
|
|
409
444
|
}
|
|
410
445
|
|
|
411
446
|
function commonDirectoryForGitdir(gitdir: string): string {
|
|
@@ -515,19 +550,36 @@ export async function removeWorktreeMetadata(
|
|
|
515
550
|
throw new GitWorktreeError(`Refusing metadata prune because the stale-record preview changed.`);
|
|
516
551
|
}
|
|
517
552
|
if (targetAdministrative) {
|
|
518
|
-
await removeAdministrativeRecord(targetAdministrative);
|
|
553
|
+
await removeAdministrativeRecord(targetAdministrative, signal);
|
|
519
554
|
} else {
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
555
|
+
signal?.throwIfAborted();
|
|
556
|
+
try {
|
|
557
|
+
await runGit(pi, ["worktree", "prune", "--expire", "now"], cwd, signal, GIT_MUTATION_TIMEOUT_MS);
|
|
558
|
+
} catch (error) {
|
|
559
|
+
// An interrupted Git process may already have deregistered the tree.
|
|
560
|
+
throw new WorktreeRecoveryError(
|
|
561
|
+
`Worktree metadata removal outcome is unknown for ${path}: ${formatError(error)}`,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
529
564
|
}
|
|
565
|
+
// Deregistration is the commit point, not the subsequent verification.
|
|
566
|
+
// Never attempt registration rollback after successful metadata deletion.
|
|
530
567
|
onMetadataRemoved?.();
|
|
568
|
+
try {
|
|
569
|
+
// Recovery verification must finish independently of the retired UI owner.
|
|
570
|
+
const after = await listWorktrees(pi, cwd);
|
|
571
|
+
if (after.some((record) => pathsEqual(record.path, path))) {
|
|
572
|
+
throw new GitWorktreeError(`Git did not remove worktree metadata for ${path}.`);
|
|
573
|
+
}
|
|
574
|
+
const administrativeAfter = await administrativePruneCandidatesIfPresent(pi, cwd);
|
|
575
|
+
if (administrativeAfter.length !== 0) {
|
|
576
|
+
throw new GitWorktreeError(`Git left stale administrative records after pruning ${path}.`);
|
|
577
|
+
}
|
|
578
|
+
} catch (error) {
|
|
579
|
+
throw new WorktreeRecoveryError(
|
|
580
|
+
`Worktree metadata removal completed for ${path}, but verification failed: ${formatError(error)}`,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
531
583
|
};
|
|
532
584
|
if (lockHeld) await operation();
|
|
533
585
|
else await withWorktreeMutationLock(cwd, operation, signal);
|
|
@@ -817,7 +869,11 @@ async function removeMetadataTree(path: string, expected: MetadataIdentity): Pro
|
|
|
817
869
|
}
|
|
818
870
|
}
|
|
819
871
|
|
|
820
|
-
async function removeAdministrativeRecord(
|
|
872
|
+
async function removeAdministrativeRecord(
|
|
873
|
+
candidate: AdministrativePruneCandidate,
|
|
874
|
+
signal?: AbortSignal,
|
|
875
|
+
): Promise<void> {
|
|
876
|
+
signal?.throwIfAborted();
|
|
821
877
|
const expected = administrativeIdentities.get(candidate);
|
|
822
878
|
if (!expected) throw new GitWorktreeError(`Git metadata identity was not captured for ${candidate.id}.`);
|
|
823
879
|
const source = candidate.administrativePath;
|
|
@@ -829,6 +885,7 @@ async function removeAdministrativeRecord(candidate: AdministrativePruneCandidat
|
|
|
829
885
|
if (worktreeGitFile && existsSync(worktreeGitFile)) {
|
|
830
886
|
throw new Error(`worktree ${worktreePath} became valid before metadata removal`);
|
|
831
887
|
}
|
|
888
|
+
signal?.throwIfAborted();
|
|
832
889
|
await rename(source, tombstone);
|
|
833
890
|
moved = true;
|
|
834
891
|
if (worktreeGitFile && existsSync(worktreeGitFile)) {
|
|
@@ -836,6 +893,14 @@ async function removeAdministrativeRecord(candidate: AdministrativePruneCandidat
|
|
|
836
893
|
moved = false;
|
|
837
894
|
throw new Error(`worktree ${worktreePath} became valid while claiming metadata`);
|
|
838
895
|
}
|
|
896
|
+
if (signal?.aborted) {
|
|
897
|
+
// Claiming metadata is reversible; finish rollback without the cancelled signal.
|
|
898
|
+
if (!existsSync(source)) {
|
|
899
|
+
await rename(tombstone, source);
|
|
900
|
+
moved = false;
|
|
901
|
+
}
|
|
902
|
+
signal.throwIfAborted();
|
|
903
|
+
}
|
|
839
904
|
await removeMetadataTree(tombstone, expected);
|
|
840
905
|
} catch (error: unknown) {
|
|
841
906
|
const retainedPath =
|
|
@@ -844,9 +909,9 @@ async function removeAdministrativeRecord(candidate: AdministrativePruneCandidat
|
|
|
844
909
|
error instanceof MetadataDeletionRetainedError && error.outcomeUnknown
|
|
845
910
|
? " Metadata removal outcome is unknown."
|
|
846
911
|
: "";
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
);
|
|
912
|
+
const message = `Git administrative metadata removal failed for ${candidate.id}${retainedPath ? `; retained at ${retainedPath}` : ""}.${outcomeWarning} ${formatError(error)}`;
|
|
913
|
+
if (retainedPath || outcomeWarning) throw new WorktreeRecoveryError(message);
|
|
914
|
+
throw new GitWorktreeError(message);
|
|
850
915
|
}
|
|
851
916
|
}
|
|
852
917
|
function sameAdministrativeCandidate(left: AdministrativePruneCandidate, right: AdministrativePruneCandidate): boolean {
|
|
@@ -959,7 +1024,7 @@ export async function pruneWorktrees(
|
|
|
959
1024
|
const result = await runGit(pi, ["worktree", "prune", "--verbose"], cwd, signal, GIT_MUTATION_TIMEOUT_MS);
|
|
960
1025
|
return combineOutput(result);
|
|
961
1026
|
}
|
|
962
|
-
for (const candidate of candidates) await removeAdministrativeRecord(candidate);
|
|
1027
|
+
for (const candidate of candidates) await removeAdministrativeRecord(candidate, signal);
|
|
963
1028
|
return candidates.map((candidate) => `Removed ${candidate.id}`).join("\n");
|
|
964
1029
|
};
|
|
965
1030
|
return lockHeld ? operation() : withWorktreeMutationLock(cwd, operation, signal);
|
|
@@ -1050,7 +1115,10 @@ async function runGitAllowFailure(
|
|
|
1050
1115
|
timeout = GIT_TIMEOUT_MS,
|
|
1051
1116
|
): Promise<ExecResult> {
|
|
1052
1117
|
try {
|
|
1053
|
-
|
|
1118
|
+
signal?.throwIfAborted();
|
|
1119
|
+
const result = await pi.exec("git", args, { cwd, signal, timeout });
|
|
1120
|
+
signal?.throwIfAborted();
|
|
1121
|
+
return result;
|
|
1054
1122
|
} catch (error) {
|
|
1055
1123
|
const message = formatError(error);
|
|
1056
1124
|
if (/\bENOENT\b|not found/i.test(message)) {
|
|
@@ -1159,65 +1227,13 @@ function runGitWithInputAllowFailure(
|
|
|
1159
1227
|
if (signal?.aborted) {
|
|
1160
1228
|
return Promise.resolve({ stdout: "", stderr: "", code: 1, killed: true });
|
|
1161
1229
|
}
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
let stdout = "";
|
|
1170
|
-
let stderr = "";
|
|
1171
|
-
let killed = false;
|
|
1172
|
-
let settled = false;
|
|
1173
|
-
const finish = (result: ExecResult) => {
|
|
1174
|
-
if (settled) return;
|
|
1175
|
-
settled = true;
|
|
1176
|
-
clearTimeout(timeoutHandle);
|
|
1177
|
-
signal?.removeEventListener("abort", stop);
|
|
1178
|
-
resolveResult(result);
|
|
1179
|
-
};
|
|
1180
|
-
const fail = (error: unknown) => {
|
|
1181
|
-
if (settled) return;
|
|
1182
|
-
settled = true;
|
|
1183
|
-
clearTimeout(timeoutHandle);
|
|
1184
|
-
signal?.removeEventListener("abort", stop);
|
|
1185
|
-
child.kill();
|
|
1186
|
-
const message = formatError(error);
|
|
1187
|
-
reject(
|
|
1188
|
-
/\bENOENT\b|not found/i.test(message)
|
|
1189
|
-
? new GitWorktreeError("Git executable was not found. Install Git and retry.", args)
|
|
1190
|
-
: new GitWorktreeError(`Could not start git ${args.slice(0, 2).join(" ")}: ${message}`, args),
|
|
1191
|
-
);
|
|
1192
|
-
};
|
|
1193
|
-
const stop = () => {
|
|
1194
|
-
killed = true;
|
|
1195
|
-
child.kill();
|
|
1196
|
-
};
|
|
1197
|
-
const timeoutHandle = setTimeout(stop, timeout);
|
|
1198
|
-
child.stdout.setEncoding("utf8");
|
|
1199
|
-
child.stderr.setEncoding("utf8");
|
|
1200
|
-
child.stdout.on("data", (chunk: string) => {
|
|
1201
|
-
stdout += chunk;
|
|
1202
|
-
});
|
|
1203
|
-
child.stderr.on("data", (chunk: string) => {
|
|
1204
|
-
stderr += chunk;
|
|
1205
|
-
});
|
|
1206
|
-
child.stdin.on("error", (error) => {
|
|
1207
|
-
if (!isNodeError(error) || error.code !== "EPIPE") fail(error);
|
|
1208
|
-
});
|
|
1209
|
-
child.once("error", fail);
|
|
1210
|
-
child.once("close", (code, closeSignal) => {
|
|
1211
|
-
finish({
|
|
1212
|
-
stdout,
|
|
1213
|
-
stderr,
|
|
1214
|
-
code: code ?? 1,
|
|
1215
|
-
killed: killed || closeSignal !== null,
|
|
1216
|
-
});
|
|
1217
|
-
});
|
|
1218
|
-
signal?.addEventListener("abort", stop, { once: true });
|
|
1219
|
-
if (signal?.aborted) stop();
|
|
1220
|
-
child.stdin.end(input);
|
|
1230
|
+
// ExtensionAPI.exec has no stdin channel. This local argv-only owner bounds
|
|
1231
|
+
// both cancellation and timeout cleanup without changing native Git settings.
|
|
1232
|
+
return runGitInput(args, cwd, input, signal, timeout).catch((error: unknown) => {
|
|
1233
|
+
const message = formatError(error);
|
|
1234
|
+
throw /\bENOENT\b|not found/i.test(message)
|
|
1235
|
+
? new GitWorktreeError("Git executable was not found. Install Git and retry.", args, error)
|
|
1236
|
+
: new GitWorktreeError(`git ${args.slice(0, 2).join(" ")}: ${message}`, args, error);
|
|
1221
1237
|
});
|
|
1222
1238
|
}
|
|
1223
1239
|
|
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"));
|