papilot 0.8.5 → 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-DHvVou9A.js → math-CBzEH7Y2.js} +1 -1
- package/dist/assets/{pdf-view-AYTS7kdA.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 +367 -92
- 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/update-install.ts +5 -1
- package/src/server/updates.ts +24 -49
- package/src/server/workspace.ts +18 -0
- package/dist/assets/index-DSWizoaI.js +0 -267
- package/dist/assets/index-PFQDdu53.css +0 -1
|
@@ -20,10 +20,27 @@ export function diffUnits(
|
|
|
20
20
|
after: string,
|
|
21
21
|
isFigure: (p: string) => boolean = () => false,
|
|
22
22
|
): Unit[] {
|
|
23
|
-
const files = repo.changedFiles(before, after)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
const files = repo.changedFiles(before, after);
|
|
24
|
+
return unitsOf(files, repo.blobs(before, files), repo.blobs(after, files), isFigure);
|
|
25
|
+
}
|
|
26
|
+
/** `diffUnits` for servers, which keep answering requests meanwhile. */
|
|
27
|
+
export async function diffUnitsAsync(
|
|
28
|
+
repo: Repo,
|
|
29
|
+
before: string,
|
|
30
|
+
after: string,
|
|
31
|
+
isFigure: (p: string) => boolean = () => false,
|
|
32
|
+
): Promise<Unit[]> {
|
|
33
|
+
const files = await repo.changedFilesAsync(before, after),
|
|
34
|
+
[old, next] = await Promise.all([repo.blobsAsync(before, files), repo.blobsAsync(after, files)]);
|
|
35
|
+
return unitsOf(files, old, next, isFigure);
|
|
36
|
+
}
|
|
37
|
+
function unitsOf(
|
|
38
|
+
files: string[],
|
|
39
|
+
oldBlobs: Record<string, Buffer | null>,
|
|
40
|
+
newBlobs: Record<string, Buffer | null>,
|
|
41
|
+
isFigure: (p: string) => boolean,
|
|
42
|
+
): Unit[] {
|
|
43
|
+
const units: Unit[] = [];
|
|
27
44
|
for (const file of files) {
|
|
28
45
|
const old = oldBlobs[file],
|
|
29
46
|
next = newBlobs[file],
|
|
@@ -159,15 +176,27 @@ export function scopeViolations(
|
|
|
159
176
|
scope: Scope,
|
|
160
177
|
) {
|
|
161
178
|
const files = repo.changedFiles(base, revision);
|
|
162
|
-
if (scope.ranges === null)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
179
|
+
if (scope.ranges === null) return files.filter((f) => !within(f, scope.paths));
|
|
180
|
+
return violationsOf(files, repo.blobs(base, files), repo.blobs(revision, files), scope);
|
|
181
|
+
}
|
|
182
|
+
/** `scopeViolations` for servers, which keep answering requests meanwhile. */
|
|
183
|
+
export async function scopeViolationsAsync(repo: Repo, base: string, revision: string, scope: Scope) {
|
|
184
|
+
const files = await repo.changedFilesAsync(base, revision);
|
|
185
|
+
if (scope.ranges === null) return files.filter((f) => !within(f, scope.paths));
|
|
186
|
+
const [old, next] = await Promise.all([repo.blobsAsync(base, files), repo.blobsAsync(revision, files)]);
|
|
187
|
+
return violationsOf(files, old, next, scope);
|
|
188
|
+
}
|
|
189
|
+
function violationsOf(
|
|
190
|
+
files: string[],
|
|
191
|
+
old: Record<string, Buffer | null>,
|
|
192
|
+
next: Record<string, Buffer | null>,
|
|
193
|
+
scope: Scope,
|
|
194
|
+
) {
|
|
195
|
+
const violations: string[] = [];
|
|
167
196
|
for (const file of files) {
|
|
168
197
|
const a = textOf(old[file]),
|
|
169
198
|
b = textOf(next[file]),
|
|
170
|
-
allowed = scope.ranges[file];
|
|
199
|
+
allowed = scope.ranges?.[file];
|
|
171
200
|
if (!allowed || old[file] === null || a === null || b === null) {
|
|
172
201
|
violations.push(file);
|
|
173
202
|
continue;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { Repo, cleanPath, within } from "./git.ts";
|
|
3
|
+
import { Repo, cleanPath, gitDirOf, within } from "./git.ts";
|
|
4
4
|
export const CONFIG_NAME = ".latex-review.json";
|
|
5
5
|
export const ENGINES = ["pdflatex", "xelatex", "lualatex"] as const;
|
|
6
6
|
type Engine = (typeof ENGINES)[number];
|
|
@@ -244,7 +244,7 @@ export function loadProject(root: string): Project {
|
|
|
244
244
|
throw new Error('"build_workers" must be an integer between 1 and 8.');
|
|
245
245
|
return {
|
|
246
246
|
root,
|
|
247
|
-
state: path.join(
|
|
247
|
+
state: path.join(gitDirOf(root), "latex-review"),
|
|
248
248
|
main,
|
|
249
249
|
doc_dir,
|
|
250
250
|
engine: engine as Engine,
|
|
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import type { Repo } from "./git.ts";
|
|
6
|
-
import {
|
|
6
|
+
import { merge3Async } from "./git.ts";
|
|
7
7
|
import type { Delivery } from "../types.ts";
|
|
8
8
|
|
|
9
9
|
export interface CombinedCommitReview { token: string; files: string[]; diff: string }
|
|
@@ -13,35 +13,35 @@ export class CommitNeedsReview extends Error {
|
|
|
13
13
|
|
|
14
14
|
// Project only the task delta onto HEAD. The worktree and staging area are
|
|
15
15
|
// separate destinations: neither is used as the content of the commit.
|
|
16
|
-
export function planCurrentCommit(repo: Repo, data: Delivery, before: string, files: string[], message: string, confirmation?: string) {
|
|
17
|
-
const ref = repo.
|
|
16
|
+
export async function planCurrentCommit(repo: Repo, data: Delivery, before: string, files: string[], message: string, confirmation?: string) {
|
|
17
|
+
const ref = await repo.textAsync(["symbolic-ref", "-q", "HEAD"], { check: false });
|
|
18
18
|
if (!ref.startsWith("refs/heads/")) throw new Error("Open a branch to commit this task, or apply it without committing.");
|
|
19
|
-
const parent = repo.
|
|
20
|
-
const base
|
|
19
|
+
const [parent, index] = await Promise.all([repo.revAsync("HEAD"), repo.textAsync(["write-tree"])]);
|
|
20
|
+
const [base, accepted, head, staged] = await Promise.all([before, data.accepted, parent, index].map((rev) => repo.blobsAsync(rev, files)));
|
|
21
21
|
const committed: Record<string, Buffer | null> = {}, nextIndex: Record<string, Buffer | null> = {};
|
|
22
22
|
const dependencies: string[] = [];
|
|
23
23
|
for (const file of files) {
|
|
24
|
-
let [content, conflict] =
|
|
24
|
+
let [content, conflict] = await merge3Async(head[file], base[file], accepted[file]);
|
|
25
25
|
if (conflict) {
|
|
26
26
|
// Include only the overlapping earlier passages, never the whole dirty
|
|
27
27
|
// file. This is a proposal until the author confirms its exact diff.
|
|
28
|
-
[content, conflict] =
|
|
28
|
+
[content, conflict] = await merge3Async(accepted[file], base[file], head[file], true);
|
|
29
29
|
if (conflict) throw new Error(`The earlier changes in ${file} cannot be included automatically. Apply to workspace to save your reviewed result without a commit.`);
|
|
30
30
|
dependencies.push(file);
|
|
31
31
|
}
|
|
32
|
-
let [combined, indexConflict] =
|
|
33
|
-
if (indexConflict && dependencies.includes(file) && !
|
|
34
|
-
[combined, indexConflict] =
|
|
32
|
+
let [combined, indexConflict] = await merge3Async(staged[file], head[file], content);
|
|
33
|
+
if (indexConflict && dependencies.includes(file) && !(await merge3Async(staged[file], base[file], accepted[file]))[1])
|
|
34
|
+
[combined, indexConflict] = await merge3Async(content, head[file], staged[file], true);
|
|
35
35
|
if (indexConflict) throw new Error(`A different version of ${file} is already selected for another commit. Apply to workspace to save this result without changing that selection.`);
|
|
36
36
|
committed[file] = content;
|
|
37
37
|
nextIndex[file] = combined;
|
|
38
38
|
}
|
|
39
|
-
const tree = repo.
|
|
40
|
-
if (tree === repo.
|
|
39
|
+
const tree = await repo.treeWithAsync(parent, committed);
|
|
40
|
+
if (tree === await repo.treeIdAsync(parent)) throw new Error("These changes are already in the current commit. Apply without committing to finish this task.");
|
|
41
41
|
if (dependencies.length || confirmation) {
|
|
42
42
|
const token = createHash("sha256").update(JSON.stringify([ref, parent, index, before, data.accepted, tree, data.writes])).digest("hex");
|
|
43
43
|
if (confirmation !== token) {
|
|
44
|
-
const diff = repo.
|
|
44
|
+
const diff = await repo.textAsync(["diff", "--no-ext-diff", "--no-textconv", parent, tree, "--"]);
|
|
45
45
|
if (diff.length > 500000) throw new Error("The combined changes are too large to preview here. Apply to workspace and review them in Git before committing.");
|
|
46
46
|
throw new CommitNeedsReview({ token, files: dependencies, diff });
|
|
47
47
|
}
|
|
@@ -49,56 +49,56 @@ export function planCurrentCommit(repo: Repo, data: Delivery, before: string, fi
|
|
|
49
49
|
data.branch = ref.slice("refs/heads/".length);
|
|
50
50
|
data.parent = parent;
|
|
51
51
|
data.indexBefore = index;
|
|
52
|
-
data.indexAfter = repo.
|
|
53
|
-
data.commit = repo.
|
|
52
|
+
data.indexAfter = await repo.treeWithAsync(index, nextIndex);
|
|
53
|
+
data.commit = await repo.textAsync(["commit-tree", tree, "-p", parent, "-m", message.trim()]);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
// Hold Git's index lock while checking and installing the recorded result.
|
|
57
57
|
// CAS on the branch prevents updating the wrong or concurrently moved HEAD.
|
|
58
58
|
// A retry recognizes our commit and completes its files/index without a
|
|
59
59
|
// duplicate commit. Unrelated staged entries (including their flags) survive.
|
|
60
|
-
export function finishCurrentCommit(repo: Repo, data: Delivery, validateFiles: () => void, writeFiles: () => void) {
|
|
61
|
-
const index = path.resolve(repo.root, repo.
|
|
60
|
+
export async function finishCurrentCommit(repo: Repo, data: Delivery, validateFiles: () => void, writeFiles: () => void) {
|
|
61
|
+
const index = path.resolve(repo.root, await repo.textAsync(["rev-parse", "--git-path", "index"]));
|
|
62
62
|
const lock = index + ".lock";
|
|
63
|
-
let
|
|
64
|
-
try {
|
|
63
|
+
let handle: fs.promises.FileHandle;
|
|
64
|
+
try { handle = await fs.promises.open(lock, "wx", fs.existsSync(index) ? fs.statSync(index).mode & 0o777 : 0o644); }
|
|
65
65
|
catch (error) {
|
|
66
66
|
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error("Another Git operation is using the staging area. Wait for it to finish, then retry.");
|
|
67
67
|
throw error;
|
|
68
68
|
}
|
|
69
69
|
let temp: string | undefined;
|
|
70
70
|
try {
|
|
71
|
-
temp = fs.
|
|
71
|
+
temp = await fs.promises.mkdtemp(path.join(os.tmpdir(), "papilot-task-index-"));
|
|
72
72
|
const ref = `refs/heads/${data.branch}`;
|
|
73
|
-
if (repo.
|
|
73
|
+
if (await repo.textAsync(["symbolic-ref", "-q", "HEAD"], { check: false }) !== ref)
|
|
74
74
|
throw new Error("The current branch changed. Return to the original branch before retrying this task commit.");
|
|
75
|
-
const head = repo.
|
|
75
|
+
const head = await repo.revAsync("HEAD");
|
|
76
76
|
if (head !== data.parent && head !== data.commit)
|
|
77
77
|
throw new Error("The current branch moved during this task commit. Nothing else was changed; inspect the Git history before retrying.");
|
|
78
78
|
const target = path.join(temp, "index");
|
|
79
|
-
fs.
|
|
79
|
+
await fs.promises.copyFile(index, target);
|
|
80
80
|
const env = { ...process.env, GIT_INDEX_FILE: target };
|
|
81
|
-
const currentIndex = repo.
|
|
81
|
+
const currentIndex = await repo.textAsync(["write-tree"], { env });
|
|
82
82
|
if (currentIndex !== data.indexBefore && currentIndex !== data.indexAfter)
|
|
83
83
|
throw new Error("The staged changes changed during this task commit. Nothing else was changed; restore the previous staging selection before retrying.");
|
|
84
84
|
validateFiles();
|
|
85
85
|
if (currentIndex !== data.indexAfter) {
|
|
86
|
-
const changed = repo.
|
|
86
|
+
const changed = await repo.changedFilesAsync(data.indexBefore!, data.indexAfter!);
|
|
87
87
|
for (const file of changed) {
|
|
88
|
-
const blob = repo.
|
|
89
|
-
if (blob === null) repo.
|
|
90
|
-
else repo.
|
|
88
|
+
const blob = await repo.blobAsync(data.indexAfter!, file);
|
|
89
|
+
if (blob === null) await repo.runAsync(["update-index", "--force-remove", "--", file], { env });
|
|
90
|
+
else await repo.runAsync(["update-index", "--add", "--cacheinfo", `${await repo.modeAsync(data.indexAfter!, file)},${await repo.hashObjectAsync(blob)},${file}`], { env });
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
-
if (repo.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (head !== data.commit) repo.
|
|
93
|
+
if (await repo.textAsync(["write-tree"], { env }) !== data.indexAfter) throw new Error("Could not prepare the task's staging result.");
|
|
94
|
+
await handle.writeFile(await fs.promises.readFile(target));
|
|
95
|
+
await handle.sync();
|
|
96
|
+
if (head !== data.commit) await repo.runAsync(["update-ref", "-m", "Papilot: commit reviewed task", ref, data.commit!, data.parent!]);
|
|
97
97
|
writeFiles();
|
|
98
98
|
fs.renameSync(lock, index);
|
|
99
99
|
} finally {
|
|
100
|
-
|
|
100
|
+
await handle.close();
|
|
101
101
|
fs.rmSync(lock, { force: true });
|
|
102
|
-
if (temp) fs.
|
|
102
|
+
if (temp) await fs.promises.rm(temp, { recursive: true, force: true });
|
|
103
103
|
}
|
|
104
104
|
}
|
|
@@ -2,7 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { App } from "../server.ts";
|
|
4
4
|
import type { Delivery } from "../types.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { merge3Async } from "./git.ts";
|
|
6
|
+
import { applyUnits } from "./changes.ts";
|
|
6
7
|
import { readBytes, same, writeOrDelete } from "./files.ts";
|
|
7
8
|
import { planCurrentCommit, finishCurrentCommit, CommitNeedsReview } from "./current-commit.ts";
|
|
8
9
|
import { safePath } from "./review.ts";
|
|
@@ -23,11 +24,57 @@ export class Deliveries {
|
|
|
23
24
|
save(name: string, data: Delivery) {
|
|
24
25
|
this.app.store.execute("INSERT OR REPLACE INTO deliveries(task,data) VALUES(?,?)", name, JSON.stringify(data));
|
|
25
26
|
}
|
|
27
|
+
/** What applying this task to the manuscript would write, if every change
|
|
28
|
+
* still pending in its latest round were accepted first: the new contents
|
|
29
|
+
* of each file it changes, merged with the files on disk exactly as an
|
|
30
|
+
* application merges them. Null while the result cannot be foreseen: a
|
|
31
|
+
* delivery or merge review is in progress, or a file would conflict. */
|
|
32
|
+
async preview(name: string): Promise<Record<string, Buffer | null> | null> {
|
|
33
|
+
const app = this.app, task = app.tasks.get(name), last = this.get(name), reconciliation = app.reconciliation.get(name);
|
|
34
|
+
if (!task.deferred || (last && !last.done) || (reconciliation && !reconciliation.ready) || app.publisher.busy(name)) return null;
|
|
35
|
+
const before = reconciliation?.base ?? (last && last.mode !== "branch" ? last.accepted : task.base);
|
|
36
|
+
// The latest round's pending changes, accepted onto the accepted text.
|
|
37
|
+
let accepted = task.accepted;
|
|
38
|
+
const round = app.review.latestRound(name);
|
|
39
|
+
if (round) {
|
|
40
|
+
const decisions = app.review.decisions(name, round), groups: Record<string, typeof round.units> = {};
|
|
41
|
+
for (const unit of round.units) if (decisions[unit.id] === "pending") (groups[unit.file] ??= []).push(unit);
|
|
42
|
+
const files = Object.keys(groups);
|
|
43
|
+
if (files.length) {
|
|
44
|
+
const [old, next, records] = await Promise.all([round.before, round.after, task.accepted].map((rev) => app.repo.blobsAsync(rev, files)));
|
|
45
|
+
const contents: Record<string, Buffer | null> = {};
|
|
46
|
+
for (const file of files) {
|
|
47
|
+
const [merged, conflict] = await merge3Async(records[file], old[file], applyUnits(old[file], next[file], groups[file]));
|
|
48
|
+
if (conflict) return null;
|
|
49
|
+
contents[file] = merged;
|
|
50
|
+
}
|
|
51
|
+
accepted = await app.repo.treeWithAsync(task.accepted, contents);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const files = await app.repo.changedFilesAsync(before, accepted);
|
|
55
|
+
const [base, result] = await Promise.all([app.repo.blobsAsync(before, files), app.repo.blobsAsync(accepted, files)]);
|
|
56
|
+
const writes: Record<string, Buffer | null> = {};
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const current = await fs.promises.readFile(safePath(app.root, file)).catch((error) => {
|
|
59
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
60
|
+
throw error;
|
|
61
|
+
});
|
|
62
|
+
const [merged, conflict] = await merge3Async(current, base[file], result[file]);
|
|
63
|
+
if (conflict) return null;
|
|
64
|
+
writes[file] = merged;
|
|
65
|
+
}
|
|
66
|
+
return writes;
|
|
67
|
+
}
|
|
26
68
|
assertReady(name: string) {
|
|
27
69
|
const last = this.get(name);
|
|
28
70
|
if (last && !last.done) throw new Error("Finish the pending delivery before changing this review. Retry the same destination.");
|
|
29
71
|
}
|
|
72
|
+
/** Applies the task to the manuscript, with or without a commit. Runs
|
|
73
|
+
* after every other write of the review engine, never beside one. */
|
|
30
74
|
apply(name: string, keepCurrent = false, mode?: "workspace" | "commit", message?: string, confirmation?: string) {
|
|
75
|
+
return this.app.exclusive(() => this.applyNow(name, keepCurrent, mode, message, confirmation));
|
|
76
|
+
}
|
|
77
|
+
private async applyNow(name: string, keepCurrent = false, mode?: "workspace" | "commit", message?: string, confirmation?: string) {
|
|
31
78
|
const active = this.app.reconciliation.get(name);
|
|
32
79
|
mode ??= active?.intent?.mode ?? "workspace";
|
|
33
80
|
message ??= active?.intent?.message ?? "";
|
|
@@ -35,27 +82,31 @@ export class Deliveries {
|
|
|
35
82
|
if (mode === "commit" && (!message.trim() || message.length > 5000)) throw new Error("Write a commit message (at most 5,000 characters).");
|
|
36
83
|
const last = this.get(name);
|
|
37
84
|
if (last && !last.done) {
|
|
38
|
-
const delivery = this.finish(name, last);
|
|
85
|
+
const delivery = await this.finish(name, last);
|
|
39
86
|
if (last.mode !== "branch") return { delivery };
|
|
40
87
|
}
|
|
41
88
|
if (keepCurrent) {
|
|
42
89
|
const task = this.app.tasks.get(name);
|
|
43
|
-
if (!active?.ready || task.accepted !== active.reviewBase || this.app.repo.
|
|
90
|
+
if (!active?.ready || task.accepted !== active.reviewBase || (await this.app.repo.changedFilesAsync(task.accepted, await this.app.tasks.snapshotAsync(task))).length)
|
|
44
91
|
throw new Error("Review the merge suggestion before applying it, or ask the agent to keep the latest wording.");
|
|
45
92
|
}
|
|
46
93
|
if (active) this.app.reconciliation.intent(name, { mode, message });
|
|
47
94
|
if (!keepCurrent && active && (!active.ready || (this.app.review.latestRound(name)?.number ?? 0) <= active.round))
|
|
48
|
-
return { reconciliation: this.app.reconciliation.prepare(name, this.app.reconciliation.get(name)!) };
|
|
49
|
-
try { return { delivery: this.
|
|
95
|
+
return { reconciliation: await this.app.reconciliation.prepare(name, this.app.reconciliation.get(name)!) };
|
|
96
|
+
try { return { delivery: await this.deliverNow(name, mode, message, keepCurrent, confirmation) }; }
|
|
50
97
|
catch (error) {
|
|
51
98
|
if (error instanceof CommitNeedsReview) return { commitReview: error.review };
|
|
52
99
|
if (!(error instanceof DeliveryConflict)) throw error;
|
|
53
|
-
const reconciliation = this.app.reconciliation.start(name, keepCurrent, { mode, message });
|
|
100
|
+
const reconciliation = await this.app.reconciliation.start(name, keepCurrent, { mode, message });
|
|
54
101
|
if (reconciliation) return { reconciliation };
|
|
55
|
-
return { delivery: this.
|
|
102
|
+
return { delivery: await this.deliverNow(name, mode, message, keepCurrent, confirmation) };
|
|
56
103
|
}
|
|
57
104
|
}
|
|
105
|
+
/** Delivers the accepted changes to the working directory or a task branch. */
|
|
58
106
|
deliver(name: string, mode: Delivery["mode"], message = "", keepCurrent = false, confirmation?: string) {
|
|
107
|
+
return this.app.exclusive(() => this.deliverNow(name, mode, message, keepCurrent, confirmation));
|
|
108
|
+
}
|
|
109
|
+
private async deliverNow(name: string, mode: Delivery["mode"], message = "", keepCurrent = false, confirmation?: string) {
|
|
59
110
|
const app = this.app, task = app.tasks.get(name), last = this.get(name);
|
|
60
111
|
if (!task.deferred) throw new Error("This task already applies accepted changes to the manuscript.");
|
|
61
112
|
if (mode !== "workspace" && mode !== "commit" && mode !== "branch") throw new Error("Choose a working directory or task branch.");
|
|
@@ -69,9 +120,9 @@ export class Deliveries {
|
|
|
69
120
|
if (last && last.mode === mode && (!last.done || (!reconciliation && last.accepted === task.accepted))) return this.finish(name, last);
|
|
70
121
|
if (app.publisher.busy(name) || app.review.counts(name).pending) throw new Error("Finish reviewing every change before choosing a destination.");
|
|
71
122
|
const before = reconciliation?.base ?? (last && last.mode !== "branch" ? last.accepted : task.base);
|
|
72
|
-
const files = app.repo.
|
|
123
|
+
const files = await app.repo.changedFilesAsync(before, task.accepted);
|
|
73
124
|
if (!files.length && !reconciliation) throw new Error("This task has no accepted changes left to deliver.");
|
|
74
|
-
const base = app.repo.
|
|
125
|
+
const [base, accepted] = await Promise.all([app.repo.blobsAsync(before, files), app.repo.blobsAsync(task.accepted, files)]);
|
|
75
126
|
const data: Delivery = { mode, accepted: task.accepted, seq: app.store.one<{ seq: number }>("SELECT MAX(seq) AS seq FROM actions WHERE task=?", name)!.seq, done: false, writes: [] };
|
|
76
127
|
const contents: Record<string, Buffer | null> = {};
|
|
77
128
|
let current: Record<string, Buffer | null>;
|
|
@@ -81,50 +132,51 @@ export class Deliveries {
|
|
|
81
132
|
data.previous = last?.commit;
|
|
82
133
|
// The base snapshot may include the author's uncommitted edits. Apply
|
|
83
134
|
// only the accepted delta onto its original HEAD, never commit those edits.
|
|
84
|
-
data.parent = last?.commit ?? app.repo.
|
|
85
|
-
current = app.repo.
|
|
86
|
-
const ref = app.repo.
|
|
135
|
+
data.parent = last?.commit ?? await app.repo.revAsync(`${task.base}^`);
|
|
136
|
+
current = await app.repo.blobsAsync(data.parent, files);
|
|
137
|
+
const ref = await app.repo.textAsync(["show-ref", "--verify", "--hash", `refs/heads/${data.branch}`], { check: false });
|
|
87
138
|
if (ref !== (data.previous ?? "")) throw new Error("The task branch was changed outside Papilot. Nothing was committed.");
|
|
88
|
-
this.assertBranchAvailable(data.branch);
|
|
139
|
+
await this.assertBranchAvailable(data.branch);
|
|
89
140
|
} else {
|
|
90
|
-
this.assertNoMerge();
|
|
141
|
+
await this.assertNoMerge();
|
|
91
142
|
current = Object.fromEntries(files.map(file => [file, readBytes(safePath(app.root, file))]));
|
|
92
143
|
}
|
|
93
144
|
for (const file of files) {
|
|
94
|
-
const [merged, conflict] =
|
|
145
|
+
const [merged, conflict] = await merge3Async(current[file], base[file], accepted[file]);
|
|
95
146
|
if (conflict) throw new DeliveryConflict(`Cannot deliver ${file}: it conflicts with the destination. Nothing was written. Revise the task and try again.`);
|
|
96
147
|
contents[file] = merged;
|
|
97
148
|
if (mode !== "branch" && !same(current[file], merged)) data.writes.push({ file, before: encode(current[file]), after: encode(merged) });
|
|
98
149
|
}
|
|
99
150
|
if (mode === "branch") {
|
|
100
|
-
const tree = app.repo.
|
|
101
|
-
data.commit = app.repo.
|
|
151
|
+
const tree = await app.repo.treeWithAsync(data.parent!, contents);
|
|
152
|
+
data.commit = await app.repo.textAsync(["commit-tree", tree, "-p", data.parent!, "-m", message.trim()]);
|
|
102
153
|
}
|
|
103
154
|
if (mode === "commit" && (!message.trim() || message.length > 5000)) throw new Error("Write a commit message (at most 5,000 characters).");
|
|
104
|
-
if (mode === "commit") planCurrentCommit(app.repo, data, before, files, message, confirmation);
|
|
155
|
+
if (mode === "commit") await planCurrentCommit(app.repo, data, before, files, message, confirmation);
|
|
105
156
|
this.save(name, data);
|
|
106
157
|
return this.finish(name, data);
|
|
107
158
|
}
|
|
108
|
-
private assertBranchAvailable(branch: string) {
|
|
109
|
-
if (this.app.repo.
|
|
159
|
+
private async assertBranchAvailable(branch: string) {
|
|
160
|
+
if ((await this.app.repo.textAsync(["worktree", "list", "--porcelain"])).split("\n").includes(`branch refs/heads/${branch}`))
|
|
110
161
|
throw new Error("The task branch is checked out. Switch that worktree to another branch before committing here.");
|
|
111
162
|
}
|
|
112
|
-
private assertNoMerge() {
|
|
113
|
-
if (fs.existsSync(path.resolve(this.app.root, this.app.repo.
|
|
163
|
+
private async assertNoMerge() {
|
|
164
|
+
if (fs.existsSync(path.resolve(this.app.root, await this.app.repo.textAsync(["rev-parse", "--git-path", "MERGE_HEAD"]))))
|
|
114
165
|
throw new Error("Finish or abort the merge before applying changes.");
|
|
115
166
|
}
|
|
116
|
-
|
|
167
|
+
// Completes a saved delivery. Callers hold the engine's write turn.
|
|
168
|
+
private async finish(name: string, data: Delivery) {
|
|
117
169
|
if (data.done) { if (data.mode !== "branch") this.app.reconciliation.clear(name); return this.result(data); }
|
|
118
170
|
const app = this.app;
|
|
119
171
|
if (data.mode === "branch") {
|
|
120
172
|
const ref = `refs/heads/${data.branch}`;
|
|
121
|
-
const current = app.repo.
|
|
173
|
+
const current = await app.repo.textAsync(["show-ref", "--verify", "--hash", ref], { check: false });
|
|
122
174
|
if (current !== data.commit) {
|
|
123
|
-
this.assertBranchAvailable(data.branch!);
|
|
124
|
-
app.repo.
|
|
175
|
+
await this.assertBranchAvailable(data.branch!);
|
|
176
|
+
await app.repo.runAsync(["update-ref", ref, data.commit!, data.previous ?? "0".repeat(data.commit!.length)]);
|
|
125
177
|
}
|
|
126
178
|
} else {
|
|
127
|
-
this.assertNoMerge();
|
|
179
|
+
await this.assertNoMerge();
|
|
128
180
|
const writes = data.writes.map(write => ({ ...write, target: safePath(app.root, write.file) }));
|
|
129
181
|
const validate = () => {
|
|
130
182
|
for (const write of writes) {
|
|
@@ -134,7 +186,7 @@ export class Deliveries {
|
|
|
134
186
|
}
|
|
135
187
|
};
|
|
136
188
|
const applyFiles = () => { for (const write of writes) writeOrDelete(write.target, decode(write.after)); };
|
|
137
|
-
if (data.mode === "commit") finishCurrentCommit(app.repo, data, validate, applyFiles);
|
|
189
|
+
if (data.mode === "commit") await finishCurrentCommit(app.repo, data, validate, applyFiles);
|
|
138
190
|
else { validate(); applyFiles(); }
|
|
139
191
|
}
|
|
140
192
|
this.save(name, { ...data, done: true, writes: [] });
|
|
@@ -145,11 +197,12 @@ export class Deliveries {
|
|
|
145
197
|
const { mode, accepted, branch, commit } = data;
|
|
146
198
|
return { mode, accepted, ...(branch ? { branch, commit } : {}) };
|
|
147
199
|
}
|
|
148
|
-
|
|
200
|
+
// At start, before any other write.
|
|
201
|
+
async recover() {
|
|
149
202
|
for (const row of this.app.store.all<{ task: string; data: Delivery }>("SELECT * FROM deliveries")) {
|
|
150
203
|
if (row.data.done && row.data.mode !== "branch" && this.app.reconciliation.get(row.task) && this.app.tasks.get(row.task).accepted === row.data.accepted)
|
|
151
204
|
this.app.reconciliation.clear(row.task);
|
|
152
|
-
else if (!row.data.done) try { this.finish(row.task, row.data); } catch { /* Retain the journal for an explicit retry. */ }
|
|
205
|
+
else if (!row.data.done) try { await this.finish(row.task, row.data); } catch { /* Retain the journal for an explicit retry. */ }
|
|
153
206
|
}
|
|
154
207
|
}
|
|
155
208
|
}
|
package/engine/src/core/files.ts
CHANGED
|
@@ -29,6 +29,25 @@ export function atomicWrite(
|
|
|
29
29
|
fs.rmSync(temporary, { force: true });
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
/** `atomicWrite` without blocking the event loop on the disk. */
|
|
33
|
+
export async function atomicWriteAsync(file: string, data: string | Uint8Array) {
|
|
34
|
+
await fs.promises.mkdir(path.dirname(file), { recursive: true });
|
|
35
|
+
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${randomUUID()}.tmp`);
|
|
36
|
+
try {
|
|
37
|
+
const handle = await fs.promises.open(temporary, "w");
|
|
38
|
+
try {
|
|
39
|
+
await handle.writeFile(data);
|
|
40
|
+
await handle.sync();
|
|
41
|
+
} finally {
|
|
42
|
+
await handle.close();
|
|
43
|
+
}
|
|
44
|
+
await fs.promises.rename(temporary, file);
|
|
45
|
+
} finally {
|
|
46
|
+
await fs.promises.rm(temporary, { force: true });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export const writeJsonAsync = (file: string, value: unknown) =>
|
|
50
|
+
atomicWriteAsync(file, JSON.stringify(value, null, 2) + "\n");
|
|
32
51
|
export function readBytes(file: string): Buffer | null {
|
|
33
52
|
try {
|
|
34
53
|
return fs.readFileSync(file);
|