overleaf-forge 2.9.1 → 2.11.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "overleaf-forge",
3
- "version": "2.9.1",
3
+ "version": "2.11.0",
4
4
  "description": "MCP server to read, edit, compile, and verify Overleaf/LaTeX projects over git: conflict-safe edits, figure upload, clean-build verification, citation and voice linting.",
5
5
  "type": "module",
6
6
  "main": "overleaf-mcp-server.js",
@@ -12,7 +12,12 @@
12
12
  "templates/",
13
13
  "examples/",
14
14
  "writing-guidelines.md",
15
- "projects.example.json"
15
+ "projects.example.json",
16
+ "efficiency.js",
17
+ "dependency-index.js",
18
+ "render-cache.js",
19
+ "transactions.js",
20
+ "runtime-observability.js"
16
21
  ],
17
22
  "scripts": {
18
23
  "start": "node overleaf-mcp-server.js",
@@ -0,0 +1,161 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFile as childExecFile } from 'node:child_process';
3
+ import { lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { promisify } from 'node:util';
7
+
8
+ const execFile = promisify(childExecFile);
9
+ const DEFAULT_CACHE_DIR = path.join(os.tmpdir(), 'overleaf-mcp-render');
10
+ const MAX_PAGES = 20;
11
+ const MIN_DPI = 72;
12
+ const MAX_DPI = 200;
13
+ const snapshotRetries = 3;
14
+
15
+ // One promise per key keeps concurrent callers from both invoking pdftoppm and
16
+ // replacing the same destination. The rename below still makes each write
17
+ // safe if another process is using the same cache directory.
18
+ const pending = new Map();
19
+
20
+ const digest = bytes => createHash('sha256').update(bytes).digest('hex');
21
+ const inside = (parent, child) => child === parent || child.startsWith(`${parent}${path.sep}`);
22
+
23
+ async function containedSource(root, filePath) {
24
+ const rootReal = await pathReal(root);
25
+ const candidate = path.resolve(root, filePath);
26
+ if (!inside(path.resolve(root), candidate)) throw new Error('PDF path must be inside project');
27
+ const sourceReal = await pathReal(candidate);
28
+ if (!inside(rootReal, sourceReal)) throw new Error('PDF symlink escapes project');
29
+ return sourceReal;
30
+ }
31
+
32
+ async function pathReal(file) {
33
+ try {
34
+ return await realpath(file);
35
+ } catch (error) {
36
+ if (error.code === 'ENOENT') throw new Error(`Path does not exist: ${file}`);
37
+ throw error;
38
+ }
39
+ }
40
+
41
+ async function cacheRoot(cacheDir) {
42
+ const requested = path.resolve(cacheDir ?? DEFAULT_CACHE_DIR);
43
+ await mkdir(requested, { recursive: true });
44
+ const entry = await lstat(requested);
45
+ if (entry.isSymbolicLink()) throw new Error('Cache directory must not be a symlink');
46
+ return await pathReal(requested);
47
+ }
48
+
49
+ function validatePages(pages) {
50
+ if (!Array.isArray(pages) || pages.length === 0 || pages.length > MAX_PAGES) {
51
+ throw new Error(`pages must contain 1-${MAX_PAGES} page numbers`);
52
+ }
53
+ if (!pages.every(page => Number.isInteger(page) && page > 0)) {
54
+ throw new Error('pages must contain positive integers');
55
+ }
56
+ return [...new Set(pages)];
57
+ }
58
+
59
+ function validateDpi(dpi) {
60
+ if (!Number.isInteger(dpi) || dpi < MIN_DPI || dpi > MAX_DPI) {
61
+ throw new Error(`dpi must be an integer from ${MIN_DPI} to ${MAX_DPI}`);
62
+ }
63
+ return dpi;
64
+ }
65
+
66
+ async function toolVersion() {
67
+ try {
68
+ const result = await execFile('pdftoppm', ['-v'], { encoding: 'utf8' });
69
+ return `${result.stdout}\n${result.stderr}`.trim();
70
+ } catch (error) {
71
+ // pdftoppm writes its version to stderr and exits successfully on common
72
+ // builds, but retain a stable failure if the executable cannot be run.
73
+ if (error.stdout || error.stderr) return `${error.stdout ?? ''}\n${error.stderr ?? ''}`.trim();
74
+ throw new Error(`Unable to run pdftoppm: ${error.message}`);
75
+ }
76
+ }
77
+
78
+ async function pageCount(pdf) {
79
+ let result;
80
+ try {
81
+ result = await execFile('pdfinfo', [pdf], { encoding: 'utf8' });
82
+ } catch (error) {
83
+ throw new Error(`Unable to inspect PDF: ${error.stderr?.trim() || error.message}`);
84
+ }
85
+ const match = result.stdout.match(/^Pages:\s+(\d+)\s*$/m);
86
+ const count = match ? Number(match[1]) : NaN;
87
+ if (!Number.isInteger(count) || count < 1) throw new Error('pdfinfo returned no valid page count');
88
+ return count;
89
+ }
90
+
91
+ async function snapshot(source, root) {
92
+ for (let attempt = 0; attempt < snapshotRetries; attempt += 1) {
93
+ const before = await stat(source);
94
+ const bytes = await readFile(source);
95
+ const after = await stat(source);
96
+ if (before.size !== after.size || before.mtimeNs !== after.mtimeNs) continue;
97
+ const hash = digest(bytes);
98
+ const dir = await mkdtemp(path.join(root, '.snapshot-'));
99
+ const file = path.join(dir, 'input.pdf');
100
+ await writeFile(file, bytes, { flag: 'wx' });
101
+ if (digest(await readFile(file)) !== hash) {
102
+ await rm(dir, { recursive: true, force: true });
103
+ throw new Error('PDF snapshot verification failed');
104
+ }
105
+ return { dir, file, hash };
106
+ }
107
+ throw new Error('PDF changed while it was being read; retry the render');
108
+ }
109
+
110
+ async function existingFile(file, root) {
111
+ if (!inside(root, path.resolve(file))) throw new Error('Cache path escapes cache directory');
112
+ try {
113
+ const info = await lstat(file);
114
+ return info.isFile() && !info.isSymbolicLink() && info.size > 0;
115
+ } catch (error) {
116
+ if (error.code === 'ENOENT') return false;
117
+ throw error;
118
+ }
119
+ }
120
+
121
+ async function renderOne(snapshotFile, root, key, page, dpi) {
122
+ const finalFile = path.join(root, `${key}.png`);
123
+ if (await existingFile(finalFile, root)) return { file: finalFile, hit: true };
124
+ const work = await mkdtemp(path.join(root, '.render-'));
125
+ try {
126
+ const prefix = path.join(work, 'page');
127
+ await execFile('pdftoppm', ['-png', '-r', String(dpi), '-f', String(page), '-l', String(page), snapshotFile, prefix], { encoding: 'utf8' });
128
+ const generated = path.join(work, `page-${page}.png`);
129
+ if (!(await existingFile(generated, work))) throw new Error(`pdftoppm produced no output for page ${page}`);
130
+ await rename(generated, finalFile);
131
+ return { file: finalFile, hit: false };
132
+ } finally {
133
+ await rm(work, { recursive: true, force: true });
134
+ }
135
+ }
136
+
137
+ export async function renderPages(root, { filePath, pages, dpi = 110, cacheDir } = {}) {
138
+ if (typeof root !== 'string' || typeof filePath !== 'string') throw new Error('root and filePath are required');
139
+ const wanted = validatePages(pages);
140
+ const resolution = validateDpi(dpi);
141
+ const cache = await cacheRoot(cacheDir);
142
+ const source = await containedSource(root, filePath);
143
+ const input = await snapshot(source, cache);
144
+ try {
145
+ const [version, count] = await Promise.all([toolVersion(), pageCount(input.file)]);
146
+ if (wanted.some(page => page > count)) throw new Error(`Requested page exceeds PDF page count (${count})`);
147
+ const results = [];
148
+ for (const page of wanted) {
149
+ const key = digest(JSON.stringify([input.hash, page, resolution, version]));
150
+ let work = pending.get(key);
151
+ if (!work) {
152
+ work = renderOne(input.file, cache, key, page, resolution).finally(() => pending.delete(key));
153
+ pending.set(key, work);
154
+ }
155
+ results.push(await work);
156
+ }
157
+ return { files: results.map(result => result.file), cacheHits: results.filter(result => result.hit).length, requestedPages: wanted, pageCount: count };
158
+ } finally {
159
+ await rm(input.dir, { recursive: true, force: true });
160
+ }
161
+ }
@@ -0,0 +1,109 @@
1
+ const totals = {
2
+ callCount: 0,
3
+ durationMs: 0,
4
+ responseBytes: 0,
5
+ cacheHits: 0,
6
+ failures: 0,
7
+ byTool: {},
8
+ };
9
+
10
+ const ERROR_CODES = new Map([
11
+ ['LOCAL_CLONE', { retryable: false, nextAction: 'Check the local clone path and synchronize the project explicitly.' }],
12
+ ['CONFLICT', { retryable: false, nextAction: 'Resolve the repository conflict, then retry the requested operation.' }],
13
+ ['STALE_SOURCE', { retryable: false, nextAction: 'Read the latest revision and affected files before preparing a new batch.' }],
14
+ ['BUILD_FAILED', { retryable: false, nextAction: 'Inspect the build log and correct the reported failure before rebuilding.' }],
15
+ ['NETWORK', { retryable: true, nextAction: 'Check the network connection and retry when the service is reachable.' }],
16
+ ['MISSING_PACKAGE', { retryable: false, nextAction: 'Install the missing package or dependency, then retry.' }],
17
+ ['INVALID_INPUT', { retryable: false, nextAction: 'Correct the input arguments and retry.' }],
18
+ ['INTERNAL', { retryable: false, nextAction: 'Inspect the server logs for the underlying failure.' }],
19
+ ]);
20
+
21
+ const scrub = value => String(value)
22
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+(?::[^\s/@]*)?@/gi, '$1***@')
23
+ .replace(/\bgit:[^\s/@]+@/gi, 'git:***@')
24
+ .slice(0, 1500);
25
+
26
+ function classify(error) {
27
+ const rawCode = String(error?.code ?? '').toUpperCase();
28
+ if (ERROR_CODES.has(rawCode)) return rawCode;
29
+ if (/^STALE_/.test(rawCode)) return 'STALE_SOURCE';
30
+ if (/^VERIFICATION_/.test(rawCode)) return 'BUILD_FAILED';
31
+ if (/^INVALID_|SYMLINK_PATH|DUPLICATE_PATH/.test(rawCode)) return 'INVALID_INPUT';
32
+ if (rawCode === 'DIRTY_CHECKOUT') return 'CONFLICT';
33
+ const text = `${rawCode} ${error?.message ?? error}`.toLowerCase();
34
+ if (/local.?clone|clone failed|clone path|repository clone/.test(text)) return 'LOCAL_CLONE';
35
+ if (/conflict|non-fast-forward|merge conflict/.test(text)) return 'CONFLICT';
36
+ if (/network|econn|etimedout|enotfound|enetunreach|ehostunreach|fetch failed|socket/.test(text)) return 'NETWORK';
37
+ if (/missing package|module_not_found|cannot find module|package .*not found/.test(text)) return 'MISSING_PACKAGE';
38
+ if (/invalid input|invalid argument|bad argument|validation|must be|expected .* but/.test(text)) return 'INVALID_INPUT';
39
+ return 'INTERNAL';
40
+ }
41
+
42
+ const responseBytes = result => {
43
+ try {
44
+ return Buffer.byteLength(JSON.stringify(result) ?? String(result), 'utf8');
45
+ } catch {
46
+ return Buffer.byteLength(String(result), 'utf8');
47
+ }
48
+ };
49
+
50
+ function blankStats() {
51
+ return { callCount: 0, durationMs: 0, responseBytes: 0, cacheHits: 0, failures: 0, byTool: {} };
52
+ }
53
+
54
+ export function usageStats({ reset = false } = {}) {
55
+ const snapshot = structuredClone(totals);
56
+ if (reset) {
57
+ Object.assign(totals, blankStats());
58
+ }
59
+ return snapshot;
60
+ }
61
+
62
+ function record(name, elapsed, result, failed) {
63
+ const tool = totals.byTool[name] ??= { callCount: 0, durationMs: 0, responseBytes: 0, cacheHits: 0, failures: 0 };
64
+ const bytes = responseBytes(result);
65
+ const hits = failed ? 0 : Number(result?.cacheHits ?? result?.structuredContent?.cacheHits ?? (result?.structuredContent?.reused || result?.structuredContent?.unchanged ? 1 : 0));
66
+ const safeHits = Number.isFinite(hits) && hits > 0 ? hits : 0;
67
+ for (const target of [totals, tool]) {
68
+ target.callCount += 1;
69
+ target.durationMs += elapsed;
70
+ target.responseBytes += bytes;
71
+ target.cacheHits += safeHits;
72
+ if (failed) target.failures += 1;
73
+ }
74
+ }
75
+
76
+ export function observeTool(name, asyncFn) {
77
+ if (typeof name !== 'string' || !name) throw new TypeError('tool name is required');
78
+ if (typeof asyncFn !== 'function') throw new TypeError('asyncFn must be a function');
79
+ return async function observedTool(...args) {
80
+ const started = performance.now();
81
+ try {
82
+ const result = await asyncFn.apply(this, args);
83
+ record(name, performance.now() - started, result, result?.isError === true || result?.structuredContent?.pass === false);
84
+ return result;
85
+ } catch (error) {
86
+ record(name, performance.now() - started, null, true);
87
+ throw error;
88
+ }
89
+ };
90
+ }
91
+
92
+ export function toolError(error) {
93
+ const code = classify(error);
94
+ const policy = ERROR_CODES.get(code);
95
+ const message = scrub(error?.message ?? error);
96
+ return {
97
+ isError: true,
98
+ content: [{ type: 'text', text: `Error: ${message}` }],
99
+ structuredContent: {
100
+ error: {
101
+ code,
102
+ message,
103
+ retryable: policy.retryable,
104
+ nextAction: policy.nextAction,
105
+ maxAutomaticRetries: 0,
106
+ },
107
+ },
108
+ };
109
+ }
@@ -0,0 +1,219 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFile as execFileCallback } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { lstat, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+
8
+ const execFile = promisify(execFileCallback);
9
+
10
+ class TransactionError extends Error {
11
+ constructor(code, message, cause) {
12
+ super(message, cause ? { cause } : undefined);
13
+ this.name = 'TransactionError';
14
+ this.code = code;
15
+ }
16
+ }
17
+
18
+ const git = (cwd, args) => execFile('git', ['-C', cwd, ...args], { maxBuffer: 10 * 1024 * 1024 });
19
+
20
+ async function gitText(cwd, args) {
21
+ const { stdout } = await git(cwd, args);
22
+ return stdout.trim();
23
+ }
24
+
25
+ function fail(code, message, cause) {
26
+ throw new TransactionError(code, message, cause);
27
+ }
28
+
29
+ async function status(root) {
30
+ // Verification tools commonly leave generated, untracked artifacts behind.
31
+ // Tracked edits still make the checkout dirty and are rejected below.
32
+ return gitText(root, ['status', '--porcelain=v1', '--untracked-files=no']);
33
+ }
34
+
35
+ async function head(root) {
36
+ return gitText(root, ['rev-parse', 'HEAD']);
37
+ }
38
+
39
+ function sha256(bytes) {
40
+ return createHash('sha256').update(bytes).digest('hex');
41
+ }
42
+
43
+ async function ensureNoSymlink(root, filePath) {
44
+ const absolute = path.resolve(root, filePath);
45
+ const relative = path.relative(root, absolute);
46
+ if (!relative || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) fail('invalid_path', `Path escapes repository: ${filePath}`);
47
+ let current = root;
48
+ for (const part of relative.split(path.sep)) {
49
+ current = path.join(current, part);
50
+ try {
51
+ if ((await lstat(current)).isSymbolicLink()) fail('symlink_path', `Symlink path is not allowed: ${filePath}`);
52
+ } catch (err) {
53
+ if (err.code !== 'ENOENT') throw err;
54
+ break;
55
+ }
56
+ }
57
+ return absolute;
58
+ }
59
+
60
+ function validateUtf8(content) {
61
+ if (typeof content !== 'string') fail('invalid_content', 'Change content must be a UTF-8 string');
62
+ const bytes = Buffer.from(content, 'utf8');
63
+ if (bytes.toString('utf8') !== content) fail('invalid_content', 'Change content is not valid UTF-8');
64
+ return bytes;
65
+ }
66
+
67
+ async function validateChanges(root, changes) {
68
+ if (!Array.isArray(changes)) fail('invalid_changes', 'changes must be an array');
69
+ const seen = new Set();
70
+ const checked = [];
71
+ for (const change of changes) {
72
+ if (!change || typeof change.filePath !== 'string' || change.filePath.includes('\0')) fail('invalid_path', 'Each change needs a valid filePath');
73
+ const normalized = path.normalize(change.filePath);
74
+ if (path.isAbsolute(change.filePath) || normalized === '.' || normalized.startsWith('..' + path.sep) || normalized === '..' || normalized === '.git' || normalized.startsWith(`.git${path.sep}`)) fail('invalid_path', `Invalid file path: ${change.filePath}`);
75
+ if (seen.has(normalized)) fail('duplicate_path', `Duplicate file path: ${change.filePath}`);
76
+ seen.add(normalized);
77
+ if (change.baseHash !== null && (typeof change.baseHash !== 'string' || !/^[0-9a-f]{64}$/i.test(change.baseHash))) fail('invalid_hash', `baseHash must be a SHA-256 hex digest or null: ${change.filePath}`);
78
+ const bytes = validateUtf8(change.content);
79
+ const absolute = await ensureNoSymlink(root, normalized);
80
+ let current = null;
81
+ try {
82
+ const st = await lstat(absolute);
83
+ if (st.isSymbolicLink()) fail('symlink_path', `Symlink target is not allowed: ${change.filePath}`);
84
+ if (!st.isFile()) fail('invalid_path', `Change target is not a regular file: ${change.filePath}`);
85
+ current = await readFile(absolute);
86
+ } catch (err) {
87
+ if (err.code !== 'ENOENT') throw err;
88
+ }
89
+ const actualHash = current === null ? null : sha256(current);
90
+ if (actualHash !== change.baseHash) fail('stale_file', `Base hash does not match ${change.filePath}`);
91
+ if (change.baseHash === null && current !== null) fail('stale_file', `New file already exists: ${change.filePath}`);
92
+ checked.push({ filePath: normalized, bytes, baseHash: change.baseHash });
93
+ }
94
+ return checked;
95
+ }
96
+
97
+ async function assertCleanAt(root, expectedHead, checked) {
98
+ if (await head(root) !== expectedHead) fail('stale_revision', 'Repository HEAD changed during the transaction');
99
+ if (await status(root) !== '') fail('dirty_checkout', 'Repository working tree changed during the transaction');
100
+ for (const change of checked) {
101
+ const absolute = await ensureNoSymlink(root, change.filePath);
102
+ let bytes = null;
103
+ try { bytes = await readFile(absolute); } catch (err) { if (err.code !== 'ENOENT') throw err; }
104
+ if ((bytes === null ? null : sha256(bytes)) !== change.baseHash) fail('stale_file', `Base hash changed for ${change.filePath}`);
105
+ }
106
+ }
107
+
108
+ function verificationPassed(value) {
109
+ return value && (value.passed === true || value.pass === true);
110
+ }
111
+
112
+ async function runVerification(verify, stageRoot) {
113
+ if (typeof verify !== 'function') fail('invalid_verify', 'verify must be a callback');
114
+ let result;
115
+ try { result = await verify(stageRoot); } catch (err) { fail('verification_error', err.message || 'Verification callback failed', err); }
116
+ if (!verificationPassed(result)) fail('verification_failed', 'Verification did not pass');
117
+ return result;
118
+ }
119
+
120
+ function compactVerification(value) {
121
+ if (!value || typeof value !== 'object') return value;
122
+ const result = { ...value };
123
+ delete result.tail;
124
+ delete result.logPath;
125
+ delete result.pdfPath;
126
+ return result;
127
+ }
128
+
129
+ async function removeWorktree(root, stageRoot) {
130
+ try { await git(root, ['worktree', 'remove', '--force', stageRoot]); } catch { /* best effort cleanup */ }
131
+ await rm(stageRoot, { recursive: true, force: true });
132
+ }
133
+
134
+ async function applyChangesUnlocked(root, { baseRevision, changes }, verify) {
135
+ const repo = path.resolve(root);
136
+ let stageRoot;
137
+ try {
138
+ if (typeof baseRevision !== 'string' || !/^[0-9a-f]{40}$/i.test(baseRevision)) fail('invalid_revision', 'baseRevision must be a full 40-hex commit SHA');
139
+ if (await status(repo) !== '') fail('dirty_checkout', 'Repository working tree must be clean');
140
+ const currentHead = await head(repo);
141
+ const requestedHead = await gitText(repo, ['rev-parse', `${baseRevision}^{commit}`]);
142
+ if (requestedHead !== currentHead) fail('stale_revision', 'baseRevision does not match HEAD');
143
+ const checked = await validateChanges(repo, changes);
144
+ stageRoot = await mkdtemp(path.join(tmpdir(), 'overleaf-transaction-'));
145
+ await git(repo, ['worktree', 'add', '--detach', stageRoot, currentHead]);
146
+ for (const change of checked) {
147
+ const target = await ensureNoSymlink(stageRoot, change.filePath);
148
+ await mkdir(path.dirname(target), { recursive: true });
149
+ await writeFile(target, change.bytes);
150
+ }
151
+ const verification = await runVerification(verify, stageRoot);
152
+ for (const change of checked) {
153
+ const bytes = await readFile(await ensureNoSymlink(stageRoot, change.filePath));
154
+ if (!bytes.equals(change.bytes)) fail('verification_mutation', `Verification changed ${change.filePath}`);
155
+ }
156
+ const changed = await gitText(stageRoot, ['diff', '--name-only']);
157
+ const allowed = new Set(checked.map(c => c.filePath));
158
+ if (changed && changed.split('\n').some(file => !allowed.has(file))) fail('unexpected_change', 'Verification changed files outside the batch');
159
+ let revision = currentHead;
160
+ if (checked.length) {
161
+ await git(stageRoot, ['add', '--', ...checked.map(c => c.filePath)]);
162
+ const staged = await gitText(stageRoot, ['diff', '--cached', '--name-only']);
163
+ if (staged && staged.split('\n').some(file => !allowed.has(file))) fail('unexpected_change', 'Unexpected file staged');
164
+ if (staged) {
165
+ await git(stageRoot, ['-c', 'user.name=Overleaf Forge', '-c', 'user.email=overleaf-forge@localhost', 'commit', '-m', 'Apply verified transaction']);
166
+ revision = await head(stageRoot);
167
+ }
168
+ }
169
+ await assertCleanAt(repo, currentHead, checked);
170
+ await git(repo, ['merge', '--ff-only', revision]);
171
+ return { revision: await head(repo), verification: compactVerification(verification), files: checked.map(c => c.filePath) };
172
+ } catch (err) {
173
+ if (err instanceof TransactionError) throw err;
174
+ throw new TransactionError('git_error', err.message || 'Git transaction failed', err);
175
+ } finally {
176
+ if (stageRoot) await removeWorktree(repo, stageRoot);
177
+ }
178
+ }
179
+
180
+ async function publishChangesUnlocked(root, { revision }, verify, push) {
181
+ const repo = path.resolve(root);
182
+ try {
183
+ if (typeof revision !== 'string' || !/^[0-9a-f]{40}$/i.test(revision)) fail('invalid_revision', 'revision must be a full 40-hex commit SHA');
184
+ if (await status(repo) !== '') fail('dirty_checkout', 'Repository working tree must be clean');
185
+ if (await head(repo) !== await gitText(repo, ['rev-parse', `${revision}^{commit}`])) fail('stale_revision', 'revision does not match HEAD');
186
+ const verification = await runVerification(verify, repo);
187
+ if (await status(repo) !== '') fail('verification_mutation', 'Verification changed the repository');
188
+ if (await head(repo) !== revision) fail('stale_revision', 'Repository HEAD changed during verification');
189
+ const branch = await gitText(repo, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
190
+ if (!branch) fail('detached_head', 'Cannot publish from a detached HEAD');
191
+ if (push !== undefined && typeof push !== 'function') fail('invalid_push', 'push must be a callback');
192
+ if (push) await push(repo, revision, branch);
193
+ else await git(repo, ['push', 'HEAD:refs/heads/' + branch]);
194
+ const files = await gitText(repo, ['diff-tree', '--no-commit-id', '--name-only', '-r', revision]);
195
+ return { revision: await head(repo), verification: compactVerification(verification), files: files ? files.split('\n') : [] };
196
+ } catch (err) {
197
+ if (err instanceof TransactionError) throw err;
198
+ throw new TransactionError('push_failed', err.message || 'Git push failed', err);
199
+ }
200
+ }
201
+
202
+ const locks = new Map();
203
+ function enqueue(root, operation) {
204
+ const key = path.resolve(root);
205
+ const prior = locks.get(key) || Promise.resolve();
206
+ const current = prior.catch(() => {}).then(operation);
207
+ locks.set(key, current);
208
+ return current.finally(() => { if (locks.get(key) === current) locks.delete(key); });
209
+ }
210
+
211
+ export function applyChanges(root, input, verify) {
212
+ return enqueue(root, () => applyChangesUnlocked(root, input, verify));
213
+ }
214
+
215
+ export function publishChanges(root, input, verify, push) {
216
+ return enqueue(root, () => publishChangesUnlocked(root, input, verify, push));
217
+ }
218
+
219
+ export { TransactionError };