pi-jar 0.1.6 → 0.1.8

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/src/changes.ts CHANGED
@@ -6,6 +6,8 @@ export const MAX_TRACKED_BYTES = 1024 * 1024;
6
6
  export const MAX_TRACKED_FILES = 200;
7
7
  /** Bound total retained baselines so a long editing session cannot pin hundreds of MiB. */
8
8
  export const MAX_TRACKED_TOTAL_BYTES = 8 * 1024 * 1024;
9
+ /** Explicit full-review choice supports bounded matrix-free previews beyond the exact-diff budget. */
10
+ export const MAX_REVIEW_DIFF_LINES = 100_000;
9
11
  /** Keep the quadratic fallback small; larger edits render a summary instead of allocating a huge matrix. */
10
12
  const MAX_DIFF_CELLS = 500_000;
11
13
  const MAX_DIFF_MIDDLE_LINES = 4_000;
@@ -13,7 +15,11 @@ const MAX_DIFF_MIDDLE_LINES = 4_000;
13
15
  export type ChangeStatus = "added" | "modified" | "deleted";
14
16
  export interface FileChange { path: string; rel: string; status: ChangeStatus; before: string; after: string; added: number; removed: number; diff?: DiffOp[] | null }
15
17
  export type DiffOp = { op: " " | "+" | "-"; text: string };
16
- export type DiffRow = { kind: "hunk" | "context" | "add" | "remove" | "note"; text: string; oldLine?: number; newLine?: number };
18
+ /** `hidden` counts unchanged lines skipped before a hunk; `pair` is the aligned replacement line for intraline diffs. */
19
+ export type DiffRow = { kind: "hunk" | "context" | "add" | "remove" | "note"; text: string; oldLine?: number; newLine?: number; hidden?: number; pair?: string };
20
+ export type SplitSide = { line: number; text: string; changed: boolean };
21
+ /** One aligned Original/Updated row: a replacement run pairs its k-th removed and k-th added line. */
22
+ export type SplitRow = { kind: "hunk"; text: string; hidden: number } | { kind: "pair"; left?: SplitSide; right?: SplitSide };
17
23
 
18
24
  /** Text content, `null` when the file is missing, or `undefined` when it cannot be tracked. */
19
25
  function readText(path: string): string | null | undefined {
@@ -27,6 +33,29 @@ function readText(path: string): string | null | undefined {
27
33
 
28
34
  const splitLines = (text: string) => text === "" ? [] : text.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n");
29
35
 
36
+ export function diffLineCount(text: string): number {
37
+ if (!text) return 0;
38
+ let count = 1;
39
+ for (let index = 0; index < text.length; index++) if (text.charCodeAt(index) === 10) count++;
40
+ if (text.endsWith("\n")) count--;
41
+ return count;
42
+ }
43
+
44
+ /** Safe O(n) fallback that preserves common prefix/suffix and clearly marks coarse replacements. */
45
+ export function coarseLineDiff(before: string, after: string): DiffOp[] {
46
+ const a = splitLines(before), b = splitLines(after);
47
+ let start = 0;
48
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
49
+ let endA = a.length, endB = b.length;
50
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
51
+ return [
52
+ ...a.slice(0, start).map(text => ({ op: " " as const, text })),
53
+ ...a.slice(start, endA).map(text => ({ op: "-" as const, text })),
54
+ ...b.slice(start, endB).map(text => ({ op: "+" as const, text })),
55
+ ...a.slice(endA).map(text => ({ op: " " as const, text }))
56
+ ];
57
+ }
58
+
30
59
  /** Line diff: trims the common prefix/suffix, then an LCS over the changed middle. */
31
60
  export function lineDiff(before: string, after: string): DiffOp[] | undefined {
32
61
  const a = splitLines(before), b = splitLines(after);
@@ -57,38 +86,220 @@ export function lineDiff(before: string, after: string): DiffOp[] | undefined {
57
86
 
58
87
  /** Unified hunks with `context` lines around each change. */
59
88
  export function diffRows(ops: readonly DiffOp[], context = 3): DiffRow[] {
60
- const rows: DiffRow[] = [];
61
- const changed = ops.map((op) => op.op !== " ");
62
- let oldLine = 1, newLine = 1;
63
- const positions = ops.map((op) => {
64
- const at = { oldLine, newLine };
65
- if (op.op !== "+") oldLine++;
66
- if (op.op !== "-") newLine++;
67
- return at;
68
- });
69
- let index = 0;
89
+ return diffRowsWindow(ops, context, 0, Number.MAX_SAFE_INTEGER);
90
+ }
91
+
92
+ const linePositionCache = new WeakMap<readonly DiffOp[], { old: Uint32Array; next: Uint32Array }>();
93
+
94
+ type DiffHunk = { from: number; end: number; offset: number };
95
+ const hunkCache = new WeakMap<readonly DiffOp[], { context: number; offsets: number[]; rows: number; hunks: DiffHunk[] }>();
96
+
97
+ /** Logical rendered row offsets for hunk navigation, without materializing unchanged rows. */
98
+ export function diffHunkOffsets(ops: readonly DiffOp[], context = 3): number[] {
99
+ const cached = hunkCache.get(ops);
100
+ if (cached?.context === context) return cached.offsets.slice();
101
+ const offsets: number[] = [];
102
+ const hunks: DiffHunk[] = [];
103
+ let rendered = 0, index = 0;
70
104
  while (index < ops.length) {
71
- if (!changed[index]) { index++; continue; }
105
+ if (ops[index]!.op === " ") { index++; continue; }
72
106
  const from = Math.max(0, index - context);
73
107
  let to = index;
74
- // Extend the hunk while the next change is within 2 × context lines.
75
108
  while (to < ops.length) {
76
109
  let next = to + 1;
77
- while (next < ops.length && !changed[next]) next++;
78
- if (next < ops.length && next - to <= context * 2) { to = next; continue; }
110
+ while (next < ops.length && ops[next]!.op === " ") next++;
111
+ // Merge when the unchanged gap fits inside both hunks' context (adjacent changes at context 0).
112
+ if (next < ops.length && next - to - 1 <= context * 2) { to = next; continue; }
79
113
  break;
80
114
  }
81
115
  const end = Math.min(ops.length, to + context + 1);
82
- const slice = ops.slice(from, end);
83
- const oldCount = slice.filter((op) => op.op !== "+").length;
84
- const newCount = slice.filter((op) => op.op !== "-").length;
85
- rows.push({ kind: "hunk", text: `@@ -${positions[from]!.oldLine},${oldCount} +${positions[from]!.newLine},${newCount} @@` });
86
- for (let at = from; at < end; at++) {
116
+ offsets.push(rendered);
117
+ hunks.push({ from, end, offset: rendered });
118
+ rendered += 1 + end - from;
119
+ index = end;
120
+ }
121
+ hunkCache.set(ops, { context, offsets, rows: rendered, hunks });
122
+ return offsets.slice();
123
+ }
124
+
125
+ export function diffRowCount(ops: readonly DiffOp[], context = 3): number {
126
+ if (hunkCache.get(ops)?.context !== context) diffHunkOffsets(ops, context);
127
+ return hunkCache.get(ops)!.rows;
128
+ }
129
+
130
+ const linePositions = (ops: readonly DiffOp[]) => {
131
+ let positions = linePositionCache.get(ops);
132
+ if (!positions) {
133
+ const old = new Uint32Array(ops.length + 1), next = new Uint32Array(ops.length + 1);
134
+ let oldLine = 1, newLine = 1;
135
+ for (let at = 0; at < ops.length; at++) {
136
+ const op = ops[at]!;
137
+ old[at] = oldLine; next[at] = newLine;
138
+ if (op.op !== "+") oldLine++;
139
+ if (op.op !== "-") newLine++;
140
+ }
141
+ old[ops.length] = oldLine;
142
+ next[ops.length] = newLine;
143
+ positions = { old, next };
144
+ linePositionCache.set(ops, positions);
145
+ }
146
+ return positions;
147
+ };
148
+
149
+ /**
150
+ * Replacement runs (maximal non-context spans) as flat typed arrays, independent of context:
151
+ * `order[start..start+removes)` lists the run's removed ops and the rest its added ops, so the
152
+ * k-th removal pairs with the k-th addition. `split` prefixes aligned rows (a run takes max(r, a)).
153
+ */
154
+ type RunLayout = { start: Uint32Array; removes: Uint32Array; length: Uint32Array; order: Uint32Array; ordinal: Uint32Array; split: Uint32Array };
155
+ const runCache = new WeakMap<readonly DiffOp[], RunLayout>();
156
+
157
+ const runLayout = (ops: readonly DiffOp[]): RunLayout => {
158
+ const cached = runCache.get(ops);
159
+ if (cached) return cached;
160
+ const n = ops.length;
161
+ const layout: RunLayout = { start: new Uint32Array(n), removes: new Uint32Array(n), length: new Uint32Array(n),
162
+ order: new Uint32Array(n), ordinal: new Uint32Array(n), split: new Uint32Array(n + 1) };
163
+ const { start, removes, length, order, ordinal, split } = layout;
164
+ for (let at = 0; at < n;) {
165
+ if (ops[at]!.op === " ") { order[at] = at; split[at + 1] = split[at]! + 1; at++; continue; }
166
+ let end = at;
167
+ while (end < n && ops[end]!.op !== " ") end++;
168
+ let r = 0, a = 0;
169
+ for (let i = at; i < end; i++) if (ops[i]!.op === "-") { ordinal[i] = r; order[at + r++] = i; }
170
+ for (let i = at; i < end; i++) if (ops[i]!.op === "+") { ordinal[i] = a; order[at + r + a++] = i; }
171
+ for (let i = at; i < end; i++) { start[i] = at; removes[i] = r; length[i] = end - at; split[i + 1] = split[at]!; }
172
+ split[at + 1] = split[at]! + Math.max(r, a);
173
+ for (let i = at + 1; i < end; i++) split[i + 1] = split[at + 1]!;
174
+ at = end;
175
+ }
176
+ runCache.set(ops, layout);
177
+ return layout;
178
+ };
179
+
180
+ /** The aligned counterpart of a changed op (k-th removal ↔ k-th addition in its run), if any. */
181
+ const partner = (ops: readonly DiffOp[], at: number): number | undefined => {
182
+ const { start, removes, length, order, ordinal } = runLayout(ops);
183
+ const s = start[at]!, r = removes[at]!, a = length[at]! - r, k = ordinal[at]!;
184
+ if (ops[at]!.op === "-") return k < a ? order[s + r + k] : undefined;
185
+ return k < r ? order[s + k] : undefined;
186
+ };
187
+
188
+ /** Compute only the requested output window; diff operations stay indexed without a second full row array. */
189
+ export function diffRowsWindow(ops: readonly DiffOp[], context = 3, offset = 0, limit = Number.MAX_SAFE_INTEGER): DiffRow[] {
190
+ const rows: DiffRow[] = [];
191
+ const firstRow = Math.max(0, offset);
192
+ const lastRow = firstRow + Math.max(0, limit);
193
+ if (hunkCache.get(ops)?.context !== context) diffHunkOffsets(ops, context);
194
+ const layout = hunkCache.get(ops)!;
195
+ if (firstRow >= layout.rows || lastRow <= firstRow) return rows;
196
+ const positions = linePositions(ops);
197
+ // Binary-search the first intersecting hunk, then visit only visible operations. In
198
+ // particular, a 100k-line replacement must not slice/filter/allocate 100k rows per frame.
199
+ let lo = 0, hi = layout.hunks.length;
200
+ while (lo < hi) {
201
+ const mid = (lo + hi) >>> 1;
202
+ const hunk = layout.hunks[mid]!;
203
+ if (hunk.offset + 1 + hunk.end - hunk.from <= firstRow) lo = mid + 1;
204
+ else hi = mid;
205
+ }
206
+ for (let index = lo; index < layout.hunks.length; index++) {
207
+ const { from, end, offset: start } = layout.hunks[index]!;
208
+ if (start >= lastRow) break;
209
+ if (start >= firstRow) rows.push({ kind: "hunk", text: hunkHeader(positions, from, end), hidden: from - (layout.hunks[index - 1]?.end ?? 0) });
210
+ const visibleFrom = from + Math.max(0, firstRow - start - 1);
211
+ const visibleEnd = Math.min(end, from + lastRow - start - 1);
212
+ for (let at = visibleFrom; at < visibleEnd; at++) {
87
213
  const op = ops[at]!;
214
+ const pair = op.op === " " ? undefined : partner(ops, at);
88
215
  rows.push({ kind: op.op === "+" ? "add" : op.op === "-" ? "remove" : "context", text: op.text,
89
- ...(op.op !== "+" ? { oldLine: positions[at]!.oldLine } : {}), ...(op.op !== "-" ? { newLine: positions[at]!.newLine } : {}) });
216
+ ...(op.op !== "+" ? { oldLine: positions.old[at]! } : {}), ...(op.op !== "-" ? { newLine: positions.next[at]! } : {}),
217
+ ...(pair !== undefined ? { pair: ops[pair]!.text } : {}) });
218
+ }
219
+ }
220
+ return rows;
221
+ }
222
+
223
+ /** Git convention: an empty side (pure insertion/deletion) names the line before the change. */
224
+ const hunkHeader = (positions: { old: Uint32Array; next: Uint32Array }, from: number, end: number) => {
225
+ const range = (lines: Uint32Array) => {
226
+ const count = lines[end]! - lines[from]!;
227
+ return `${count ? lines[from]! : lines[from]! - 1},${count}`;
228
+ };
229
+ return `@@ -${range(positions.old)} +${range(positions.next)} @@`;
230
+ };
231
+
232
+ type SplitHunk = DiffHunk & { split: number };
233
+ const splitCache = new WeakMap<readonly DiffOp[], { context: number; hunks: SplitHunk[]; offsets: number[]; rows: number }>();
234
+
235
+ const splitLayout = (ops: readonly DiffOp[], context: number) => {
236
+ const cached = splitCache.get(ops);
237
+ if (cached?.context === context) return cached;
238
+ if (hunkCache.get(ops)?.context !== context) diffHunkOffsets(ops, context);
239
+ const { split } = runLayout(ops);
240
+ const hunks: SplitHunk[] = [];
241
+ let rows = 0;
242
+ // Hunks start and end on context ops, so a replacement run never straddles two hunks.
243
+ for (const hunk of hunkCache.get(ops)!.hunks) {
244
+ hunks.push({ ...hunk, split: rows });
245
+ rows += 1 + split[hunk.end]! - split[hunk.from]!;
246
+ }
247
+ const layout = { context, hunks, offsets: hunks.map(hunk => hunk.split), rows };
248
+ splitCache.set(ops, layout);
249
+ return layout;
250
+ };
251
+
252
+ /** Aligned side-by-side row offsets of each hunk header. */
253
+ export function diffSplitHunkOffsets(ops: readonly DiffOp[], context = 3): number[] {
254
+ return splitLayout(ops, context).offsets.slice();
255
+ }
256
+
257
+ export function diffSplitRowCount(ops: readonly DiffOp[], context = 3): number {
258
+ return splitLayout(ops, context).rows;
259
+ }
260
+
261
+ /** Window of aligned Original/Updated rows; like `diffRowsWindow`, it visits only visible operations. */
262
+ export function diffSplitRowsWindow(ops: readonly DiffOp[], context = 3, offset = 0, limit = Number.MAX_SAFE_INTEGER): SplitRow[] {
263
+ const rows: SplitRow[] = [];
264
+ const firstRow = Math.max(0, offset);
265
+ const lastRow = firstRow + Math.max(0, limit);
266
+ const layout = splitLayout(ops, context);
267
+ if (firstRow >= layout.rows || lastRow <= firstRow) return rows;
268
+ const positions = linePositions(ops);
269
+ const { split, order, removes, length } = runLayout(ops);
270
+ const size = (hunk: SplitHunk) => 1 + split[hunk.end]! - split[hunk.from]!;
271
+ let lo = 0, hi = layout.hunks.length;
272
+ while (lo < hi) {
273
+ const mid = (lo + hi) >>> 1;
274
+ if (layout.hunks[mid]!.split + size(layout.hunks[mid]!) <= firstRow) lo = mid + 1;
275
+ else hi = mid;
276
+ }
277
+ for (let index = lo; index < layout.hunks.length; index++) {
278
+ const hunk = layout.hunks[index]!;
279
+ if (hunk.split >= lastRow) break;
280
+ if (hunk.split >= firstRow) rows.push({ kind: "hunk", text: hunkHeader(positions, hunk.from, hunk.end), hidden: hunk.from - (layout.hunks[index - 1]?.end ?? 0) });
281
+ const visibleFrom = Math.max(0, firstRow - hunk.split - 1);
282
+ const visibleEnd = Math.min(size(hunk) - 1, lastRow - hunk.split - 1);
283
+ for (let row = visibleFrom; row < visibleEnd; row++) {
284
+ const target = split[hunk.from]! + row;
285
+ // First op whose aligned-row interval contains target; only run starts carry run rows.
286
+ let low = hunk.from, high = hunk.end - 1;
287
+ while (low < high) {
288
+ const mid = (low + high) >>> 1;
289
+ if (split[mid + 1]! > target) high = mid; else low = mid + 1;
290
+ }
291
+ const op = ops[low]!;
292
+ if (op.op === " ") {
293
+ rows.push({ kind: "pair", left: { line: positions.old[low]!, text: op.text, changed: false }, right: { line: positions.next[low]!, text: op.text, changed: false } });
294
+ continue;
295
+ }
296
+ const k = target - split[low]!, r = removes[low]!, a = length[low]! - r;
297
+ const removed = k < r ? order[low + k]! : undefined;
298
+ const added = k < a ? order[low + r + k]! : undefined;
299
+ rows.push({ kind: "pair",
300
+ ...(removed !== undefined ? { left: { line: positions.old[removed]!, text: ops[removed]!.text, changed: true } } : {}),
301
+ ...(added !== undefined ? { right: { line: positions.next[added]!, text: ops[added]!.text, changed: true } } : {}) });
90
302
  }
91
- index = end;
92
303
  }
93
304
  return rows;
94
305
  }
@@ -174,13 +385,9 @@ export class ChangeTracker {
174
385
  if (now === undefined) { this.drop(path); continue; }
175
386
  if (now === before) { this.dirty.delete(path); continue; }
176
387
  const ops = lineDiff(before ?? "", now ?? "");
388
+ const summaryOps = ops ?? coarseLineDiff(before ?? "", now ?? "");
177
389
  let added = 0, removed = 0;
178
- if (ops) {
179
- for (const op of ops) { if (op.op === "+") added++; else if (op.op === "-") removed++; }
180
- } else {
181
- added = splitLines(now ?? "").length;
182
- removed = splitLines(before ?? "").length;
183
- }
390
+ for (const op of summaryOps) { if (op.op === "+") added++; else if (op.op === "-") removed++; }
184
391
  const rel = relative(this.cwd(), path);
185
392
  const change: FileChange = {
186
393
  path, rel: rel && !rel.startsWith("..") ? rel : path,
package/src/commit.ts CHANGED
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import type { ModelRoleManager } from "./model-roles.ts";
6
6
  import { askRole, type SideUsage } from "./side-model.ts";
7
+ import { runProcess } from "./async-process.ts";
7
8
 
8
9
  const DIFF_LIMIT = 60_000;
9
10
 
@@ -27,13 +28,11 @@ export function commitPrompt(stat: string, diff: string, log: string, hint: stri
27
28
  }
28
29
 
29
30
  /** `/jar commit [note]`: draft a message for the staged changes with the commit role, review it, commit. Never pushes. */
30
- export async function jarCommit(pi: ExtensionAPI, ctx: ExtensionContext, roles: ModelRoleManager, usage: SideUsage | undefined, hint = ""): Promise<void> {
31
+ export async function jarCommit(_pi: ExtensionAPI, ctx: ExtensionContext, roles: ModelRoleManager, usage: SideUsage | undefined, hint = ""): Promise<void> {
31
32
  if (!ctx.hasUI) { ctx.ui.notify("/jar commit needs the interactive UI to review the message", "warning"); return; }
32
- const git = async (...args: string[]) => {
33
- const result = await pi.exec("git", args, { cwd: ctx.cwd, timeout: 20_000 });
34
- if (result.code !== 0) throw new Error(`git ${args[0]}: ${(result.stderr || result.stdout).trim() || "exit " + result.code}`);
35
- return result.stdout;
36
- };
33
+ const git = async (...args: string[]) => (await runProcess(process.env.PI_JAR_GIT_PATH?.trim() || "git", args, {
34
+ cwd: ctx.cwd, signal: ctx.signal, timeoutMs: 20_000, maxOutputBytes: 64 * 1024 * 1024
35
+ })).toString("utf8");
37
36
  try {
38
37
  await git("rev-parse", "--is-inside-work-tree");
39
38
  let stat = await git("diff", "--cached", "--stat");
@@ -1,7 +1,6 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { runProcess } from "./async-process.ts";
2
2
  import {
3
3
  chmodSync,
4
- cpSync,
5
4
  existsSync,
6
5
  lstatSync,
7
6
  mkdirSync,
@@ -13,10 +12,10 @@ import {
13
12
  renameSync,
14
13
  rmdirSync,
15
14
  writeFileSync,
16
- symlinkSync,
17
15
  unlinkSync,
18
16
  type Stats
19
17
  } from "node:fs";
18
+ import { chmod, mkdir, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises";
20
19
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
21
20
  import { homedir, tmpdir } from "node:os";
22
21
  import { fileURLToPath } from "node:url";
@@ -25,6 +24,10 @@ import { MAX_TRACKED_BYTES, type ChangeTracker } from "./changes.ts";
25
24
  /** Present only in writable delegated children. The child extension treats this as its filesystem boundary. */
26
25
  export const CHILD_WORKTREE_ENV = "PI_JAR_WORKTREE_ROOT";
27
26
  const MAX_GIT_OUTPUT = 64 * 1024 * 1024;
27
+ const GIT_TIMEOUT_MS = 30_000;
28
+ /** Set PI_JAR_GIT_PATH to bypass wrappers such as git-ai for pi-jar's internal plumbing. */
29
+ export const GIT_EXECUTABLE_ENV = "PI_JAR_GIT_PATH";
30
+ const gitExecutable = () => process.env[GIT_EXECUTABLE_ENV]?.trim() || "git";
28
31
 
29
32
  export interface DelegateWorktree {
30
33
  /** Git repository root in the parent workspace. */
@@ -42,19 +45,14 @@ export interface DelegateWorktree {
42
45
  const safeGitArgs = (cwd: string, args: string[]) => ["-C", cwd, "--literal-pathspecs", "-c", "core.hooksPath=/dev/null",
43
46
  "-c", "core.fsmonitor=false", ...args];
44
47
 
45
- const gitText = (cwd: string, args: string[], input?: string | Buffer): string =>
46
- execFileSync("git", safeGitArgs(cwd, args), {
47
- encoding: "utf8",
48
- ...(input === undefined ? {} : { input }),
49
- maxBuffer: MAX_GIT_OUTPUT,
50
- stdio: ["pipe", "pipe", "pipe"]
51
- });
48
+ const gitText = async (cwd: string, args: string[], input?: string | Buffer, signal?: AbortSignal): Promise<string> =>
49
+ (await runProcess(gitExecutable(), safeGitArgs(cwd, args), {
50
+ cwd, input, signal, timeoutMs: GIT_TIMEOUT_MS, maxOutputBytes: MAX_GIT_OUTPUT
51
+ })).toString("utf8");
52
52
 
53
- const gitBuffer = (cwd: string, args: string[]): Buffer =>
54
- execFileSync("git", safeGitArgs(cwd, args), {
55
- encoding: null,
56
- maxBuffer: MAX_GIT_OUTPUT,
57
- stdio: ["ignore", "pipe", "pipe"]
53
+ const gitBuffer = (cwd: string, args: string[], signal?: AbortSignal): Promise<Buffer> =>
54
+ runProcess(gitExecutable(), safeGitArgs(cwd, args), {
55
+ cwd, signal, timeoutMs: GIT_TIMEOUT_MS, maxOutputBytes: MAX_GIT_OUTPUT
58
56
  });
59
57
 
60
58
  const nulList = (value: string): string[] => value.split("\0").filter(Boolean);
@@ -144,13 +142,41 @@ function canonicalWorkspacePathAllowed(root: string, lexical: string): boolean {
144
142
  } catch { return false; }
145
143
  }
146
144
 
147
- const copyInto = (fromRoot: string, toRoot: string, rel: string): void => {
148
- const from = resolve(fromRoot, rel);
149
- const to = resolve(toRoot, rel);
150
- if (!safeFilePath(fromRoot, rel) || !safeFilePath(toRoot, rel)) throw new Error("unsafe worktree path: " + rel);
151
- mkdirSync(dirname(to), { recursive: true });
152
- if (lstatSync(from).isSymbolicLink()) symlinkSync(readlinkSync(from), to);
153
- else cpSync(from, to, { force: true, dereference: false });
145
+ const writeSnapshot = async (root: string, rel: string, bytes: Buffer, stat: Stats): Promise<void> => {
146
+ const to = resolve(root, rel);
147
+ if (!safeFilePath(root, rel)) throw new Error("unsafe worktree path: " + rel);
148
+ await mkdir(dirname(to), { recursive: true });
149
+ // Use the bytes we hashed, not a second copy of a source the user may edit during the await.
150
+ if (stat.isSymbolicLink()) await symlink(bytes.toString(), to);
151
+ else {
152
+ await writeFile(to, bytes, { mode: stat.mode & 0o777 });
153
+ await chmod(to, stat.mode & 0o777);
154
+ }
155
+ };
156
+
157
+ // Each git call has its own GIT_TIMEOUT_MS deadline, so bound the work per batched call.
158
+ const HASH_BATCH_FILES = 1000;
159
+ const HASH_BATCH_BYTES = 128 * 1024 * 1024;
160
+
161
+ /** Object ids for regular files, in order, from bounded `hash-object --stdin-paths` chunks. */
162
+ const hashPaths = async (root: string, files: readonly { path: string; size: number }[], write: boolean,
163
+ signal?: AbortSignal): Promise<string[]> => {
164
+ const oids: string[] = [];
165
+ for (let start = 0; start < files.length;) {
166
+ let end = start;
167
+ let bytes = 0;
168
+ // A file larger than the byte budget still gets its own chunk, matching per-file hashing.
169
+ while (end < files.length && end - start < HASH_BATCH_FILES && (end === start || bytes + files[end]!.size <= HASH_BATCH_BYTES)) {
170
+ bytes += files[end++]!.size;
171
+ }
172
+ const chunk = files.slice(start, end);
173
+ const output = (await gitText(root, ["hash-object", ...(write ? ["-w"] : []), "--no-filters", "--stdin-paths"],
174
+ chunk.map(file => file.path).join("\n") + "\n", signal)).split("\n").filter(Boolean);
175
+ if (output.length !== chunk.length) throw new Error("git hash-object returned incomplete hashes");
176
+ oids.push(...output);
177
+ start = end;
178
+ }
179
+ return oids;
154
180
  };
155
181
 
156
182
  /**
@@ -158,9 +184,9 @@ const copyInto = (fromRoot: string, toRoot: string, rel: string): void => {
158
184
  * parent's tracked modifications plus non-ignored untracked files. The parent index/branch is not
159
185
  * touched. That baseline lets multiple children run against the exact same starting filesystem.
160
186
  */
161
- export function createDelegateWorktree(cwd: string): DelegateWorktree {
187
+ export async function createDelegateWorktree(cwd: string, signal?: AbortSignal): Promise<DelegateWorktree> {
162
188
  const requested = realpathSync(cwd);
163
- const repoRoot = realpathSync(gitText(requested, ["rev-parse", "--show-toplevel"]).trim());
189
+ const repoRoot = realpathSync((await gitText(requested, ["rev-parse", "--show-toplevel"], undefined, signal)).trim());
164
190
  if (!inside(repoRoot, requested)) throw new Error("working directory is outside the git repository");
165
191
  const relCwd = relative(repoRoot, requested);
166
192
  const tempRoot = realpathSync(mkdtempSync(join(tmpdir(), "pi-jar-worktree-")));
@@ -168,61 +194,104 @@ export function createDelegateWorktree(cwd: string): DelegateWorktree {
168
194
  let added = false;
169
195
  try {
170
196
  // Never checkout/add: they execute clean/smudge filters and alter byte baselines.
171
- gitText(repoRoot, ["worktree", "add", "--no-checkout", "--detach", root, "HEAD"]);
197
+ await gitText(repoRoot, ["worktree", "add", "--no-checkout", "--detach", root, "HEAD"], undefined, signal);
172
198
  added = true;
173
- gitText(root, ["read-tree", "--empty"]);
174
- const paths = new Set(nulList(gitText(repoRoot, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"])));
199
+ await gitText(root, ["read-tree", "--empty"], undefined, signal);
200
+ const paths = new Set(nulList(await gitText(repoRoot, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], undefined, signal)));
201
+ // Git process startup (often behind wrappers such as git-ai) dominates setup time, so hash
202
+ // and stage in bounded batches rather than two git processes per repository file.
203
+ const entries: { rel: string; mode: string; oid: string }[] = [];
204
+ const batched: { entry: (typeof entries)[number]; path: string; size: number }[] = [];
175
205
  for (const rel of paths) {
176
206
  if (!safeFilePath(repoRoot, rel)) throw new Error("unsafe snapshot path: " + rel);
177
207
  const source = resolve(repoRoot, rel);
178
208
  const stat = statIfPresent(source);
179
209
  if (!stat) continue;
180
210
  if (!stat.isFile() && !stat.isSymbolicLink()) throw new Error("unsupported snapshot file: " + rel);
181
- const bytes = stat.isSymbolicLink() ? Buffer.from(readlinkSync(source)) : readFileSync(source);
182
- const oid = gitText(root, ["hash-object", "-w", "--no-filters", "--stdin"], bytes).trim();
183
- const mode = stat.isSymbolicLink() ? "120000" : (stat.mode & 0o111) ? "100755" : "100644";
184
- gitText(root, ["update-index", "--add", "--cacheinfo", mode, oid, rel]);
185
- copyInto(repoRoot, root, rel);
211
+ const bytes = stat.isSymbolicLink() ? Buffer.from(await readlink(source)) : await readFile(source);
212
+ signal?.throwIfAborted();
213
+ await writeSnapshot(root, rel, bytes, stat);
214
+ const entry = { rel, mode: stat.isSymbolicLink() ? "120000" : (stat.mode & 0o111) ? "100755" : "100644", oid: "" };
215
+ entries.push(entry);
216
+ // The private copy holds exactly the bytes read above. Hashing it by path would follow a
217
+ // symlink, and --stdin-paths is line-based, so those entries hash their bytes directly.
218
+ const path = resolve(root, rel);
219
+ if (stat.isSymbolicLink() || path.includes("\n")) {
220
+ entry.oid = (await gitText(root, ["hash-object", "-w", "--no-filters", "--stdin"], bytes, signal)).trim();
221
+ } else batched.push({ entry, path, size: bytes.length });
186
222
  }
187
- const tree = gitText(root, ["write-tree"]).trim();
188
- const baseline = gitText(root, ["-c", "user.name=pi-jar", "-c", "user.email=pi-jar@local",
189
- "commit-tree", "--no-gpg-sign", tree, "-p", "HEAD", "-m", "pi-jar delegate baseline"]).trim();
190
- gitText(root, ["update-ref", "HEAD", baseline]);
223
+ const oids = await hashPaths(root, batched, true, signal);
224
+ batched.forEach((item, index) => { item.entry.oid = oids[index]!; });
225
+ if (entries.length) {
226
+ await gitText(root, ["update-index", "-z", "--index-info"],
227
+ entries.map(entry => `${entry.mode} ${entry.oid}\t${entry.rel}\0`).join(""), signal);
228
+ }
229
+ const tree = (await gitText(root, ["write-tree"], undefined, signal)).trim();
230
+ const baseline = (await gitText(root, ["-c", "user.name=pi-jar", "-c", "user.email=pi-jar@local",
231
+ "commit-tree", "--no-gpg-sign", tree, "-p", "HEAD", "-m", "pi-jar delegate baseline"], undefined, signal)).trim();
232
+ await gitText(root, ["update-ref", "HEAD", baseline], undefined, signal);
191
233
  const childCwd = relCwd ? resolve(root, relCwd) : root;
192
234
  if (!workspacePathAllowed(root, root, childCwd)) throw new Error("unsafe child working directory");
193
235
  if (!existsSync(childCwd)) mkdirSync(childCwd, { recursive: true });
194
236
  return { repoRoot, root, cwd: childCwd, baseline, tempRoot };
195
237
  } catch (error) {
196
238
  if (added) {
197
- try { gitText(repoRoot, ["worktree", "remove", "--force", root]); } catch { /* best effort */ }
239
+ try { await gitText(repoRoot, ["worktree", "remove", "--force", root]); } catch { /* best effort */ }
198
240
  }
199
- rmSync(tempRoot, { recursive: true, force: true });
241
+ await rm(tempRoot, { recursive: true, force: true });
200
242
  throw error;
201
243
  }
202
244
  }
203
245
 
204
246
  /** Files whose final worktree state differs from the snapshot, including new untracked files. */
205
- export function worktreeChangedFiles(worktree: DelegateWorktree): string[] {
206
- const baselinePaths = nulList(gitText(worktree.root, ["ls-tree", "-r", "--name-only", "-z", worktree.baseline]));
207
- const currentPaths = nulList(gitText(worktree.root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]));
208
- return [...new Set([...baselinePaths, ...currentPaths])]
209
- .filter(rel => !parentMatchesBaseline(worktree, worktree.root, rel)).sort();
247
+ export async function worktreeChangedFiles(worktree: DelegateWorktree, signal?: AbortSignal): Promise<string[]> {
248
+ const baseline = new Map<string, { mode: string; oid: string }>();
249
+ for (const record of nulList(await gitText(worktree.root, ["ls-tree", "-r", "-z", worktree.baseline], undefined, signal))) {
250
+ const tab = record.indexOf("\t");
251
+ const [mode, type, oid] = record.slice(0, tab).split(" ");
252
+ if (type !== "blob" || !mode || !oid) throw new Error("unsupported baseline entry: " + record.slice(tab + 1));
253
+ baseline.set(record.slice(tab + 1), { mode, oid });
254
+ }
255
+ const currentPaths = nulList(await gitText(worktree.root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], undefined, signal));
256
+ const changed: string[] = [];
257
+ // Compare object ids from bounded hash-object batches instead of two git processes per file.
258
+ const batched: { rel: string; oid: string; path: string; size: number }[] = [];
259
+ const hashBytes = async (bytes: Buffer) => (await gitText(worktree.root, ["hash-object", "--no-filters", "--stdin"], bytes, signal)).trim();
260
+ for (const rel of new Set([...baseline.keys(), ...currentPaths])) {
261
+ const entry = baseline.get(rel);
262
+ const target = resolve(worktree.root, rel);
263
+ if (!safeFilePath(worktree.root, rel)) { changed.push(rel); continue; }
264
+ const stat = statIfPresent(target);
265
+ if (!entry || !stat) {
266
+ if (entry || stat) changed.push(rel);
267
+ continue;
268
+ }
269
+ if (entry.mode === "120000") {
270
+ if (!stat.isSymbolicLink() || await hashBytes(Buffer.from(await readlink(target))) !== entry.oid) changed.push(rel);
271
+ continue;
272
+ }
273
+ if (!stat.isFile() || (entry.mode === "100755") !== ((stat.mode & 0o111) !== 0)) changed.push(rel);
274
+ else if (target.includes("\n")) { if (await hashBytes(await readFile(target)) !== entry.oid) changed.push(rel); }
275
+ else batched.push({ rel, oid: entry.oid, path: target, size: stat.size });
276
+ }
277
+ const oids = await hashPaths(worktree.root, batched, false, signal);
278
+ batched.forEach((item, index) => { if (oids[index] !== item.oid) changed.push(item.rel); });
279
+ return changed.sort();
210
280
  }
211
281
 
212
282
  type BaselineEntry = { mode: string; content: Buffer } | null;
213
283
 
214
- const baselineEntry = (worktree: DelegateWorktree, rel: string): BaselineEntry => {
215
- const line = gitText(worktree.root, ["ls-tree", "-z", worktree.baseline, "--", rel]);
284
+ const baselineEntry = async (worktree: DelegateWorktree, rel: string, signal?: AbortSignal): Promise<BaselineEntry> => {
285
+ const line = await gitText(worktree.root, ["ls-tree", "-z", worktree.baseline, "--", rel], undefined, signal);
216
286
  if (!line || line.slice(line.indexOf("\t") + 1, -1) !== rel) return null;
217
287
  const [mode, type, oid] = line.slice(0, line.indexOf("\t")).split(" ");
218
288
  if (type !== "blob" || !mode || !oid) throw new Error("unsupported baseline entry: " + rel);
219
- return { mode, content: gitBuffer(worktree.root, ["cat-file", "blob", oid]) };
289
+ return { mode, content: await gitBuffer(worktree.root, ["cat-file", "blob", oid], signal) };
220
290
  };
221
291
 
222
- const parentMatchesBaseline = (worktree: DelegateWorktree, parentRoot: string, rel: string): boolean => {
292
+ const matchesBaseline = (baseline: BaselineEntry, parentRoot: string, rel: string): boolean => {
223
293
  const target = resolve(parentRoot, rel);
224
294
  if (!safeFilePath(parentRoot, rel)) return false;
225
- const baseline = baselineEntry(worktree, rel);
226
295
  const stat = statIfPresent(target);
227
296
  if (!baseline) return !stat;
228
297
  if (!stat) return false;
@@ -245,17 +314,20 @@ const reviewableBytes = (bytes: Buffer): boolean => bytes.length <= MAX_TRACKED_
245
314
  * Copy a completed child's changed files back to the parent only when every target still equals the
246
315
  * baseline snapshot. The parent is therefore never overwritten after another actor changed a file.
247
316
  */
248
- export function applyDelegateWorktree(worktree: DelegateWorktree, parentCwd: string, files: readonly string[],
249
- tracker?: ChangeTracker): string[] {
250
- const parentRoot = realpathSync(gitText(parentCwd, ["rev-parse", "--show-toplevel"]).trim());
317
+ export async function applyDelegateWorktree(worktree: DelegateWorktree, parentCwd: string, files: readonly string[],
318
+ tracker?: ChangeTracker, signal?: AbortSignal): Promise<string[]> {
319
+ const parentRoot = realpathSync((await gitText(parentCwd, ["rev-parse", "--show-toplevel"], undefined, signal)).trim());
251
320
  if (parentRoot !== worktree.repoRoot) throw new Error("parent repository changed while subagent was running");
252
321
  // Review identities follow the caller's cwd spelling (e.g. /var rather than /private/var).
253
322
  const reviewRoot = resolve(parentCwd, relative(realpathSync(parentCwd), parentRoot));
323
+ const baselines = new Map<string, BaselineEntry>();
254
324
  for (const rel of files) {
255
325
  if (!safeFilePath(parentRoot, rel) || statIfPresent(resolve(parentRoot, rel))?.isSymbolicLink()) {
256
326
  throw new Error("unsafe parent changed path: " + rel);
257
327
  }
258
- if (!parentMatchesBaseline(worktree, parentRoot, rel)) {
328
+ const baseline = await baselineEntry(worktree, rel, signal);
329
+ baselines.set(rel, baseline);
330
+ if (!matchesBaseline(baseline, parentRoot, rel)) {
259
331
  throw new Error(`parent changed since delegation started: ${rel}`);
260
332
  }
261
333
  const parentStat = statIfPresent(resolve(parentRoot, rel));
@@ -315,10 +387,15 @@ export function applyDelegateWorktree(worktree: DelegateWorktree, parentCwd: str
315
387
  chmodSync(entry.replacement, after.mode & 0o777);
316
388
  }
317
389
  }
390
+ // No await after entering the atomic apply/rollback section: asynchronous Git calls between
391
+ // replacements would let parent edits or a session switch interleave with partial application.
392
+ signal?.throwIfAborted();
318
393
  for (const entry of staged) {
319
- if (!safeFilePath(parentRoot, entry.rel) || !parentMatchesBaseline(worktree, parentRoot, entry.rel)) {
394
+ if (!safeFilePath(parentRoot, entry.rel) || !matchesBaseline(baselines.get(entry.rel)!, parentRoot, entry.rel)) {
320
395
  throw new Error("parent changed while preparing finalization: " + entry.rel);
321
396
  }
397
+ }
398
+ for (const entry of staged) {
322
399
  if (entry.replacement) renameSync(entry.replacement, entry.target);
323
400
  else if (statIfPresent(entry.target)) unlinkSync(entry.target);
324
401
  applied.push(entry);
@@ -349,12 +426,12 @@ export class WorktreeApplyError extends Error {
349
426
  }
350
427
 
351
428
  /** Remove a disposable worktree and its dangling snapshot commit reference. */
352
- export function disposeDelegateWorktree(worktree: DelegateWorktree): void {
353
- try { gitText(worktree.repoRoot, ["worktree", "remove", "--force", worktree.root]); }
429
+ export async function disposeDelegateWorktree(worktree: DelegateWorktree, signal?: AbortSignal): Promise<void> {
430
+ try { await gitText(worktree.repoRoot, ["worktree", "remove", "--force", worktree.root], undefined, signal); }
354
431
  catch {
355
- rmSync(worktree.tempRoot, { recursive: true, force: true });
356
- try { gitText(worktree.repoRoot, ["worktree", "prune"]); } catch { /* best effort */ }
432
+ await rm(worktree.tempRoot, { recursive: true, force: true });
433
+ try { await gitText(worktree.repoRoot, ["worktree", "prune"], undefined, signal); } catch { /* best effort */ }
357
434
  return;
358
435
  }
359
- rmSync(worktree.tempRoot, { recursive: true, force: true });
436
+ await rm(worktree.tempRoot, { recursive: true, force: true });
360
437
  }