pi-jar 0.1.6 → 0.1.7

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.
@@ -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,16 @@ 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
+ }
154
155
  };
155
156
 
156
157
  /**
@@ -158,9 +159,9 @@ const copyInto = (fromRoot: string, toRoot: string, rel: string): void => {
158
159
  * parent's tracked modifications plus non-ignored untracked files. The parent index/branch is not
159
160
  * touched. That baseline lets multiple children run against the exact same starting filesystem.
160
161
  */
161
- export function createDelegateWorktree(cwd: string): DelegateWorktree {
162
+ export async function createDelegateWorktree(cwd: string, signal?: AbortSignal): Promise<DelegateWorktree> {
162
163
  const requested = realpathSync(cwd);
163
- const repoRoot = realpathSync(gitText(requested, ["rev-parse", "--show-toplevel"]).trim());
164
+ const repoRoot = realpathSync((await gitText(requested, ["rev-parse", "--show-toplevel"], undefined, signal)).trim());
164
165
  if (!inside(repoRoot, requested)) throw new Error("working directory is outside the git repository");
165
166
  const relCwd = relative(repoRoot, requested);
166
167
  const tempRoot = realpathSync(mkdtempSync(join(tmpdir(), "pi-jar-worktree-")));
@@ -168,61 +169,67 @@ export function createDelegateWorktree(cwd: string): DelegateWorktree {
168
169
  let added = false;
169
170
  try {
170
171
  // Never checkout/add: they execute clean/smudge filters and alter byte baselines.
171
- gitText(repoRoot, ["worktree", "add", "--no-checkout", "--detach", root, "HEAD"]);
172
+ await gitText(repoRoot, ["worktree", "add", "--no-checkout", "--detach", root, "HEAD"], undefined, signal);
172
173
  added = true;
173
- gitText(root, ["read-tree", "--empty"]);
174
- const paths = new Set(nulList(gitText(repoRoot, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"])));
174
+ await gitText(root, ["read-tree", "--empty"], undefined, signal);
175
+ const paths = new Set(nulList(await gitText(repoRoot, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], undefined, signal)));
175
176
  for (const rel of paths) {
176
177
  if (!safeFilePath(repoRoot, rel)) throw new Error("unsafe snapshot path: " + rel);
177
178
  const source = resolve(repoRoot, rel);
178
179
  const stat = statIfPresent(source);
179
180
  if (!stat) continue;
180
181
  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();
182
+ 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();
183
184
  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);
185
+ await gitText(root, ["update-index", "--add", "--cacheinfo", mode, oid, rel], undefined, signal);
186
+ signal?.throwIfAborted();
187
+ await writeSnapshot(root, rel, bytes, stat);
186
188
  }
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]);
189
+ const tree = (await gitText(root, ["write-tree"], undefined, signal)).trim();
190
+ const baseline = (await gitText(root, ["-c", "user.name=pi-jar", "-c", "user.email=pi-jar@local",
191
+ "commit-tree", "--no-gpg-sign", tree, "-p", "HEAD", "-m", "pi-jar delegate baseline"], undefined, signal)).trim();
192
+ await gitText(root, ["update-ref", "HEAD", baseline], undefined, signal);
191
193
  const childCwd = relCwd ? resolve(root, relCwd) : root;
192
194
  if (!workspacePathAllowed(root, root, childCwd)) throw new Error("unsafe child working directory");
193
195
  if (!existsSync(childCwd)) mkdirSync(childCwd, { recursive: true });
194
196
  return { repoRoot, root, cwd: childCwd, baseline, tempRoot };
195
197
  } catch (error) {
196
198
  if (added) {
197
- try { gitText(repoRoot, ["worktree", "remove", "--force", root]); } catch { /* best effort */ }
199
+ try { await gitText(repoRoot, ["worktree", "remove", "--force", root]); } catch { /* best effort */ }
198
200
  }
199
- rmSync(tempRoot, { recursive: true, force: true });
201
+ await rm(tempRoot, { recursive: true, force: true });
200
202
  throw error;
201
203
  }
202
204
  }
203
205
 
204
206
  /** 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();
207
+ 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));
209
+ const currentPaths = nulList(await gitText(worktree.root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], undefined, signal));
210
+ const changed: string[] = [];
211
+ for (const rel of new Set([...baselinePaths, ...currentPaths])) {
212
+ if (!await parentMatchesBaseline(worktree, worktree.root, rel, signal)) changed.push(rel);
213
+ }
214
+ return changed.sort();
210
215
  }
211
216
 
212
217
  type BaselineEntry = { mode: string; content: Buffer } | null;
213
218
 
214
- const baselineEntry = (worktree: DelegateWorktree, rel: string): BaselineEntry => {
215
- const line = gitText(worktree.root, ["ls-tree", "-z", worktree.baseline, "--", rel]);
219
+ const baselineEntry = async (worktree: DelegateWorktree, rel: string, signal?: AbortSignal): Promise<BaselineEntry> => {
220
+ const line = await gitText(worktree.root, ["ls-tree", "-z", worktree.baseline, "--", rel], undefined, signal);
216
221
  if (!line || line.slice(line.indexOf("\t") + 1, -1) !== rel) return null;
217
222
  const [mode, type, oid] = line.slice(0, line.indexOf("\t")).split(" ");
218
223
  if (type !== "blob" || !mode || !oid) throw new Error("unsupported baseline entry: " + rel);
219
- return { mode, content: gitBuffer(worktree.root, ["cat-file", "blob", oid]) };
224
+ return { mode, content: await gitBuffer(worktree.root, ["cat-file", "blob", oid], signal) };
220
225
  };
221
226
 
222
- const parentMatchesBaseline = (worktree: DelegateWorktree, parentRoot: string, rel: string): boolean => {
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
+ const matchesBaseline = (baseline: BaselineEntry, parentRoot: string, rel: string): boolean => {
223
231
  const target = resolve(parentRoot, rel);
224
232
  if (!safeFilePath(parentRoot, rel)) return false;
225
- const baseline = baselineEntry(worktree, rel);
226
233
  const stat = statIfPresent(target);
227
234
  if (!baseline) return !stat;
228
235
  if (!stat) return false;
@@ -245,17 +252,20 @@ const reviewableBytes = (bytes: Buffer): boolean => bytes.length <= MAX_TRACKED_
245
252
  * Copy a completed child's changed files back to the parent only when every target still equals the
246
253
  * baseline snapshot. The parent is therefore never overwritten after another actor changed a file.
247
254
  */
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());
255
+ export async function applyDelegateWorktree(worktree: DelegateWorktree, parentCwd: string, files: readonly string[],
256
+ tracker?: ChangeTracker, signal?: AbortSignal): Promise<string[]> {
257
+ const parentRoot = realpathSync((await gitText(parentCwd, ["rev-parse", "--show-toplevel"], undefined, signal)).trim());
251
258
  if (parentRoot !== worktree.repoRoot) throw new Error("parent repository changed while subagent was running");
252
259
  // Review identities follow the caller's cwd spelling (e.g. /var rather than /private/var).
253
260
  const reviewRoot = resolve(parentCwd, relative(realpathSync(parentCwd), parentRoot));
261
+ const baselines = new Map<string, BaselineEntry>();
254
262
  for (const rel of files) {
255
263
  if (!safeFilePath(parentRoot, rel) || statIfPresent(resolve(parentRoot, rel))?.isSymbolicLink()) {
256
264
  throw new Error("unsafe parent changed path: " + rel);
257
265
  }
258
- if (!parentMatchesBaseline(worktree, parentRoot, rel)) {
266
+ const baseline = await baselineEntry(worktree, rel, signal);
267
+ baselines.set(rel, baseline);
268
+ if (!matchesBaseline(baseline, parentRoot, rel)) {
259
269
  throw new Error(`parent changed since delegation started: ${rel}`);
260
270
  }
261
271
  const parentStat = statIfPresent(resolve(parentRoot, rel));
@@ -315,10 +325,15 @@ export function applyDelegateWorktree(worktree: DelegateWorktree, parentCwd: str
315
325
  chmodSync(entry.replacement, after.mode & 0o777);
316
326
  }
317
327
  }
328
+ // No await after entering the atomic apply/rollback section: asynchronous Git calls between
329
+ // replacements would let parent edits or a session switch interleave with partial application.
330
+ signal?.throwIfAborted();
318
331
  for (const entry of staged) {
319
- if (!safeFilePath(parentRoot, entry.rel) || !parentMatchesBaseline(worktree, parentRoot, entry.rel)) {
332
+ if (!safeFilePath(parentRoot, entry.rel) || !matchesBaseline(baselines.get(entry.rel)!, parentRoot, entry.rel)) {
320
333
  throw new Error("parent changed while preparing finalization: " + entry.rel);
321
334
  }
335
+ }
336
+ for (const entry of staged) {
322
337
  if (entry.replacement) renameSync(entry.replacement, entry.target);
323
338
  else if (statIfPresent(entry.target)) unlinkSync(entry.target);
324
339
  applied.push(entry);
@@ -349,12 +364,12 @@ export class WorktreeApplyError extends Error {
349
364
  }
350
365
 
351
366
  /** 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]); }
367
+ export async function disposeDelegateWorktree(worktree: DelegateWorktree, signal?: AbortSignal): Promise<void> {
368
+ try { await gitText(worktree.repoRoot, ["worktree", "remove", "--force", worktree.root], undefined, signal); }
354
369
  catch {
355
- rmSync(worktree.tempRoot, { recursive: true, force: true });
356
- try { gitText(worktree.repoRoot, ["worktree", "prune"]); } catch { /* best effort */ }
370
+ await rm(worktree.tempRoot, { recursive: true, force: true });
371
+ try { await gitText(worktree.repoRoot, ["worktree", "prune"], undefined, signal); } catch { /* best effort */ }
357
372
  return;
358
373
  }
359
- rmSync(worktree.tempRoot, { recursive: true, force: true });
374
+ await rm(worktree.tempRoot, { recursive: true, force: true });
360
375
  }