@signalridge/pi-worktree 1.4.4 → 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 +7 -0
- package/README.md +2 -0
- package/package.json +2 -2
- package/src/git-input.ts +178 -0
- package/src/git.ts +17 -63
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
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
|
+
|
|
3
10
|
## 1.4.4
|
|
4
11
|
### Patch Changes
|
|
5
12
|
|
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",
|
|
@@ -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
|
}
|
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,9 +63,15 @@ 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
|
}
|
|
@@ -1221,65 +1227,13 @@ function runGitWithInputAllowFailure(
|
|
|
1221
1227
|
if (signal?.aborted) {
|
|
1222
1228
|
return Promise.resolve({ stdout: "", stderr: "", code: 1, killed: true });
|
|
1223
1229
|
}
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
let stdout = "";
|
|
1232
|
-
let stderr = "";
|
|
1233
|
-
let killed = false;
|
|
1234
|
-
let settled = false;
|
|
1235
|
-
const finish = (result: ExecResult) => {
|
|
1236
|
-
if (settled) return;
|
|
1237
|
-
settled = true;
|
|
1238
|
-
clearTimeout(timeoutHandle);
|
|
1239
|
-
signal?.removeEventListener("abort", stop);
|
|
1240
|
-
resolveResult(result);
|
|
1241
|
-
};
|
|
1242
|
-
const fail = (error: unknown) => {
|
|
1243
|
-
if (settled) return;
|
|
1244
|
-
settled = true;
|
|
1245
|
-
clearTimeout(timeoutHandle);
|
|
1246
|
-
signal?.removeEventListener("abort", stop);
|
|
1247
|
-
child.kill();
|
|
1248
|
-
const message = formatError(error);
|
|
1249
|
-
reject(
|
|
1250
|
-
/\bENOENT\b|not found/i.test(message)
|
|
1251
|
-
? new GitWorktreeError("Git executable was not found. Install Git and retry.", args)
|
|
1252
|
-
: new GitWorktreeError(`Could not start git ${args.slice(0, 2).join(" ")}: ${message}`, args),
|
|
1253
|
-
);
|
|
1254
|
-
};
|
|
1255
|
-
const stop = () => {
|
|
1256
|
-
killed = true;
|
|
1257
|
-
child.kill();
|
|
1258
|
-
};
|
|
1259
|
-
const timeoutHandle = setTimeout(stop, timeout);
|
|
1260
|
-
child.stdout.setEncoding("utf8");
|
|
1261
|
-
child.stderr.setEncoding("utf8");
|
|
1262
|
-
child.stdout.on("data", (chunk: string) => {
|
|
1263
|
-
stdout += chunk;
|
|
1264
|
-
});
|
|
1265
|
-
child.stderr.on("data", (chunk: string) => {
|
|
1266
|
-
stderr += chunk;
|
|
1267
|
-
});
|
|
1268
|
-
child.stdin.on("error", (error) => {
|
|
1269
|
-
if (!isNodeError(error) || error.code !== "EPIPE") fail(error);
|
|
1270
|
-
});
|
|
1271
|
-
child.once("error", fail);
|
|
1272
|
-
child.once("close", (code, closeSignal) => {
|
|
1273
|
-
finish({
|
|
1274
|
-
stdout,
|
|
1275
|
-
stderr,
|
|
1276
|
-
code: code ?? 1,
|
|
1277
|
-
killed: killed || closeSignal !== null,
|
|
1278
|
-
});
|
|
1279
|
-
});
|
|
1280
|
-
signal?.addEventListener("abort", stop, { once: true });
|
|
1281
|
-
if (signal?.aborted) stop();
|
|
1282
|
-
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);
|
|
1283
1237
|
});
|
|
1284
1238
|
}
|
|
1285
1239
|
|