pi-jar 0.1.7 → 0.1.9

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
@@ -15,7 +15,11 @@ const MAX_DIFF_MIDDLE_LINES = 4_000;
15
15
  export type ChangeStatus = "added" | "modified" | "deleted";
16
16
  export interface FileChange { path: string; rel: string; status: ChangeStatus; before: string; after: string; added: number; removed: number; diff?: DiffOp[] | null }
17
17
  export type DiffOp = { op: " " | "+" | "-"; text: string };
18
- 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 };
19
23
 
20
24
  /** Text content, `null` when the file is missing, or `undefined` when it cannot be tracked. */
21
25
  function readText(path: string): string | null | undefined {
@@ -104,7 +108,8 @@ export function diffHunkOffsets(ops: readonly DiffOp[], context = 3): number[] {
104
108
  while (to < ops.length) {
105
109
  let next = to + 1;
106
110
  while (next < ops.length && ops[next]!.op === " ") next++;
107
- if (next < ops.length && next - to <= context * 2) { to = next; continue; }
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; }
108
113
  break;
109
114
  }
110
115
  const end = Math.min(ops.length, to + context + 1);
@@ -122,14 +127,7 @@ export function diffRowCount(ops: readonly DiffOp[], context = 3): number {
122
127
  return hunkCache.get(ops)!.rows;
123
128
  }
124
129
 
125
- /** Compute only the requested output window; diff operations stay indexed without a second full row array. */
126
- export function diffRowsWindow(ops: readonly DiffOp[], context = 3, offset = 0, limit = Number.MAX_SAFE_INTEGER): DiffRow[] {
127
- const rows: DiffRow[] = [];
128
- const firstRow = Math.max(0, offset);
129
- const lastRow = firstRow + Math.max(0, limit);
130
- if (hunkCache.get(ops)?.context !== context) diffHunkOffsets(ops, context);
131
- const layout = hunkCache.get(ops)!;
132
- if (firstRow >= layout.rows || lastRow <= firstRow) return rows;
130
+ const linePositions = (ops: readonly DiffOp[]) => {
133
131
  let positions = linePositionCache.get(ops);
134
132
  if (!positions) {
135
133
  const old = new Uint32Array(ops.length + 1), next = new Uint32Array(ops.length + 1);
@@ -145,6 +143,57 @@ export function diffRowsWindow(ops: readonly DiffOp[], context = 3, offset = 0,
145
143
  positions = { old, next };
146
144
  linePositionCache.set(ops, positions);
147
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);
148
197
  // Binary-search the first intersecting hunk, then visit only visible operations. In
149
198
  // particular, a 100k-line replacement must not slice/filter/allocate 100k rows per frame.
150
199
  let lo = 0, hi = layout.hunks.length;
@@ -157,13 +206,99 @@ export function diffRowsWindow(ops: readonly DiffOp[], context = 3, offset = 0,
157
206
  for (let index = lo; index < layout.hunks.length; index++) {
158
207
  const { from, end, offset: start } = layout.hunks[index]!;
159
208
  if (start >= lastRow) break;
160
- if (start >= firstRow) rows.push({ kind: "hunk", text: `@@ -${positions.old[from]!},${positions.old[end]! - positions.old[from]!} +${positions.next[from]!},${positions.next[end]! - positions.next[from]!} @@` });
209
+ if (start >= firstRow) rows.push({ kind: "hunk", text: hunkHeader(positions, from, end), hidden: from - (layout.hunks[index - 1]?.end ?? 0) });
161
210
  const visibleFrom = from + Math.max(0, firstRow - start - 1);
162
211
  const visibleEnd = Math.min(end, from + lastRow - start - 1);
163
212
  for (let at = visibleFrom; at < visibleEnd; at++) {
164
213
  const op = ops[at]!;
214
+ const pair = op.op === " " ? undefined : partner(ops, at);
165
215
  rows.push({ kind: op.op === "+" ? "add" : op.op === "-" ? "remove" : "context", text: op.text,
166
- ...(op.op !== "+" ? { oldLine: positions.old[at]! } : {}), ...(op.op !== "-" ? { newLine: positions.next[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 } } : {}) });
167
302
  }
168
303
  }
169
304
  return rows;
@@ -154,6 +154,31 @@ const writeSnapshot = async (root: string, rel: string, bytes: Buffer, stat: Sta
154
154
  }
155
155
  };
156
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;
180
+ };
181
+
157
182
  /**
158
183
  * Create a detached disposable worktree and commit a private baseline snapshot containing the
159
184
  * parent's tracked modifications plus non-ignored untracked files. The parent index/branch is not
@@ -173,6 +198,10 @@ export async function createDelegateWorktree(cwd: string, signal?: AbortSignal):
173
198
  added = true;
174
199
  await gitText(root, ["read-tree", "--empty"], undefined, signal);
175
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 }[] = [];
176
205
  for (const rel of paths) {
177
206
  if (!safeFilePath(repoRoot, rel)) throw new Error("unsafe snapshot path: " + rel);
178
207
  const source = resolve(repoRoot, rel);
@@ -180,11 +209,22 @@ export async function createDelegateWorktree(cwd: string, signal?: AbortSignal):
180
209
  if (!stat) continue;
181
210
  if (!stat.isFile() && !stat.isSymbolicLink()) throw new Error("unsupported snapshot file: " + rel);
182
211
  const bytes = stat.isSymbolicLink() ? Buffer.from(await readlink(source)) : await readFile(source);
183
- const oid = (await gitText(root, ["hash-object", "-w", "--no-filters", "--stdin"], bytes, signal)).trim();
184
- const mode = stat.isSymbolicLink() ? "120000" : (stat.mode & 0o111) ? "100755" : "100644";
185
- await gitText(root, ["update-index", "--add", "--cacheinfo", mode, oid, rel], undefined, signal);
186
212
  signal?.throwIfAborted();
187
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 });
222
+ }
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);
188
228
  }
189
229
  const tree = (await gitText(root, ["write-tree"], undefined, signal)).trim();
190
230
  const baseline = (await gitText(root, ["-c", "user.name=pi-jar", "-c", "user.email=pi-jar@local",
@@ -205,12 +245,37 @@ export async function createDelegateWorktree(cwd: string, signal?: AbortSignal):
205
245
 
206
246
  /** Files whose final worktree state differs from the snapshot, including new untracked files. */
207
247
  export async function worktreeChangedFiles(worktree: DelegateWorktree, signal?: AbortSignal): Promise<string[]> {
208
- const baselinePaths = nulList(await gitText(worktree.root, ["ls-tree", "-r", "--name-only", "-z", worktree.baseline], undefined, signal));
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
+ }
209
255
  const currentPaths = nulList(await gitText(worktree.root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], undefined, signal));
210
256
  const changed: string[] = [];
211
- for (const rel of new Set([...baselinePaths, ...currentPaths])) {
212
- if (!await parentMatchesBaseline(worktree, worktree.root, rel, signal)) changed.push(rel);
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 });
213
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); });
214
279
  return changed.sort();
215
280
  }
216
281
 
@@ -224,9 +289,6 @@ const baselineEntry = async (worktree: DelegateWorktree, rel: string, signal?: A
224
289
  return { mode, content: await gitBuffer(worktree.root, ["cat-file", "blob", oid], signal) };
225
290
  };
226
291
 
227
- const parentMatchesBaseline = async (worktree: DelegateWorktree, parentRoot: string, rel: string, signal?: AbortSignal): Promise<boolean> =>
228
- matchesBaseline(await baselineEntry(worktree, rel, signal), parentRoot, rel);
229
-
230
292
  const matchesBaseline = (baseline: BaselineEntry, parentRoot: string, rel: string): boolean => {
231
293
  const target = resolve(parentRoot, rel);
232
294
  if (!safeFilePath(parentRoot, rel)) return false;