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.
- package/dist/assets/index-BIc9Mnim.css +1 -0
- package/dist/assets/index-MvQvvv3I.js +267 -0
- package/dist/assets/{math-C7ENJr-a.js → math-CBzEH7Y2.js} +1 -1
- package/dist/assets/{pdf-view-B2xQuHmI.js → pdf-view-ByftRBhD.js} +2 -2
- package/dist/index.html +2 -2
- package/engine/dist/web/assets/{FullPaper-DlgZ1MOz.js → FullPaper-DWwiT43p.js} +2 -2
- package/engine/dist/web/assets/{index-BCgVChtV.js → index-DBYJK8Uy.js} +4 -4
- package/engine/dist/web/assets/{index-D3_ZFdT8.css → index-DGkq8xQx.css} +1 -1
- package/engine/dist/web/index.html +2 -2
- package/engine/src/cli.ts +3 -0
- package/engine/src/client.ts +24 -7
- package/engine/src/core/build.ts +357 -93
- package/engine/src/core/changes.ts +39 -10
- package/engine/src/core/config.ts +2 -2
- package/engine/src/core/current-commit.ts +34 -34
- package/engine/src/core/delivery.ts +83 -30
- package/engine/src/core/files.ts +19 -0
- package/engine/src/core/git.ts +283 -92
- package/engine/src/core/process.ts +124 -11
- package/engine/src/core/publish.ts +59 -50
- package/engine/src/core/reconcile.ts +23 -21
- package/engine/src/core/review.ts +43 -24
- package/engine/src/core/tasks.ts +85 -28
- package/engine/src/core/view.ts +8 -8
- package/engine/src/server.ts +62 -6
- package/engine/src/types.ts +8 -0
- package/engine/src/web/FullPaper.tsx +122 -34
- package/engine/src/web/review.tsx +4 -1
- package/engine/src/web/style.css +24 -0
- package/package.json +1 -1
- package/src/server/agents.ts +3 -0
- package/src/server/annotated-pdf.ts +392 -0
- package/src/server/annotation-placement.ts +18 -3
- package/src/server/app.ts +68 -10
- package/src/server/changes.ts +12 -7
- package/src/server/cursor.ts +1 -0
- package/src/server/manuscript.ts +218 -56
- package/src/server/pdf.ts +176 -23
- package/src/server/references.ts +10 -2
- package/src/server/review.ts +6 -2
- package/src/server/start.ts +4 -1
- package/src/server/workspace.ts +18 -0
- package/dist/assets/index-Dm7AYM9e.js +0 -267
- package/dist/assets/index-PFQDdu53.css +0 -1
package/engine/src/core/git.ts
CHANGED
|
@@ -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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
"
|
|
194
|
-
|
|
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
|
-
|
|
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
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
const
|
|
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.
|
|
291
|
-
cloned =
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
301
|
-
}
|
|
302
|
-
}
|
|
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.
|
|
305
|
-
fs.
|
|
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>(),
|
|
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.
|
|
484
|
+
const stat = await fs.promises.lstat(source);
|
|
316
485
|
if (stat.isFile() && stat.size === entry.size) {
|
|
317
|
-
if (!cloned) fs.
|
|
318
|
-
if ((stat.mode & 0o777) !== (entry.mode === "100755" ? 0o755 : 0o644)) fs.
|
|
319
|
-
|
|
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.
|
|
325
|
-
|
|
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[] = [],
|
|
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 (
|
|
376
|
-
else
|
|
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 {
|
|
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.
|
|
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,
|