@signalridge/pi-worktree 1.4.6 → 1.5.0
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 +15 -0
- package/README.md +4 -0
- package/package.json +5 -5
- package/src/git-input.ts +44 -9
- package/src/git.ts +109 -27
- package/src/safe-remove.ts +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.0
|
|
4
|
+
### Minor Changes
|
|
5
|
+
|
|
6
|
+
- 3096585: Qualify the shared TUI Kit 0.69 release against Pi 1.5, synchronizing all consumer ranges. Forward native viewport capture through border adapters and declare it for custom selectors so configured paging reaches the focused list while overlays retain priority.
|
|
7
|
+
|
|
8
|
+
Settle bordered custom interactions locally on dispose-only host teardown without invoking a stale host completion callback. Preserve consumer-owned cancellation, session fences, draft handling, and accepted-work draining; dispose wrapped components exactly once.
|
|
9
|
+
|
|
10
|
+
### Patch Changes
|
|
11
|
+
|
|
12
|
+
- 3096585: Bound combined Git and PR subprocess output by operation, reject incomplete safety data, preserve uncertain mutation outcomes for inspection, and let passive statusline timers release the process naturally.
|
|
13
|
+
- 3096585: Move the development SDK pins to exact Pi 1.5.0 while retaining wildcard host-owned peers and canonical TypeBox 1.3.27. Import the advisor's schema builder directly from TypeBox and report the required `truncated` flag from local Git process adapters. Update SDK fixtures for the new baseline and pi-schema validation diagnostics without changing lifecycle, consent, or model-selection policy.
|
|
14
|
+
- Updated dependencies [3096585]
|
|
15
|
+
- Updated dependencies [3096585]
|
|
16
|
+
- @signalridge/pi-ui@1.5.0
|
|
17
|
+
|
|
3
18
|
## 1.4.6
|
|
4
19
|
### Patch Changes
|
|
5
20
|
|
package/README.md
CHANGED
|
@@ -170,6 +170,10 @@ packages/pi-worktree/
|
|
|
170
170
|
└── tsconfig.json
|
|
171
171
|
```
|
|
172
172
|
|
|
173
|
+
## Local divergence
|
|
174
|
+
|
|
175
|
+
This fork retains the guarded inventory, quarantine, metadata-pruning, and process-ownership safeguards described above. Git capture budgets count combined stdout/stderr bytes per operation: metadata 64 KiB, inventories 8 MiB, and mutations 1 MiB. Incomplete output is rejected before parsing or expected-nonzero fallback, including sparse-checkout rule checks. An incomplete inventory never authorizes deletion. Mutation overflow may occur after Git commits its change: inspect `git worktree list` and affected paths before retrying; the extension does not automatically roll back that uncertain outcome. Stdin capture handles fragmented UTF-8 and uses the same bounded TERM/KILL/drain ownership on overflow as on cancellation and timeout.
|
|
176
|
+
|
|
173
177
|
## License
|
|
174
178
|
|
|
175
179
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signalridge/pi-worktree",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Pi extension for safe interactive Git worktree management and workspace switching.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@biomejs/biome": "2.5.7",
|
|
42
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
43
|
-
"@earendil-works/pi-tui": "1.
|
|
42
|
+
"@earendil-works/pi-coding-agent": "1.5.0",
|
|
43
|
+
"@earendil-works/pi-tui": "1.5.0",
|
|
44
44
|
"@types/node": "26.1.2",
|
|
45
45
|
"@types/proper-lockfile": "^4.1.4",
|
|
46
46
|
"typescript": "7.0.2",
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
"access": "public"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@narumitw/pi-tui-kit": "^0.
|
|
63
|
-
"@signalridge/pi-ui": "^1.
|
|
62
|
+
"@narumitw/pi-tui-kit": "^0.69.0",
|
|
63
|
+
"@signalridge/pi-ui": "^1.5.0",
|
|
64
64
|
"proper-lockfile": "^4.1.2"
|
|
65
65
|
}
|
|
66
66
|
}
|
package/src/git-input.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
2
3
|
import type { ExecResult } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
|
|
4
5
|
// An unconfirmed process remains owned by its actual handle. Never signal a
|
|
@@ -6,6 +7,7 @@ import type { ExecResult } from "@earendil-works/pi-coding-agent";
|
|
|
6
7
|
const unconfirmed = new Map<string, Set<ChildProcessWithoutNullStreams>>();
|
|
7
8
|
const TERM_GRACE_MS = 250;
|
|
8
9
|
const CONFIRMATION_MS = 1_000;
|
|
10
|
+
const INVENTORY_MAX_BYTES = 8 * 1024 * 1024;
|
|
9
11
|
|
|
10
12
|
type SignalAttempt = { signal: NodeJS.Signals; accepted: boolean; error?: Error };
|
|
11
13
|
|
|
@@ -16,6 +18,7 @@ export class GitInputError extends Error {
|
|
|
16
18
|
readonly reason: string;
|
|
17
19
|
readonly terminationConfirmed: boolean;
|
|
18
20
|
readonly signals: readonly SignalAttempt[];
|
|
21
|
+
readonly truncated: boolean;
|
|
19
22
|
|
|
20
23
|
constructor(
|
|
21
24
|
message: string,
|
|
@@ -25,6 +28,7 @@ export class GitInputError extends Error {
|
|
|
25
28
|
terminationConfirmed: boolean,
|
|
26
29
|
signals: readonly SignalAttempt[],
|
|
27
30
|
cause?: Error,
|
|
31
|
+
truncated = false,
|
|
28
32
|
) {
|
|
29
33
|
super(message, { cause });
|
|
30
34
|
this.name = "GitInputError";
|
|
@@ -33,6 +37,7 @@ export class GitInputError extends Error {
|
|
|
33
37
|
this.reason = reason;
|
|
34
38
|
this.terminationConfirmed = terminationConfirmed;
|
|
35
39
|
this.signals = [...signals];
|
|
40
|
+
this.truncated = truncated;
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
43
|
|
|
@@ -60,7 +65,11 @@ export function runGitInput(
|
|
|
60
65
|
input: string,
|
|
61
66
|
signal?: AbortSignal,
|
|
62
67
|
timeout = 15_000,
|
|
68
|
+
maxBuffer = INVENTORY_MAX_BYTES,
|
|
63
69
|
): Promise<ExecResult> {
|
|
70
|
+
if (!Number.isSafeInteger(maxBuffer) || maxBuffer < 0) {
|
|
71
|
+
return Promise.reject(new RangeError("maxBuffer must be a nonnegative safe integer"));
|
|
72
|
+
}
|
|
64
73
|
const key = JSON.stringify([cwd, args]);
|
|
65
74
|
const previous = unconfirmed.get(key);
|
|
66
75
|
if (previous) {
|
|
@@ -73,18 +82,22 @@ export function runGitInput(
|
|
|
73
82
|
}
|
|
74
83
|
unconfirmed.delete(key);
|
|
75
84
|
}
|
|
76
|
-
if (signal?.aborted) return Promise.resolve({ stdout: "", stderr: "", code: 1, killed: true });
|
|
85
|
+
if (signal?.aborted) return Promise.resolve({ stdout: "", stderr: "", code: 1, killed: true, truncated: false });
|
|
77
86
|
return new Promise((resolve, reject) => {
|
|
78
87
|
const child = spawn("git", args, { cwd, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
79
88
|
let stdout = "";
|
|
80
89
|
let stderr = "";
|
|
90
|
+
let capturedBytes = 0;
|
|
91
|
+
let truncated = false;
|
|
92
|
+
const stdoutDecoder = new StringDecoder("utf8");
|
|
93
|
+
const stderrDecoder = new StringDecoder("utf8");
|
|
81
94
|
let settled = false;
|
|
82
95
|
let stopping = false;
|
|
83
96
|
let reason = "";
|
|
84
97
|
let failure: Error | undefined;
|
|
85
98
|
const signals: SignalAttempt[] = [];
|
|
86
99
|
const capturedFailure = (message: string, confirmed: boolean, cause?: Error) =>
|
|
87
|
-
new GitInputError(message, stdout, stderr, reason, confirmed, signals, cause);
|
|
100
|
+
new GitInputError(message, stdout, stderr, reason, confirmed, signals, cause, truncated);
|
|
88
101
|
let escalation: ReturnType<typeof setTimeout> | undefined;
|
|
89
102
|
let confirmation: ReturnType<typeof setTimeout> | undefined;
|
|
90
103
|
const cleanup = () => {
|
|
@@ -151,21 +164,43 @@ export function runGitInput(
|
|
|
151
164
|
const inputError = (value: NodeJS.ErrnoException) => {
|
|
152
165
|
if (value.code !== "EPIPE") error(value);
|
|
153
166
|
};
|
|
154
|
-
|
|
155
|
-
|
|
167
|
+
// Count raw bytes across BOTH pipes, including fragmented UTF-8 code points.
|
|
168
|
+
// Once full, keep draining without retaining output until the owned child closes.
|
|
169
|
+
const capture = (chunk: Buffer, decoder: StringDecoder): string => {
|
|
170
|
+
if (truncated) return "";
|
|
171
|
+
const retained = chunk.subarray(0, Math.max(0, maxBuffer - capturedBytes));
|
|
172
|
+
capturedBytes += retained.length;
|
|
173
|
+
const text = decoder.write(retained);
|
|
174
|
+
if (retained.length < chunk.length) {
|
|
175
|
+
truncated = true;
|
|
176
|
+
stop(`output overflow (${maxBuffer} bytes)`);
|
|
177
|
+
}
|
|
178
|
+
return text;
|
|
179
|
+
};
|
|
180
|
+
const output = (chunk: Buffer) => {
|
|
181
|
+
stdout += capture(chunk, stdoutDecoder);
|
|
156
182
|
};
|
|
157
|
-
const diagnostics = (chunk:
|
|
158
|
-
stderr += chunk;
|
|
183
|
+
const diagnostics = (chunk: Buffer) => {
|
|
184
|
+
stderr += capture(chunk, stderrDecoder);
|
|
159
185
|
};
|
|
160
186
|
const close = (code: number | null, closeSignal: NodeJS.Signals | null) => {
|
|
187
|
+
// Do not synthesize a replacement character for a code point cut by the cap.
|
|
188
|
+
if (!truncated) {
|
|
189
|
+
stdout += stdoutDecoder.end();
|
|
190
|
+
stderr += stderrDecoder.end();
|
|
191
|
+
}
|
|
161
192
|
finish(
|
|
162
|
-
{
|
|
193
|
+
{
|
|
194
|
+
stdout,
|
|
195
|
+
stderr,
|
|
196
|
+
code: truncated ? code || 1 : (code ?? 1),
|
|
197
|
+
killed: stopping || closeSignal !== null,
|
|
198
|
+
truncated,
|
|
199
|
+
},
|
|
163
200
|
failure ? capturedFailure(failure.message, true, failure) : undefined,
|
|
164
201
|
);
|
|
165
202
|
};
|
|
166
203
|
const deadline = setTimeout(() => stop(`${timeout}ms timeout`), timeout);
|
|
167
|
-
child.stdout.setEncoding("utf8");
|
|
168
|
-
child.stderr.setEncoding("utf8");
|
|
169
204
|
child.stdout.on("data", output);
|
|
170
205
|
child.stderr.on("data", diagnostics);
|
|
171
206
|
child.stdin.on("error", inputError);
|
package/src/git.ts
CHANGED
|
@@ -2,12 +2,16 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { lstat, readdir, rename, rmdir, unlink } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
-
import type { ExecResult, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { ExecOptions, ExecResult, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import lockfile from "proper-lockfile";
|
|
7
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;
|
|
11
|
+
// Operation classes, not user settings: incomplete safety inventories must fail closed.
|
|
12
|
+
const GIT_METADATA_MAX_BYTES = 64 * 1024;
|
|
13
|
+
const GIT_INVENTORY_MAX_BYTES = 8 * 1024 * 1024;
|
|
14
|
+
const GIT_MUTATION_MAX_BYTES = 1024 * 1024;
|
|
11
15
|
const MUTATION_LOCK_WAIT_MS = 50;
|
|
12
16
|
const MUTATION_LOCK_TIMEOUT_MS = 120_000;
|
|
13
17
|
const MUTATION_LOCK_STALE_MS = 30_000;
|
|
@@ -55,11 +59,7 @@ export interface GitClient {
|
|
|
55
59
|
exec(command: string, args: string[], options?: GitExecOptions): Promise<ExecResult>;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
|
|
59
|
-
cwd?: string;
|
|
60
|
-
signal?: AbortSignal;
|
|
61
|
-
timeout?: number;
|
|
62
|
-
}
|
|
62
|
+
type GitExecOptions = ExecOptions;
|
|
63
63
|
|
|
64
64
|
export class GitWorktreeError extends Error {
|
|
65
65
|
readonly args?: readonly string[];
|
|
@@ -242,7 +242,14 @@ export async function listWorktrees(
|
|
|
242
242
|
cwd: string,
|
|
243
243
|
signal?: AbortSignal,
|
|
244
244
|
): Promise<WorktreeRecord[]> {
|
|
245
|
-
const result = await runGit(
|
|
245
|
+
const result = await runGit(
|
|
246
|
+
pi,
|
|
247
|
+
["worktree", "list", "--porcelain", "-z"],
|
|
248
|
+
cwd,
|
|
249
|
+
signal,
|
|
250
|
+
GIT_TIMEOUT_MS,
|
|
251
|
+
GIT_INVENTORY_MAX_BYTES,
|
|
252
|
+
);
|
|
246
253
|
return parseWorktreePorcelain(result.stdout);
|
|
247
254
|
}
|
|
248
255
|
|
|
@@ -313,7 +320,7 @@ export async function addWorktree(
|
|
|
313
320
|
input: AddArguments,
|
|
314
321
|
signal?: AbortSignal,
|
|
315
322
|
): Promise<void> {
|
|
316
|
-
await
|
|
323
|
+
await runGitMutation(pi, buildAddArguments(input), cwd, signal);
|
|
317
324
|
}
|
|
318
325
|
|
|
319
326
|
export async function moveWorktree(
|
|
@@ -323,7 +330,7 @@ export async function moveWorktree(
|
|
|
323
330
|
newPath: string,
|
|
324
331
|
signal?: AbortSignal,
|
|
325
332
|
): Promise<void> {
|
|
326
|
-
await
|
|
333
|
+
await runGitMutation(pi, ["worktree", "move", path, newPath], cwd, signal);
|
|
327
334
|
}
|
|
328
335
|
|
|
329
336
|
export async function removeWorktree(
|
|
@@ -332,7 +339,7 @@ export async function removeWorktree(
|
|
|
332
339
|
path: string,
|
|
333
340
|
signal?: AbortSignal,
|
|
334
341
|
): Promise<void> {
|
|
335
|
-
await
|
|
342
|
+
await runGitMutation(pi, ["worktree", "remove", path], cwd, signal);
|
|
336
343
|
}
|
|
337
344
|
|
|
338
345
|
async function waitForMutationLock(signal?: AbortSignal): Promise<void> {
|
|
@@ -541,6 +548,7 @@ export async function removeWorktreeMetadata(
|
|
|
541
548
|
cwd,
|
|
542
549
|
signal,
|
|
543
550
|
GIT_MUTATION_TIMEOUT_MS,
|
|
551
|
+
GIT_INVENTORY_MAX_BYTES,
|
|
544
552
|
);
|
|
545
553
|
const previewEntries = prunePreviewEntries(`${preview.stdout}\n${preview.stderr}`);
|
|
546
554
|
if (
|
|
@@ -554,7 +562,7 @@ export async function removeWorktreeMetadata(
|
|
|
554
562
|
} else {
|
|
555
563
|
signal?.throwIfAborted();
|
|
556
564
|
try {
|
|
557
|
-
await
|
|
565
|
+
await runGitMutation(pi, ["worktree", "prune", "--expire", "now"], cwd, signal);
|
|
558
566
|
} catch (error) {
|
|
559
567
|
// An interrupted Git process may already have deregistered the tree.
|
|
560
568
|
throw new WorktreeRecoveryError(
|
|
@@ -597,10 +605,17 @@ export async function worktreeInventory(
|
|
|
597
605
|
"--ignored=matching",
|
|
598
606
|
"--ignore-submodules=none",
|
|
599
607
|
];
|
|
600
|
-
const status = await runGit(pi, statusArgs, path, signal);
|
|
601
|
-
const indexFlags = await runGit(pi, ["ls-files", "-v", "-z"], path, signal);
|
|
608
|
+
const status = await runGit(pi, statusArgs, path, signal, GIT_TIMEOUT_MS, GIT_INVENTORY_MAX_BYTES);
|
|
609
|
+
const indexFlags = await runGit(pi, ["ls-files", "-v", "-z"], path, signal, GIT_TIMEOUT_MS, GIT_INVENTORY_MAX_BYTES);
|
|
602
610
|
const indexInventory = await indexFlagInventory(pi, indexFlags.stdout, path, signal);
|
|
603
|
-
const submoduleStatus = await runGit(
|
|
611
|
+
const submoduleStatus = await runGit(
|
|
612
|
+
pi,
|
|
613
|
+
["submodule", "status", "--recursive"],
|
|
614
|
+
path,
|
|
615
|
+
signal,
|
|
616
|
+
GIT_TIMEOUT_MS,
|
|
617
|
+
GIT_INVENTORY_MAX_BYTES,
|
|
618
|
+
);
|
|
604
619
|
const initializedSubmodules = nonEmptyLines(submoduleStatus.stdout)
|
|
605
620
|
.filter((line) => !line.startsWith("-"))
|
|
606
621
|
.map((line) => `initialized submodule: ${line.slice(1).trimStart()}`);
|
|
@@ -615,6 +630,8 @@ export async function worktreeInventory(
|
|
|
615
630
|
],
|
|
616
631
|
path,
|
|
617
632
|
signal,
|
|
633
|
+
GIT_TIMEOUT_MS,
|
|
634
|
+
GIT_INVENTORY_MAX_BYTES,
|
|
618
635
|
);
|
|
619
636
|
return [
|
|
620
637
|
...nonEmptyLines(status.stdout),
|
|
@@ -646,7 +663,14 @@ export async function worktreeStatusOutput(
|
|
|
646
663
|
//
|
|
647
664
|
// `-z` so a path containing a newline or a quote is read exactly as git wrote
|
|
648
665
|
// it, rather than through the quoting v1 output would need.
|
|
649
|
-
const result = await runGit(
|
|
666
|
+
const result = await runGit(
|
|
667
|
+
pi,
|
|
668
|
+
["status", "--porcelain=v2", "--untracked-files=all", "-z"],
|
|
669
|
+
path,
|
|
670
|
+
signal,
|
|
671
|
+
GIT_TIMEOUT_MS,
|
|
672
|
+
GIT_INVENTORY_MAX_BYTES,
|
|
673
|
+
);
|
|
650
674
|
return result.stdout;
|
|
651
675
|
}
|
|
652
676
|
|
|
@@ -675,9 +699,18 @@ export async function administrativeHistoryOids(
|
|
|
675
699
|
[gitDirArgument, "for-each-ref", "--format=%(objectname)", "refs/worktree", "refs/rewritten", "refs/bisect"],
|
|
676
700
|
cwd,
|
|
677
701
|
signal,
|
|
702
|
+
GIT_TIMEOUT_MS,
|
|
703
|
+
GIT_INVENTORY_MAX_BYTES,
|
|
678
704
|
);
|
|
679
705
|
values.push(...splitAdministrativeOids(refs.stdout, "per-worktree refs"));
|
|
680
|
-
const reflogs = await runGit(
|
|
706
|
+
const reflogs = await runGit(
|
|
707
|
+
pi,
|
|
708
|
+
[gitDirArgument, "reflog", "--all", "--format=%H"],
|
|
709
|
+
cwd,
|
|
710
|
+
signal,
|
|
711
|
+
GIT_TIMEOUT_MS,
|
|
712
|
+
GIT_INVENTORY_MAX_BYTES,
|
|
713
|
+
);
|
|
681
714
|
values.push(...splitAdministrativeOids(reflogs.stdout, "Git reflogs"));
|
|
682
715
|
|
|
683
716
|
for (const name of ["ORIG_HEAD", "MERGE_HEAD", "REBASE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "BISECT_HEAD"]) {
|
|
@@ -988,12 +1021,21 @@ export async function durableRefsContaining(
|
|
|
988
1021
|
["for-each-ref", "--format=%(refname)", `--contains=${head}`, "refs/heads", "refs/tags", "refs/remotes"],
|
|
989
1022
|
cwd,
|
|
990
1023
|
signal,
|
|
1024
|
+
GIT_TIMEOUT_MS,
|
|
1025
|
+
GIT_INVENTORY_MAX_BYTES,
|
|
991
1026
|
);
|
|
992
1027
|
return nonEmptyLines(result.stdout);
|
|
993
1028
|
}
|
|
994
1029
|
|
|
995
1030
|
export async function prunePreview(pi: Pick<ExtensionAPI, "exec">, cwd: string, signal?: AbortSignal): Promise<string> {
|
|
996
|
-
const result = await runGit(
|
|
1031
|
+
const result = await runGit(
|
|
1032
|
+
pi,
|
|
1033
|
+
["worktree", "prune", "--dry-run", "--verbose"],
|
|
1034
|
+
cwd,
|
|
1035
|
+
signal,
|
|
1036
|
+
GIT_TIMEOUT_MS,
|
|
1037
|
+
GIT_INVENTORY_MAX_BYTES,
|
|
1038
|
+
);
|
|
997
1039
|
return combineOutput(result);
|
|
998
1040
|
}
|
|
999
1041
|
|
|
@@ -1021,7 +1063,7 @@ export async function pruneWorktrees(
|
|
|
1021
1063
|
if (approvedCandidates?.length || hasAdministrativeWorktrees(cwd) || existsSync(worktreeMutationLockKey(cwd))) {
|
|
1022
1064
|
throw new GitWorktreeError("No approved Git administrative records are available to prune.");
|
|
1023
1065
|
}
|
|
1024
|
-
const result = await
|
|
1066
|
+
const result = await runGitMutation(pi, ["worktree", "prune", "--verbose"], cwd, signal);
|
|
1025
1067
|
return combineOutput(result);
|
|
1026
1068
|
}
|
|
1027
1069
|
for (const candidate of candidates) await removeAdministrativeRecord(candidate, signal);
|
|
@@ -1100,8 +1142,9 @@ async function runGit(
|
|
|
1100
1142
|
cwd: string,
|
|
1101
1143
|
signal?: AbortSignal,
|
|
1102
1144
|
timeout = GIT_TIMEOUT_MS,
|
|
1145
|
+
maxBuffer = GIT_METADATA_MAX_BYTES,
|
|
1103
1146
|
): Promise<ExecResult> {
|
|
1104
|
-
const result = await runGitAllowFailure(pi, args, cwd, signal, timeout);
|
|
1147
|
+
const result = await runGitAllowFailure(pi, args, cwd, signal, timeout, maxBuffer);
|
|
1105
1148
|
if (result.killed) throw killedError(args);
|
|
1106
1149
|
if (result.code !== 0) throw gitFailure(args, result);
|
|
1107
1150
|
return result;
|
|
@@ -1113,13 +1156,25 @@ async function runGitAllowFailure(
|
|
|
1113
1156
|
cwd: string,
|
|
1114
1157
|
signal?: AbortSignal,
|
|
1115
1158
|
timeout = GIT_TIMEOUT_MS,
|
|
1159
|
+
maxBuffer = GIT_METADATA_MAX_BYTES,
|
|
1160
|
+
mutation = false,
|
|
1116
1161
|
): Promise<ExecResult> {
|
|
1117
1162
|
try {
|
|
1118
1163
|
signal?.throwIfAborted();
|
|
1119
|
-
const result = await pi.exec("git", args, { cwd, signal, timeout });
|
|
1164
|
+
const result = await pi.exec("git", args, { cwd, signal, timeout, maxBuffer });
|
|
1165
|
+
// Check before cancellation/nonzero handling: overflow is not an expected Git failure.
|
|
1166
|
+
if (result.truncated) {
|
|
1167
|
+
if (mutation) {
|
|
1168
|
+
throw new WorktreeRecoveryError(
|
|
1169
|
+
`git ${args.slice(0, 2).join(" ")} output exceeded ${maxBuffer} bytes; mutation outcome may already be committed. Inspect git worktree list and affected paths before retrying; do not automatically roll back.`,
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
throw outputLimitError(args, maxBuffer);
|
|
1173
|
+
}
|
|
1120
1174
|
signal?.throwIfAborted();
|
|
1121
1175
|
return result;
|
|
1122
1176
|
} catch (error) {
|
|
1177
|
+
if (error instanceof GitWorktreeError) throw error;
|
|
1123
1178
|
const message = formatError(error);
|
|
1124
1179
|
if (/\bENOENT\b|not found/i.test(message)) {
|
|
1125
1180
|
throw new GitWorktreeError("Git executable was not found. Install Git and retry.", args);
|
|
@@ -1128,6 +1183,27 @@ async function runGitAllowFailure(
|
|
|
1128
1183
|
}
|
|
1129
1184
|
}
|
|
1130
1185
|
|
|
1186
|
+
function outputLimitError(args: string[], maxBuffer: number): GitWorktreeError {
|
|
1187
|
+
return new GitWorktreeError(
|
|
1188
|
+
`git ${args.slice(0, 2).join(" ")} output exceeded ${maxBuffer} bytes; result is incomplete.`,
|
|
1189
|
+
args,
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
async function runGitMutation(
|
|
1194
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1195
|
+
args: string[],
|
|
1196
|
+
cwd: string,
|
|
1197
|
+
signal?: AbortSignal,
|
|
1198
|
+
): Promise<ExecResult> {
|
|
1199
|
+
// Unlike read-only overflow, a noisy mutation may already have committed.
|
|
1200
|
+
// Never authorize automatic rollback from its incomplete diagnostics.
|
|
1201
|
+
const result = await runGitAllowFailure(pi, args, cwd, signal, GIT_MUTATION_TIMEOUT_MS, GIT_MUTATION_MAX_BYTES, true);
|
|
1202
|
+
if (result.killed) throw killedError(args);
|
|
1203
|
+
if (result.code !== 0) throw gitFailure(args, result);
|
|
1204
|
+
return result;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1131
1207
|
function gitFailure(args: string[], result: ExecResult): GitWorktreeError {
|
|
1132
1208
|
const detail = stripTerminalControls([result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n"));
|
|
1133
1209
|
const hint = /not a git repository/i.test(detail)
|
|
@@ -1225,16 +1301,22 @@ function runGitWithInputAllowFailure(
|
|
|
1225
1301
|
timeout = GIT_TIMEOUT_MS,
|
|
1226
1302
|
): Promise<ExecResult> {
|
|
1227
1303
|
if (signal?.aborted) {
|
|
1228
|
-
return Promise.resolve({ stdout: "", stderr: "", code: 1, killed: true });
|
|
1304
|
+
return Promise.resolve({ stdout: "", stderr: "", code: 1, killed: true, truncated: false });
|
|
1229
1305
|
}
|
|
1230
1306
|
// ExtensionAPI.exec has no stdin channel. This local argv-only owner bounds
|
|
1231
1307
|
// both cancellation and timeout cleanup without changing native Git settings.
|
|
1232
|
-
return runGitInput(args, cwd, input, signal, timeout
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1308
|
+
return runGitInput(args, cwd, input, signal, timeout, GIT_INVENTORY_MAX_BYTES)
|
|
1309
|
+
.then((result) => {
|
|
1310
|
+
if (result.truncated) throw outputLimitError(args, GIT_INVENTORY_MAX_BYTES);
|
|
1311
|
+
return result;
|
|
1312
|
+
})
|
|
1313
|
+
.catch((error: unknown) => {
|
|
1314
|
+
if (error instanceof GitWorktreeError) throw error;
|
|
1315
|
+
const message = formatError(error);
|
|
1316
|
+
throw /\bENOENT\b|not found/i.test(message)
|
|
1317
|
+
? new GitWorktreeError("Git executable was not found. Install Git and retry.", args, error)
|
|
1318
|
+
: new GitWorktreeError(`git ${args.slice(0, 2).join(" ")}: ${message}`, args, error);
|
|
1319
|
+
});
|
|
1238
1320
|
}
|
|
1239
1321
|
|
|
1240
1322
|
function nulSeparatedPaths(value: string, source: string): string[] {
|
package/src/safe-remove.ts
CHANGED
|
@@ -323,7 +323,11 @@ export async function removeWorktreeSafely(
|
|
|
323
323
|
signal?.throwIfAborted();
|
|
324
324
|
await removeSnapshot(tombstonePath, snapshot, beforeDeleteEntry);
|
|
325
325
|
} catch (error: unknown) {
|
|
326
|
-
if (!tombstonePath && !metadataRemoved) {
|
|
326
|
+
if (!tombstonePath && !metadataRemoved && error instanceof WorktreeRecoveryError) {
|
|
327
|
+
// Mutation output overflow cannot prove whether Git committed the move.
|
|
328
|
+
// Filesystem presence alone cannot authorize another mutation as rollback.
|
|
329
|
+
moveOutcomeUnknown = true;
|
|
330
|
+
} else if (!tombstonePath && !metadataRemoved) {
|
|
327
331
|
try {
|
|
328
332
|
const outcome = await classifyMoveOutcome(path, quarantinePath);
|
|
329
333
|
if (outcome === "moved") moved = true;
|