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/README.md +19 -7
- package/docs/DIFF-REVIEW.md +14 -0
- package/docs/SUBAGENT-TOOLS.md +24 -0
- package/docs/WORKFLOWS.md +2 -2
- package/extensions/index.ts +21 -23
- package/package.json +1 -1
- package/src/advisor.ts +17 -8
- package/src/async-process.ts +144 -0
- package/src/changes.ts +235 -28
- package/src/commit.ts +5 -6
- package/src/delegate-worktree.ts +135 -58
- package/src/delegate.ts +291 -88
- package/src/diff-inline.ts +80 -0
- package/src/diff-view.ts +292 -44
- package/src/discussion.ts +59 -21
- package/src/quota.ts +12 -1
- package/src/shell-notifications.ts +104 -0
- package/src/shells.ts +130 -26
- package/src/subagent-tools.ts +45 -0
- package/src/working.ts +3 -5
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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 (
|
|
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 &&
|
|
78
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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]
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
34
|
-
|
|
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");
|
package/src/delegate-worktree.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (
|
|
153
|
-
else
|
|
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(
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
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
|
-
|
|
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) || !
|
|
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
|
-
|
|
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
|
-
|
|
436
|
+
await rm(worktree.tempRoot, { recursive: true, force: true });
|
|
360
437
|
}
|