papilot 0.8.6 → 0.8.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.
Files changed (44) hide show
  1. package/dist/assets/index-BIc9Mnim.css +1 -0
  2. package/dist/assets/index-MvQvvv3I.js +267 -0
  3. package/dist/assets/{math-C7ENJr-a.js → math-CBzEH7Y2.js} +1 -1
  4. package/dist/assets/{pdf-view-B2xQuHmI.js → pdf-view-ByftRBhD.js} +2 -2
  5. package/dist/index.html +2 -2
  6. package/engine/dist/web/assets/{FullPaper-DlgZ1MOz.js → FullPaper-DWwiT43p.js} +2 -2
  7. package/engine/dist/web/assets/{index-BCgVChtV.js → index-DBYJK8Uy.js} +4 -4
  8. package/engine/dist/web/assets/{index-D3_ZFdT8.css → index-DGkq8xQx.css} +1 -1
  9. package/engine/dist/web/index.html +2 -2
  10. package/engine/src/cli.ts +3 -0
  11. package/engine/src/client.ts +24 -7
  12. package/engine/src/core/build.ts +357 -93
  13. package/engine/src/core/changes.ts +39 -10
  14. package/engine/src/core/config.ts +2 -2
  15. package/engine/src/core/current-commit.ts +34 -34
  16. package/engine/src/core/delivery.ts +83 -30
  17. package/engine/src/core/files.ts +19 -0
  18. package/engine/src/core/git.ts +283 -92
  19. package/engine/src/core/process.ts +124 -11
  20. package/engine/src/core/publish.ts +59 -50
  21. package/engine/src/core/reconcile.ts +23 -21
  22. package/engine/src/core/review.ts +43 -24
  23. package/engine/src/core/tasks.ts +85 -28
  24. package/engine/src/core/view.ts +8 -8
  25. package/engine/src/server.ts +62 -6
  26. package/engine/src/types.ts +8 -0
  27. package/engine/src/web/FullPaper.tsx +122 -34
  28. package/engine/src/web/review.tsx +4 -1
  29. package/engine/src/web/style.css +24 -0
  30. package/package.json +1 -1
  31. package/src/server/agents.ts +3 -0
  32. package/src/server/annotated-pdf.ts +392 -0
  33. package/src/server/annotation-placement.ts +18 -3
  34. package/src/server/app.ts +68 -10
  35. package/src/server/changes.ts +12 -7
  36. package/src/server/cursor.ts +1 -0
  37. package/src/server/manuscript.ts +218 -56
  38. package/src/server/pdf.ts +176 -23
  39. package/src/server/references.ts +10 -2
  40. package/src/server/review.ts +6 -2
  41. package/src/server/start.ts +4 -1
  42. package/src/server/workspace.ts +18 -0
  43. package/dist/assets/index-Dm7AYM9e.js +0 -267
  44. package/dist/assets/index-PFQDdu53.css +0 -1
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawn } from "node:child_process";
5
- import { run, which } from "./process.ts";
5
+ import { capture, run, startTracked, which } from "./process.ts";
6
6
  import { same } from "./files.ts";
7
7
  const IDENTITY = {
8
8
  GIT_AUTHOR_NAME: "latex-review",
@@ -27,6 +27,18 @@ export function gitBinary() {
27
27
  }
28
28
  return binary;
29
29
  }
30
+ // A repository's Git directory, looked up once: every build and connection
31
+ // asks for it, and each lookup would run Git. It is looked up again if the
32
+ // directory disappears, as when a repository is moved.
33
+ const gitDirs = new Map<string, string>();
34
+ export function gitDirOf(root: string) {
35
+ let dir = gitDirs.get(root);
36
+ if (!dir || !fs.existsSync(dir)) {
37
+ dir = new Repo(root).gitDir();
38
+ gitDirs.set(root, dir);
39
+ }
40
+ return dir;
41
+ }
30
42
  export const within = (file: string, scopes: readonly string[]) =>
31
43
  scopes.some(
32
44
  (scope) =>
@@ -50,6 +62,46 @@ function temporary<T>(prefix: string, work: (folder: string) => T): T {
50
62
  fs.rmSync(dir, { recursive: true, force: true });
51
63
  }
52
64
  }
65
+ // `ls-tree -r -l -z` output as blob entries.
66
+ function parseEntries(output: Buffer) {
67
+ return output.toString().split("\0").filter(Boolean).flatMap(entry => {
68
+ const tab = entry.indexOf("\t"), [mode, type, oid, size] = entry.slice(0, tab).split(/ +/);
69
+ return type === "blob" ? [{ mode, oid, size: Number(size), file: entry.slice(tab + 1) }] : [];
70
+ });
71
+ }
72
+ // `cat-file --batch` output for these names, in order.
73
+ function parseBlobs(names: string[], output: Buffer) {
74
+ const result: Record<string, Buffer | null> = {};
75
+ let offset = 0;
76
+ for (const name of names) {
77
+ const end = output.indexOf(10, offset),
78
+ header = output.subarray(offset, end).toString().split(" ");
79
+ offset = end + 1;
80
+ const size = Number(header[2]);
81
+ result[name] =
82
+ header[1] === "blob" && Number.isFinite(size)
83
+ ? output.subarray(offset, offset + size)
84
+ : null;
85
+ if (Number.isFinite(size)) offset += size + 1;
86
+ }
87
+ return result;
88
+ }
89
+ // Copies an index file and keeps its modification time: Git compares a file's
90
+ // modification time with the index's own to catch edits made in the same
91
+ // instant the index was written, so a copy must not look newer than it is.
92
+ function copyIndex(from: string, to: string) {
93
+ try {
94
+ const stat = fs.statSync(from);
95
+ fs.copyFileSync(from, to);
96
+ fs.utimesSync(to, stat.atime, stat.mtime);
97
+ return true;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+ // Where each checkout keeps the index its snapshots start from, and its own
103
+ // index. Linked worktrees have their own Git directory for both.
104
+ const indexes = new Map<string, { saved: string; own: string }>();
53
105
  export class Repo {
54
106
  constructor(public root: string) {
55
107
  this.root = path.resolve(root);
@@ -76,6 +128,21 @@ export class Repo {
76
128
  text(args: string[], options: Parameters<Repo["run"]>[1] = {}) {
77
129
  return this.run(args, options).toString().trim();
78
130
  }
131
+ /** `run` for servers: Git runs while the event loop answers other requests. */
132
+ async runAsync(args: string[], options: Parameters<Repo["run"]>[1] = {}) {
133
+ try {
134
+ return (await capture(
135
+ gitBinary(),
136
+ ["-c", "core.quotepath=off", "--literal-pathspecs", ...args],
137
+ { cwd: this.root, ...options },
138
+ )).stdout;
139
+ } catch (error) {
140
+ throw new GitError((error as Error).message);
141
+ }
142
+ }
143
+ async textAsync(args: string[], options: Parameters<Repo["run"]>[1] = {}) {
144
+ return (await this.runAsync(args, options)).toString().trim();
145
+ }
79
146
  toplevel() {
80
147
  return fs.realpathSync(this.text(["rev-parse", "--show-toplevel"]));
81
148
  }
@@ -94,24 +161,17 @@ export class Repo {
94
161
  }
95
162
  blobs(rev: string, paths: string[]) {
96
163
  const names = [...new Set(paths)];
97
- const result: Record<string, Buffer | null> = {};
98
- if (!names.length) return result;
99
- const output = this.run(["cat-file", "--batch", "-z"], {
164
+ if (!names.length) return {} as Record<string, Buffer | null>;
165
+ return parseBlobs(names, this.run(["cat-file", "--batch", "-z"], {
100
166
  input: names.map((p) => `${rev}:${p}\0`).join(""),
101
- });
102
- let offset = 0;
103
- for (const name of names) {
104
- const end = output.indexOf(10, offset),
105
- header = output.subarray(offset, end).toString().split(" ");
106
- offset = end + 1;
107
- const size = Number(header[2]);
108
- result[name] =
109
- header[1] === "blob" && Number.isFinite(size)
110
- ? output.subarray(offset, offset + size)
111
- : null;
112
- if (Number.isFinite(size)) offset += size + 1;
113
- }
114
- return result;
167
+ }));
168
+ }
169
+ async blobsAsync(rev: string, paths: string[]) {
170
+ const names = [...new Set(paths)];
171
+ if (!names.length) return {} as Record<string, Buffer | null>;
172
+ return parseBlobs(names, await this.runAsync(["cat-file", "--batch", "-z"], {
173
+ input: names.map((p) => `${rev}:${p}\0`).join(""),
174
+ }));
115
175
  }
116
176
  blob(rev: string, file: string) {
117
177
  return this.blobs(rev, [file])[file];
@@ -130,12 +190,10 @@ export class Repo {
130
190
  .filter(Boolean);
131
191
  }
132
192
  entries(rev: string, folder = "") {
133
- return this.run([
134
- "ls-tree", "-r", "-l", "-z", rev, ...(folder ? ["--", folder] : []),
135
- ]).toString().split("\0").filter(Boolean).flatMap(entry => {
136
- const tab = entry.indexOf("\t"), [mode, type, oid, size] = entry.slice(0, tab).split(/ +/);
137
- return type === "blob" ? [{ mode, oid, size: Number(size), file: entry.slice(tab + 1) }] : [];
138
- });
193
+ return parseEntries(this.run(["ls-tree", "-r", "-l", "-z", rev, ...(folder ? ["--", folder] : [])]));
194
+ }
195
+ async entriesAsync(rev: string, folder = "") {
196
+ return parseEntries(await this.runAsync(["ls-tree", "-r", "-l", "-z", rev, ...(folder ? ["--", folder] : [])]));
139
197
  }
140
198
  changedFiles(before: string, after: string) {
141
199
  return this.run([
@@ -150,6 +208,10 @@ export class Repo {
150
208
  .split("\0")
151
209
  .filter(Boolean);
152
210
  }
211
+ async changedFilesAsync(before: string, after: string) {
212
+ return (await this.runAsync(["diff", "--name-only", "--no-renames", "-z", before, after]))
213
+ .toString().split("\0").filter(Boolean);
214
+ }
153
215
  mode(rev: string, file: string) {
154
216
  return this.text(["ls-tree", rev, "--", file]).split(/\s+/)[0] || null;
155
217
  }
@@ -159,6 +221,9 @@ export class Repo {
159
221
  folder ? `${rev}:${folder}` : `${rev}^{tree}`,
160
222
  ]);
161
223
  }
224
+ treeIdAsync(rev: string, folder = "") {
225
+ return this.textAsync(["rev-parse", folder ? `${rev}:${folder}` : `${rev}^{tree}`]);
226
+ }
162
227
  hashObject(data: Buffer) {
163
228
  return this.text(["hash-object", "-w", "--stdin"], { input: data });
164
229
  }
@@ -178,6 +243,34 @@ export class Repo {
178
243
  updateRef(ref: string, value: string) {
179
244
  this.run(["update-ref", ref, value]);
180
245
  }
246
+ revAsync(name: string) {
247
+ return this.textAsync(["rev-parse", "--verify", "--quiet", name + "^{commit}"]);
248
+ }
249
+ async blobAsync(rev: string, file: string) {
250
+ return (await this.blobsAsync(rev, [file]))[file];
251
+ }
252
+ async modeAsync(rev: string, file: string) {
253
+ return (await this.textAsync(["ls-tree", rev, "--", file])).split(/\s+/)[0] || null;
254
+ }
255
+ hashObjectAsync(data: Buffer) {
256
+ return this.textAsync(["hash-object", "-w", "--stdin"], { input: data });
257
+ }
258
+ commitTreeAsync(tree: string, parent: string | null, message: string) {
259
+ return this.textAsync(
260
+ ["commit-tree", "--no-gpg-sign", tree, ...(parent ? ["-p", parent] : []), "-m", message],
261
+ { env: { ...process.env, ...IDENTITY } },
262
+ );
263
+ }
264
+ async commitWithAsync(parent: string, contents: Record<string, Buffer | null>, message: string) {
265
+ return this.commitTreeAsync(await this.treeWithAsync(parent, contents), parent, message);
266
+ }
267
+ async updateRefAsync(ref: string, value: string) {
268
+ await this.runAsync(["update-ref", ref, value]);
269
+ }
270
+ async deleteRefsAsync(prefix: string) {
271
+ const refs = (await this.textAsync(["for-each-ref", "--format=%(refname)", prefix])).split("\n").filter(Boolean);
272
+ if (refs.length) await this.runAsync(["update-ref", "--stdin"], { input: refs.map((r) => `delete ${r}\n`).join("") });
273
+ }
181
274
  deleteRefs(prefix: string) {
182
275
  const refs = this.text(["for-each-ref", "--format=%(refname)", prefix])
183
276
  .split("\n")
@@ -187,57 +280,105 @@ export class Repo {
187
280
  input: refs.map((r) => `delete ${r}\n`).join(""),
188
281
  });
189
282
  }
283
+ // The index a snapshot of this checkout starts from: the previous
284
+ // snapshot's, or else the checkout's own. Either records each file's size
285
+ // and modification time, so Git rehashes only files changed since; a fresh
286
+ // index from read-tree would make it rehash every tracked file.
287
+ private indexFiles() {
288
+ let found = indexes.get(this.root);
289
+ if (!found) {
290
+ const [saved, own] = this.text(["rev-parse", "--git-path", "latex-review/snapshot-index", "--git-path", "index"])
291
+ .split("\n").map((file) => path.resolve(this.root, file));
292
+ indexes.set(this.root, found = { saved, own });
293
+ }
294
+ return found;
295
+ }
296
+ private keepIndex(index: string) {
297
+ const { saved } = this.indexFiles(), temporary = `${saved}.${process.pid}.${Date.now()}.tmp`;
298
+ try {
299
+ fs.mkdirSync(path.dirname(saved), { recursive: true });
300
+ if (copyIndex(index, temporary)) fs.renameSync(temporary, saved);
301
+ } catch { /* The next snapshot rehashes more files. */ }
302
+ finally { fs.rmSync(temporary, { force: true }); }
303
+ }
304
+ // The working tree as a commit on HEAD: tracked files as they are on disk,
305
+ // untracked files within `include` and newly staged files, without
306
+ // touching the checkout's index.
190
307
  snapshot(include: string[] = [], exclude: readonly string[] = []) {
191
308
  const head = this.rev("HEAD");
192
- const staged = this.run([
193
- "diff",
194
- "--cached",
195
- "--name-only",
196
- "--diff-filter=A",
197
- "-z",
198
- ])
199
- .toString()
200
- .split("\0")
201
- .filter(Boolean);
309
+ const staged = this.run(["diff", "--cached", "--name-only", "--diff-filter=A", "-z"])
310
+ .toString().split("\0").filter(Boolean);
311
+ const { saved, own } = this.indexFiles();
202
312
  const tree = temporary("latex-review-index-", (folder) => {
203
- const env = {
204
- ...process.env,
205
- GIT_INDEX_FILE: path.join(folder, "index"),
206
- };
313
+ const index = path.join(folder, "index"), env = { ...process.env, GIT_INDEX_FILE: index };
314
+ // read-tree -m keeps the stat data of entries that match HEAD.
207
315
  const capture = () => {
208
- this.run(["read-tree", head], { env });
316
+ this.run(["read-tree", "-m", head], { env });
209
317
  this.run(["add", "-u"], { env });
210
- const untracked = this.run(
211
- ["ls-files", "--others", "--exclude-standard", "-z"],
212
- { env },
213
- )
214
- .toString()
215
- .split("\0");
216
- const chosen = new Set([
217
- ...untracked.filter(
218
- (p) =>
219
- p &&
220
- within(p, include) &&
221
- !exclude.some((suffix) => p.endsWith(suffix)),
222
- ),
223
- ...staged.filter((p) => fs.existsSync(path.join(this.root, p))),
224
- ]);
225
- if (chosen.size)
226
- this.run(["add", "--pathspec-from-file=-", "--pathspec-file-nul"], {
227
- env,
228
- input: [...chosen].sort().join("\0") + "\0",
229
- });
318
+ const chosen = this.chosen(this.run(["ls-files", "--others", "--exclude-standard", "-z"], { env }), staged, include, exclude);
319
+ if (chosen.length)
320
+ this.run(["add", "--pathspec-from-file=-", "--pathspec-file-nul"], { env, input: chosen.join("\0") + "\0" });
230
321
  return this.text(["write-tree"], { env });
231
322
  };
232
- const first = capture();
323
+ let first: string;
324
+ try {
325
+ if (!copyIndex(saved, index) && !copyIndex(own, index)) throw new Error();
326
+ first = capture();
327
+ } catch {
328
+ // An index with unresolved conflicts cannot be merged: start empty.
329
+ fs.rmSync(index, { force: true });
330
+ this.run(["read-tree", head], { env });
331
+ first = capture();
332
+ }
233
333
  if (first !== capture() || head !== this.rev("HEAD"))
234
- throw new GitError(
235
- "Files changed while the snapshot was taken. Wait for saves to finish and retry.",
236
- );
334
+ throw new GitError("Files changed while the snapshot was taken. Wait for saves to finish and retry.");
335
+ this.keepIndex(index);
237
336
  return first;
238
337
  });
239
338
  return this.commitTree(tree, head, "latex-review snapshot");
240
339
  }
340
+ /** `snapshot` for servers, which keep answering requests meanwhile. */
341
+ async snapshotAsync(include: string[] = [], exclude: readonly string[] = []) {
342
+ const head = await this.textAsync(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
343
+ const staged = (await this.runAsync(["diff", "--cached", "--name-only", "--diff-filter=A", "-z"]))
344
+ .toString().split("\0").filter(Boolean);
345
+ const { saved, own } = this.indexFiles();
346
+ const folder = await fs.promises.mkdtemp(path.join(os.tmpdir(), "latex-review-index-"));
347
+ try {
348
+ const index = path.join(folder, "index"), env = { ...process.env, GIT_INDEX_FILE: index };
349
+ const capture = async () => {
350
+ await this.runAsync(["read-tree", "-m", head], { env });
351
+ await this.runAsync(["add", "-u"], { env });
352
+ const chosen = this.chosen(await this.runAsync(["ls-files", "--others", "--exclude-standard", "-z"], { env }), staged, include, exclude);
353
+ if (chosen.length)
354
+ await this.runAsync(["add", "--pathspec-from-file=-", "--pathspec-file-nul"], { env, input: chosen.join("\0") + "\0" });
355
+ return this.textAsync(["write-tree"], { env });
356
+ };
357
+ let first: string;
358
+ try {
359
+ if (!copyIndex(saved, index) && !copyIndex(own, index)) throw new Error();
360
+ first = await capture();
361
+ } catch {
362
+ await fs.promises.rm(index, { force: true });
363
+ await this.runAsync(["read-tree", head], { env });
364
+ first = await capture();
365
+ }
366
+ if (first !== await capture() || head !== await this.textAsync(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]))
367
+ throw new GitError("Files changed while the snapshot was taken. Wait for saves to finish and retry.");
368
+ this.keepIndex(index);
369
+ return await this.textAsync(["commit-tree", "--no-gpg-sign", first, "-p", head, "-m", "latex-review snapshot"], { env: { ...process.env, ...IDENTITY } });
370
+ } finally {
371
+ await fs.promises.rm(folder, { recursive: true, force: true });
372
+ }
373
+ }
374
+ // The untracked files a snapshot adds: those within `include` that no
375
+ // excluded suffix names, and files staged as new.
376
+ private chosen(untracked: Buffer, staged: string[], include: string[], exclude: readonly string[]) {
377
+ return [...new Set([
378
+ ...untracked.toString().split("\0").filter((p) => p && within(p, include) && !exclude.some((suffix) => p.endsWith(suffix))),
379
+ ...staged.filter((p) => fs.existsSync(path.join(this.root, p))),
380
+ ])].sort();
381
+ }
241
382
  commitWith(
242
383
  parent: string,
243
384
  contents: Record<string, Buffer | null>,
@@ -270,14 +411,37 @@ export class Repo {
270
411
  return this.text(["write-tree"], { env });
271
412
  });
272
413
  }
414
+ /** `treeWith` for servers: one index update for every file. */
415
+ async treeWithAsync(parent: string, contents: Record<string, Buffer | null>) {
416
+ const files = Object.keys(contents).sort();
417
+ const folder = await fs.promises.mkdtemp(path.join(os.tmpdir(), "latex-review-index-"));
418
+ try {
419
+ const env = { ...process.env, GIT_INDEX_FILE: path.join(folder, "index") };
420
+ await this.runAsync(["read-tree", parent], { env });
421
+ if (!files.length) return await this.textAsync(["write-tree"], { env });
422
+ const modes = new Map<string, string>(), absent = "0".repeat((await this.textAsync(["rev-parse", `${parent}^{tree}`])).length);
423
+ for (const entry of (await this.runAsync(["ls-tree", "-z", parent, "--", ...files])).toString().split("\0").filter(Boolean))
424
+ modes.set(entry.slice(entry.indexOf("\t") + 1), entry.split(" ")[0]);
425
+ const lines = await Promise.all(files.map(async (file) => {
426
+ const data = contents[file];
427
+ if (data === null) return `0 ${absent}\t${file}`;
428
+ return `${modes.get(file) || "100644"} ${await this.textAsync(["hash-object", "-w", "--stdin"], { input: data })}\t${file}`;
429
+ }));
430
+ await this.runAsync(["update-index", "--index-info"], { env, input: lines.join("\n") + "\n" });
431
+ return await this.textAsync(["write-tree"], { env });
432
+ } finally {
433
+ await fs.promises.rm(folder, { recursive: true, force: true });
434
+ }
435
+ }
273
436
  // Write a revision's files under destination. Given an earlier export of
274
437
  // another tree, files with the same Git object are cloned from it
275
438
  // (copy-on-write where the file system supports it), so only changed files
276
439
  // are read from Git.
277
440
  async export(rev: string, folder: string, destination: string, base?: { root: string; tree: string }) {
278
441
  const previous = new Map<string, string>();
279
- if (base) for (const entry of this.entries(base.tree)) previous.set(entry.file, entry.mode + " " + entry.oid);
280
- const wanted = this.entries(rev, folder).map(entry => {
442
+ const [before, after] = await Promise.all([base ? this.entriesAsync(base.tree) : [], this.entriesAsync(rev, folder)]);
443
+ for (const entry of before) previous.set(entry.file, entry.mode + " " + entry.oid);
444
+ const wanted = after.map(entry => {
281
445
  const relative = path.posix.relative(folder || ".", entry.file);
282
446
  cleanPath(relative);
283
447
  return { ...entry, relative, target: path.join(destination, relative) };
@@ -287,50 +451,57 @@ export class Repo {
287
451
  let cloned = false;
288
452
  if (base && process.platform === "darwin" && !fs.existsSync(destination))
289
453
  try {
290
- fs.mkdirSync(path.dirname(destination), { recursive: true });
291
- cloned = run("/bin/cp", ["-c", "-R", base.root, destination], { check: false }).status === 0;
454
+ await fs.promises.mkdir(path.dirname(destination), { recursive: true });
455
+ cloned = (await capture("/bin/cp", ["-c", "-R", base.root, destination], { check: false })).status === 0;
292
456
  }
293
457
  catch { /* Copy file by file below. */ }
294
458
  if (cloned) {
295
459
  const keep = new Set(wanted.map(entry => entry.relative));
296
- (function prune(directory: string, prefix: string) {
297
- for (const item of fs.readdirSync(directory, { withFileTypes: true })) {
460
+ const prune = async (directory: string, prefix: string): Promise<void> => {
461
+ await Promise.all((await fs.promises.readdir(directory, { withFileTypes: true })).map(item => {
298
462
  const relative = prefix + item.name, file = path.join(directory, item.name);
299
- if (item.isDirectory()) prune(file, relative + "/");
300
- else if (!keep.has(relative)) fs.rmSync(file, { force: true });
301
- }
302
- })(destination, "");
463
+ if (item.isDirectory()) return prune(file, relative + "/");
464
+ return keep.has(relative) ? undefined : fs.promises.rm(file, { force: true });
465
+ }));
466
+ };
467
+ await prune(destination, "");
303
468
  } else {
304
- fs.rmSync(destination, { recursive: true, force: true });
305
- fs.mkdirSync(destination, { recursive: true });
469
+ await fs.promises.rm(destination, { recursive: true, force: true });
470
+ await fs.promises.mkdir(destination, { recursive: true });
306
471
  }
307
- const folders = new Set<string>(), entries: typeof wanted = [];
472
+ const folders = new Set<string>(), stale = new Set<(typeof wanted)[number]>();
308
473
  for (const entry of wanted) {
474
+ const directory = path.dirname(entry.target);
475
+ if (!folders.has(directory)) { folders.add(directory); await fs.promises.mkdir(directory, { recursive: true }); }
476
+ }
477
+ // Unchanged files come from the earlier export, a batch at a time.
478
+ const reuse = async (entry: (typeof wanted)[number]) => {
309
479
  const { relative, target } = entry;
310
- if (!folders.has(path.dirname(target))) { fs.mkdirSync(path.dirname(target), { recursive: true }); folders.add(path.dirname(target)); }
311
480
  if (entry.mode !== "120000" && previous.get(relative) === entry.mode + " " + entry.oid) {
312
481
  const source = cloned ? target : path.join(base!.root, relative);
313
482
  try {
314
483
  // A compiler may have rewritten a file in the earlier export.
315
- const stat = fs.lstatSync(source);
484
+ const stat = await fs.promises.lstat(source);
316
485
  if (stat.isFile() && stat.size === entry.size) {
317
- if (!cloned) fs.copyFileSync(source, target, fs.constants.COPYFILE_FICLONE);
318
- if ((stat.mode & 0o777) !== (entry.mode === "100755" ? 0o755 : 0o644)) fs.chmodSync(target, entry.mode === "100755" ? 0o755 : 0o644);
319
- continue;
486
+ if (!cloned) await fs.promises.copyFile(source, target, fs.constants.COPYFILE_FICLONE);
487
+ if ((stat.mode & 0o777) !== (entry.mode === "100755" ? 0o755 : 0o644)) await fs.promises.chmod(target, entry.mode === "100755" ? 0o755 : 0o644);
488
+ return;
320
489
  }
321
490
  } catch { /* Read this file from Git. */ }
322
491
  }
323
492
  // A cloned link or rewritten file is replaced, never written through.
324
- if (cloned) fs.rmSync(target, { force: true });
325
- entries.push(entry);
326
- }
493
+ if (cloned) await fs.promises.rm(target, { force: true });
494
+ stale.add(entry);
495
+ };
496
+ for (let at = 0; at < wanted.length; at += 64) await Promise.all(wanted.slice(at, at + 64).map(reuse));
497
+ const entries = wanted.filter(entry => stale.has(entry));
327
498
  if (!entries.length) return;
328
499
  // Keep one Git process and stream each blob to disk. Collecting the entire
329
500
  // snapshot with blobs() both duplicates memory and hits spawnSync's buffer
330
501
  // limit for ordinary repositories containing PDFs and figures.
331
- const child = spawn(gitBinary(), ["cat-file", "--batch"], {
332
- cwd: this.root, stdio: ["pipe", "pipe", "pipe"],
333
- });
502
+ const child = startTracked(() => spawn(gitBinary(), ["cat-file", "--batch"], {
503
+ cwd: this.root, detached: true, stdio: ["pipe", "pipe", "pipe"],
504
+ }));
334
505
  let stderr = "", timedOut = false;
335
506
  child.stderr.on("data", (data: Buffer) => { stderr = (stderr + data.toString()).slice(-65536); });
336
507
  child.stdin.on("error", () => { /* Report process failures through finished. */ });
@@ -367,16 +538,16 @@ export class Repo {
367
538
  let remaining = Number(size);
368
539
  if (object !== oid || type !== "blob" || !Number.isSafeInteger(remaining) || remaining < 0)
369
540
  throw new GitError(`Git snapshot export could not read: ${file}`);
370
- const linkParts: Buffer[] = [], fd = mode === "120000" ? null : fs.openSync(target, "w", mode === "100755" ? 0o755 : 0o644);
541
+ const linkParts: Buffer[] = [], handle = mode === "120000" ? null : await fs.promises.open(target, "w", mode === "100755" ? 0o755 : 0o644);
371
542
  try {
372
543
  while (remaining) {
373
544
  await available();
374
545
  const length = Math.min(remaining, chunk.length - offset), data = chunk.subarray(offset, offset + length);
375
- if (fd === null) linkParts.push(Buffer.from(data));
376
- else fs.writeFileSync(fd, data);
546
+ if (handle === null) linkParts.push(Buffer.from(data));
547
+ else await handle.write(data);
377
548
  remaining -= length; offset += length;
378
549
  }
379
- } finally { if (fd !== null) fs.closeSync(fd); }
550
+ } finally { await handle?.close(); }
380
551
  await available();
381
552
  if (chunk[offset++] !== 10) throw new GitError(`Git snapshot export returned an invalid file boundary: ${file}`);
382
553
  if (mode === "120000") {
@@ -390,7 +561,7 @@ export class Repo {
390
561
  if (!(await output.next()).done) throw new GitError("Git snapshot export returned unexpected output.");
391
562
  const failure = await finished;
392
563
  if (failure) throw failure;
393
- for (const [target, link] of links) fs.symlinkSync(link, target);
564
+ for (const [target, link] of links) await fs.promises.symlink(link, target);
394
565
  } finally {
395
566
  clearTimeout(timer);
396
567
  if (child.exitCode === null) child.kill("SIGKILL");
@@ -398,6 +569,26 @@ export class Repo {
398
569
  }
399
570
  }
400
571
  }
572
+ /** `merge3` for servers, which keep answering requests meanwhile. */
573
+ export async function merge3Async(
574
+ ours: Buffer | null,
575
+ base: Buffer | null,
576
+ theirs: Buffer | null,
577
+ favorCurrent = false,
578
+ ): Promise<[Buffer | null, boolean]> {
579
+ if (same(ours, base) || same(ours, theirs)) return [theirs, false];
580
+ if (same(theirs, base)) return [ours, false];
581
+ if (ours === null || base === null || theirs === null || [ours, base, theirs].some((b) => b.includes(0))) return [null, true];
582
+ const folder = await fs.promises.mkdtemp(path.join(os.tmpdir(), "latex-review-merge-"));
583
+ try {
584
+ const files = ["ours", "base", "theirs"].map((name) => path.join(folder, name));
585
+ await Promise.all([ours, base, theirs].map((data, i) => fs.promises.writeFile(files[i], data)));
586
+ const result = await capture(gitBinary(), ["merge-file", "-p", ...(favorCurrent ? ["--ours"] : []), ...files], { check: false });
587
+ return [result.stdout, result.status !== 0];
588
+ } finally {
589
+ await fs.promises.rm(folder, { recursive: true, force: true });
590
+ }
591
+ }
401
592
  export function merge3(
402
593
  ours: Buffer | null,
403
594
  base: Buffer | null,