@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 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.4.6",
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.4.0",
43
- "@earendil-works/pi-tui": "1.4.0",
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.54.0",
63
- "@signalridge/pi-ui": "^1.4.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
- const output = (chunk: string) => {
155
- stdout += chunk;
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: string) => {
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
- { stdout, stderr, code: code ?? 1, killed: stopping || closeSignal !== null },
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
- interface GitExecOptions {
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(pi, ["worktree", "list", "--porcelain", "-z"], cwd, signal);
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 runGit(pi, buildAddArguments(input), cwd, signal, GIT_MUTATION_TIMEOUT_MS);
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 runGit(pi, ["worktree", "move", path, newPath], cwd, signal, GIT_MUTATION_TIMEOUT_MS);
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 runGit(pi, ["worktree", "remove", path], cwd, signal, GIT_MUTATION_TIMEOUT_MS);
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 runGit(pi, ["worktree", "prune", "--expire", "now"], cwd, signal, GIT_MUTATION_TIMEOUT_MS);
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(pi, ["submodule", "status", "--recursive"], path, signal);
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(pi, ["status", "--porcelain=v2", "--untracked-files=all", "-z"], path, signal);
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(pi, [gitDirArgument, "reflog", "--all", "--format=%H"], cwd, signal);
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(pi, ["worktree", "prune", "--dry-run", "--verbose"], cwd, signal);
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 runGit(pi, ["worktree", "prune", "--verbose"], cwd, signal, GIT_MUTATION_TIMEOUT_MS);
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).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);
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[] {
@@ -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;