git-reviewer 2.14.1 → 2.16.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.
@@ -0,0 +1,223 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const fsp = require('fs').promises;
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+
8
+ const { repoKey } = require('./review');
9
+ const { writeFileAtomic, isAlive } = require('./store');
10
+
11
+ /**
12
+ * Which agents are connected to a review right now.
13
+ *
14
+ * An agent answering in a review's threads may live in another process -- a
15
+ * Claude Code session running `reviewer channel` -- or inside the server
16
+ * itself. Either way it leaves a small file here saying who it is and what it
17
+ * is working on, refreshes it while it lives, and removes it when it stops.
18
+ * The page shows those files as "Claude is connected" and "answering…", which
19
+ * is the difference between a reply that is coming and silence.
20
+ *
21
+ * A file is trusted only while its process is alive and its heartbeat is
22
+ * recent, so an agent killed without the chance to clean up drops out of the
23
+ * list on its own instead of claiming to be connected forever.
24
+ */
25
+
26
+ const HEARTBEAT_MS = 10000;
27
+ const STALE_MS = 30000;
28
+ const POLL_MS = 3000;
29
+
30
+ /**
31
+ * @typedef {object} AgentPresence
32
+ * @property {string} id
33
+ * @property {string} repoPath
34
+ * @property {'channel'|'background'|'watch'} kind how it is connected
35
+ * @property {string} agent who answers, e.g. `claude`
36
+ * @property {string|null} session the agent's own session id, when known
37
+ * @property {string|null} title what that session is about, when known
38
+ * @property {number} pid
39
+ * @property {string} startedAt
40
+ * @property {string} heartbeatAt
41
+ * @property {string[]} working keys of threads it has been asked and not yet answered
42
+ */
43
+
44
+ /** @param {string} reviewsDir @param {string} repoPath */
45
+ function agentsDir(reviewsDir, repoPath) {
46
+ return path.join(reviewsDir, 'agents', repoKey(repoPath));
47
+ }
48
+
49
+ /**
50
+ * Announce an agent and keep the announcement fresh until `close()`.
51
+ *
52
+ * @param {string} reviewsDir
53
+ * @param {string} repoPath
54
+ * @param {Omit<AgentPresence, 'id'|'repoPath'|'pid'|'startedAt'|'heartbeatAt'|'working'> & {working?: string[]}} info
55
+ */
56
+ function announce(reviewsDir, repoPath, info) {
57
+ const id = crypto.randomBytes(6).toString('base64url');
58
+ const dir = agentsDir(reviewsDir, repoPath);
59
+ const file = path.join(dir, `${id}.json`);
60
+ const startedAt = new Date().toISOString();
61
+ /** @type {AgentPresence} */
62
+ let record = {
63
+ id,
64
+ repoPath,
65
+ kind: info.kind,
66
+ agent: info.agent,
67
+ session: info.session ?? null,
68
+ title: info.title ?? null,
69
+ pid: process.pid,
70
+ startedAt,
71
+ heartbeatAt: startedAt,
72
+ working: info.working ?? []
73
+ };
74
+ let closed = false;
75
+ let writing = Promise.resolve();
76
+
77
+ const write = () => {
78
+ record = { ...record, heartbeatAt: new Date().toISOString() };
79
+ const snapshot = `${JSON.stringify(record, null, 2)}\n`;
80
+ writing = writing
81
+ .then(async () => {
82
+ if (closed) return;
83
+ await fsp.mkdir(dir, { recursive: true });
84
+ await writeFileAtomic(file, snapshot);
85
+ })
86
+ .catch(() => {});
87
+ return writing;
88
+ };
89
+
90
+ const heartbeat = setInterval(write, HEARTBEAT_MS);
91
+ heartbeat.unref();
92
+
93
+ // A synchronous last word, for an exit that gives no time for anything else.
94
+ const removeNow = () => {
95
+ try {
96
+ fs.rmSync(file, { force: true });
97
+ } catch {
98
+ /* nothing more can be done on the way out */
99
+ }
100
+ };
101
+ process.on('exit', removeNow);
102
+
103
+ return {
104
+ id,
105
+ ready: write(),
106
+ /** @param {Partial<AgentPresence>} fields */
107
+ update(fields) {
108
+ record = { ...record, ...fields };
109
+ return write();
110
+ },
111
+ get record() {
112
+ return record;
113
+ },
114
+ async close() {
115
+ closed = true;
116
+ clearInterval(heartbeat);
117
+ process.off('exit', removeNow);
118
+ await writing;
119
+ await fsp.rm(file, { force: true });
120
+ }
121
+ };
122
+ }
123
+
124
+ /**
125
+ * The agents connected to a review, live ones only.
126
+ *
127
+ * @param {string} reviewsDir
128
+ * @param {string} repoPath
129
+ * @param {object} [options]
130
+ * @param {number} [options.now]
131
+ * @returns {Promise<AgentPresence[]>} oldest first
132
+ */
133
+ async function listAgents(reviewsDir, repoPath, { now = Date.now() } = {}) {
134
+ const dir = agentsDir(reviewsDir, repoPath);
135
+ let names;
136
+ try {
137
+ names = await fsp.readdir(dir);
138
+ } catch (error) {
139
+ if (error.code === 'ENOENT') return [];
140
+ throw error;
141
+ }
142
+
143
+ const agents = [];
144
+ for (const name of names) {
145
+ if (!name.endsWith('.json')) continue;
146
+ const file = path.join(dir, name);
147
+ let record;
148
+ try {
149
+ record = JSON.parse(await fsp.readFile(file, 'utf-8'));
150
+ } catch {
151
+ continue; // being replaced right now, or not ours
152
+ }
153
+ const fresh = now - Date.parse(record.heartbeatAt) < STALE_MS;
154
+ if (fresh && isAlive(record.pid)) {
155
+ agents.push(record);
156
+ } else if (!isAlive(record.pid)) {
157
+ // Its process is gone and cannot clean up after itself any more.
158
+ await fsp.rm(file, { force: true }).catch(() => {});
159
+ }
160
+ }
161
+
162
+ return agents.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
163
+ }
164
+
165
+ /**
166
+ * Hear about agents connecting, disconnecting and changing what they work on.
167
+ *
168
+ * @param {string} reviewsDir
169
+ * @param {string} repoPath
170
+ * @param {(agents: AgentPresence[]) => void} onChange called at the start and on every change
171
+ * @param {object} [options]
172
+ * @param {number} [options.pollMs]
173
+ * @returns {{close: () => void, ready: Promise<void>}}
174
+ */
175
+ function watchAgents(reviewsDir, repoPath, onChange, { pollMs = POLL_MS } = {}) {
176
+ const dir = agentsDir(reviewsDir, repoPath);
177
+ let closed = false;
178
+ let last;
179
+ let timer = null;
180
+
181
+ const check = async () => {
182
+ if (closed) return;
183
+ const agents = await listAgents(reviewsDir, repoPath).catch(() => []);
184
+ // Heartbeats move every ten seconds without anything a person would see
185
+ // changing, so they are left out of the comparison.
186
+ const signature = JSON.stringify(agents.map(({ heartbeatAt, ...agent }) => agent));
187
+ if (!closed && signature !== last) {
188
+ last = signature;
189
+ onChange(agents);
190
+ }
191
+ };
192
+
193
+ let watcher = null;
194
+ try {
195
+ fs.mkdirSync(dir, { recursive: true });
196
+ watcher = fs.watch(dir, () => {
197
+ clearTimeout(timer);
198
+ timer = setTimeout(check, 25);
199
+ });
200
+ watcher.on('error', () => {
201
+ watcher?.close();
202
+ watcher = null;
203
+ });
204
+ } catch {
205
+ watcher = null;
206
+ }
207
+
208
+ // Also how an agent that died without cleaning up is noticed.
209
+ const poll = setInterval(check, pollMs);
210
+ poll.unref();
211
+
212
+ return {
213
+ ready: check(),
214
+ close() {
215
+ closed = true;
216
+ clearTimeout(timer);
217
+ clearInterval(poll);
218
+ watcher?.close();
219
+ }
220
+ };
221
+ }
222
+
223
+ module.exports = { announce, listAgents, watchAgents, agentsDir, isAlive, HEARTBEAT_MS, STALE_MS };
package/lib/review.js CHANGED
@@ -124,13 +124,15 @@ function reviewFilename(repoPath, generatedAt) {
124
124
  * @param {string} args.repoPath repository the review covers
125
125
  * @param {import('./comments').Comment[]} args.comments
126
126
  * @param {Date} args.generatedAt
127
+ * @param {string|null} [args.scope] an explanation of an explicitly restricted comparison
127
128
  * @returns {string}
128
129
  */
129
- function formatReview({ repoPath, comments, generatedAt }) {
130
+ function formatReview({ repoPath, comments, generatedAt, scope = null }) {
130
131
  const sections = [
131
132
  '# Code Review\n',
132
133
  `Repository: ${repoPath}`,
133
134
  `Generated: ${generatedAt.toISOString()}`,
135
+ ...(scope ? [scope] : []),
134
136
  `Total Comments: ${comments.length}\n`
135
137
  ];
136
138
 
package/lib/sessions.js CHANGED
@@ -35,7 +35,7 @@ class SessionStore {
35
35
  */
36
36
  constructor(generateId = () => crypto.randomBytes(8).toString('hex')) {
37
37
  this.generateId = generateId;
38
- /** @type {Map<string, {repoPath: string, mode: ReviewMode, range: ReviewRange|null}>} */
38
+ /** @type {Map<string, {repoPath: string, mode: ReviewMode, range: ReviewRange|null, untracked?: boolean}>} */
39
39
  this.sessions = new Map();
40
40
  }
41
41
 
@@ -43,17 +43,21 @@ class SessionStore {
43
43
  * @param {string} repoPath absolute path of the opened repository
44
44
  * @param {ReviewMode} mode
45
45
  * @param {ReviewRange|null} [range] the two refs, for a commit-based review
46
+ * @param {boolean} [untracked] whether a working comparison includes untracked files
46
47
  * @returns {string} the new session id
47
48
  */
48
- create(repoPath, mode, range = null) {
49
+ create(repoPath, mode, range = null, untracked = true) {
49
50
  const repoId = this.generateId();
50
- this.sessions.set(repoId, { repoPath, mode, range });
51
+ this.sessions.set(repoId, {
52
+ repoPath, mode, range,
53
+ ...(mode === 'working' && untracked === false ? { untracked: false } : {})
54
+ });
51
55
  return repoId;
52
56
  }
53
57
 
54
58
  /**
55
59
  * @param {string} repoId
56
- * @returns {{repoPath: string, mode: ReviewMode, range: ReviewRange|null}|undefined}
60
+ * @returns {{repoPath: string, mode: ReviewMode, range: ReviewRange|null, untracked?: boolean}|undefined}
57
61
  */
58
62
  get(repoId) {
59
63
  return this.sessions.get(repoId);
package/lib/store.js CHANGED
@@ -2,8 +2,10 @@
2
2
 
3
3
  const fs = require('fs').promises;
4
4
  const path = require('path');
5
+ const crypto = require('crypto');
5
6
 
6
7
  const { commentsFilename, legacyCommentsFilename, canonicalRepoPath } = require('./review');
8
+ const { withKeys } = require('./comments');
7
9
 
8
10
  /**
9
11
  * Reading a repository's saved comments, whatever name they were written under.
@@ -74,10 +76,15 @@ async function readJson(file) {
74
76
  *
75
77
  * @param {string} reviewsDir
76
78
  * @param {string} repoPath
77
- * @returns {Promise<{comments: object[], mode: string|null, range: object|null, adopted: boolean}>}
79
+ * @returns {Promise<{comments: object[], mode: string|null, range: object|null, untracked?: boolean, adopted: boolean, lastUpdated: string|null}>}
78
80
  * `mode` is null for a file written before it was recorded -- there is
79
81
  * nothing on disk that says what was compared, and guessing would be
80
- * worse than admitting it.
82
+ * worse than admitting it. `lastUpdated` is null when the file carries
83
+ * no stamp (or there is no file yet): it is the version token a caller
84
+ * sends back on save so the server can tell a current writer from a
85
+ * stale one (#107). Every comment and follow-up comes back with a `key`;
86
+ * one saved before keys existed gets the key derived from its content (see
87
+ * `withKeys`), the same on every read.
81
88
  * @throws {ReviewOwnershipError} when the current file names another repository
82
89
  */
83
90
  async function readSavedComments(reviewsDir, repoPath) {
@@ -90,15 +97,20 @@ async function readSavedComments(reviewsDir, repoPath) {
90
97
  if (!belongsTo(saved.repoPath, repoPath)) {
91
98
  throw new ReviewOwnershipError(repoPath, saved.repoPath);
92
99
  }
93
- return { comments: saved.comments ?? [], mode: saved.mode ?? null, range: saved.range ?? null, adopted: false };
100
+ return {
101
+ comments: withKeys(saved.comments ?? []), mode: saved.mode ?? null, range: saved.range ?? null,
102
+ ...(saved.mode === 'working' && saved.untracked === false ? { untracked: false } : {}),
103
+ adopted: false,
104
+ lastUpdated: saved.lastUpdated ?? null
105
+ };
94
106
  }
95
107
 
96
108
  const legacyName = legacyCommentsFilename(repoPath);
97
- if (legacyName === commentsFilename(repoPath)) return { comments: [], mode: null, range: null, adopted: false };
109
+ if (legacyName === commentsFilename(repoPath)) return { comments: [], mode: null, range: null, adopted: false, lastUpdated: null };
98
110
 
99
111
  const legacy = await readJson(path.join(reviewsDir, legacyName));
100
112
  if (legacy === null || !belongsTo(legacy.repoPath, repoPath)) {
101
- return { comments: [], mode: null, range: null, adopted: false };
113
+ return { comments: [], mode: null, range: null, adopted: false, lastUpdated: null };
102
114
  }
103
115
 
104
116
  // Move it under the new name so the next read is a plain hit and the old
@@ -110,7 +122,278 @@ async function readSavedComments(reviewsDir, repoPath) {
110
122
  /* the comments are already in hand; renaming is housekeeping */
111
123
  }
112
124
 
113
- return { comments: legacy.comments ?? [], mode: legacy.mode ?? null, range: legacy.range ?? null, adopted: true };
125
+ return {
126
+ comments: withKeys(legacy.comments ?? []), mode: legacy.mode ?? null, range: legacy.range ?? null,
127
+ ...(legacy.mode === 'working' && legacy.untracked === false ? { untracked: false } : {}),
128
+ adopted: true,
129
+ lastUpdated: legacy.lastUpdated ?? null
130
+ };
114
131
  }
115
132
 
116
- module.exports = { readSavedComments, ReviewOwnershipError, belongsTo };
133
+ /**
134
+ * The `lastUpdated` stamp for a save that follows `previous`.
135
+ *
136
+ * Stamps are the version token for conditional saves (#107), so a new stamp
137
+ * must differ from the one it replaces even when two saves land inside the
138
+ * same millisecond -- otherwise a stale tab would look current by accident.
139
+ *
140
+ * @param {string|null} previous stamp currently on disk, if any
141
+ * @returns {string} ISO 8601 stamp strictly after `previous`
142
+ */
143
+ function nextLastUpdated(previous) {
144
+ const parsed = previous ? Date.parse(previous) : NaN;
145
+ const stamp = Number.isNaN(parsed) ? Date.now() : Math.max(Date.now(), parsed + 1);
146
+ return new Date(stamp).toISOString();
147
+ }
148
+
149
+ // A lock older than this belongs to a writer that died holding it. Reading
150
+ // and writing one small JSON file takes milliseconds; ten seconds is not a
151
+ // writer that is merely slow. A lock whose writer is provably gone is not
152
+ // waited on at all.
153
+ const LOCK_STALE_MS = 10000;
154
+ const LOCK_RETRY_MS = 15;
155
+ const LOCK_TIMEOUT_MS = 5000;
156
+
157
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
158
+
159
+ /**
160
+ * Whether a process is running.
161
+ *
162
+ * @param {number} pid
163
+ * @returns {boolean}
164
+ */
165
+ function isAlive(pid) {
166
+ if (!Number.isInteger(pid) || pid <= 0) return false;
167
+ try {
168
+ process.kill(pid, 0);
169
+ return true;
170
+ } catch (error) {
171
+ // EPERM: it exists, it just is not ours to signal.
172
+ return error.code === 'EPERM';
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Whether creating a lock failed only because somebody else holds it.
178
+ *
179
+ * EEXIST everywhere. On Windows, also EPERM and EACCES: a file another
180
+ * process has just deleted lingers as "delete pending" while anyone still
181
+ * has it open, and creating one by the same name is refused until it is
182
+ * gone -- the lock is being released, not withheld. CI caught it: two
183
+ * writers in separate processes, and one of them died of EPERM.
184
+ *
185
+ * @param {NodeJS.ErrnoException} error
186
+ */
187
+ function lockIsBusy(error) {
188
+ if (error.code === 'EEXIST') return true;
189
+ return process.platform === 'win32' && (error.code === 'EPERM' || error.code === 'EACCES');
190
+ }
191
+
192
+ /**
193
+ * Whether a lock has been abandoned: the process that wrote it has gone, or
194
+ * it has been held far longer than any write takes.
195
+ *
196
+ * @param {string} lock
197
+ * @returns {Promise<boolean>}
198
+ */
199
+ async function isAbandoned(lock) {
200
+ const [content, stat] = await Promise.all([
201
+ fs.readFile(lock, 'utf-8').catch(() => null),
202
+ fs.stat(lock).catch(() => null)
203
+ ]);
204
+ if (content === null || !stat) return false; // released meanwhile: just try again
205
+ const owner = Number.parseInt(content, 10);
206
+ if (Number.isInteger(owner) && owner > 0 && !isAlive(owner)) return true;
207
+ return Date.now() - stat.mtimeMs > LOCK_STALE_MS;
208
+ }
209
+
210
+ /**
211
+ * Remove a lock whose writer died holding it, and say whether it was removed.
212
+ *
213
+ * Two waiters can both find the same lock abandoned. If each simply removed
214
+ * it, the second would remove the lock the first had just taken, and both
215
+ * would write at once -- the lost reply the lock exists to prevent. So
216
+ * breaking a lock is done under a lock of its own, and the one holding it
217
+ * looks again before removing anything. Nothing but a breaker removes a lock
218
+ * that is not its own, and there is one breaker at a time.
219
+ *
220
+ * @param {string} lock
221
+ * @returns {Promise<boolean>}
222
+ */
223
+ async function breakAbandonedLock(lock) {
224
+ if (!(await isAbandoned(lock))) return false;
225
+
226
+ const breaker = `${lock}.break`;
227
+ try {
228
+ await (await fs.open(breaker, 'wx')).close();
229
+ } catch (error) {
230
+ if (!lockIsBusy(error)) throw error;
231
+ // Somebody else is breaking it. One who died doing so is cleared by age.
232
+ const stat = await fs.stat(breaker).catch(() => null);
233
+ if (stat && Date.now() - stat.mtimeMs > LOCK_STALE_MS) await fs.rm(breaker, { force: true });
234
+ return false;
235
+ }
236
+
237
+ try {
238
+ if (!(await isAbandoned(lock))) return false;
239
+ await fs.rm(lock, { force: true });
240
+ return true;
241
+ } finally {
242
+ await fs.rm(breaker, { force: true });
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Run `task` holding the lock on `file`, against every process on the machine.
248
+ *
249
+ * The page is no longer the only writer. An agent answering in a thread
250
+ * writes the same file from another process -- `reviewer channel`, run by
251
+ * Claude Code -- and a read-modify-write from each, interleaved, loses one of
252
+ * them. The lock is a sibling file created with `wx`, which the operating
253
+ * system creates atomically or refuses, on every platform Node runs on, and
254
+ * which holds the writer's pid so a lock left by a process that died can be
255
+ * recognised as such.
256
+ *
257
+ * @template T
258
+ * @param {string} file
259
+ * @param {() => Promise<T>} task
260
+ * @returns {Promise<T>}
261
+ */
262
+ async function withFileLock(file, task) {
263
+ const lock = `${file}.lock`;
264
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
265
+
266
+ for (;;) {
267
+ try {
268
+ const handle = await fs.open(lock, 'wx');
269
+ try {
270
+ await handle.writeFile(`${process.pid}\n`);
271
+ } finally {
272
+ await handle.close();
273
+ }
274
+ break;
275
+ } catch (error) {
276
+ if (!lockIsBusy(error)) throw error;
277
+ if (error.code === 'EEXIST' && await breakAbandonedLock(lock)) continue;
278
+ if (Date.now() > deadline) {
279
+ if (error.code !== 'EEXIST') throw error;
280
+ throw new Error(`Timed out waiting for ${path.basename(lock)}; if no reviewer is running, delete it.`);
281
+ }
282
+ await sleep(LOCK_RETRY_MS);
283
+ }
284
+ }
285
+
286
+ try {
287
+ return await task();
288
+ } finally {
289
+ await fs.rm(lock, { force: true });
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Replace `file` in one step, so no reader ever sees half of it.
295
+ *
296
+ * A reader in another process -- an agent watching the review -- would
297
+ * otherwise be able to catch the file truncated mid-write and parse nothing.
298
+ * Windows refuses to rename over a file another process has open for a
299
+ * moment, so that is retried briefly rather than failing the save.
300
+ *
301
+ * @param {string} file
302
+ * @param {string} content
303
+ */
304
+ async function writeFileAtomic(file, content) {
305
+ const temporary = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
306
+ await fs.writeFile(temporary, content);
307
+ for (let attempt = 0; ; attempt++) {
308
+ try {
309
+ await fs.rename(temporary, file);
310
+ return;
311
+ } catch (error) {
312
+ if (attempt < 20 && ['EPERM', 'EACCES', 'EBUSY'].includes(error.code)) {
313
+ await sleep(10);
314
+ continue;
315
+ }
316
+ await fs.rm(temporary, { force: true });
317
+ throw error;
318
+ }
319
+ }
320
+ }
321
+
322
+ // Writes from this process to one file go one at a time, so they queue here
323
+ // instead of polling for the lock against each other.
324
+ /** @type {Map<string, Promise<void>>} */
325
+ const queues = new Map();
326
+
327
+ function serialize(file, task) {
328
+ const previous = queues.get(file) ?? Promise.resolve();
329
+ const run = previous.then(task, task);
330
+ const tail = run.then(() => {}, () => {});
331
+ queues.set(file, tail);
332
+ tail.then(() => {
333
+ if (queues.get(file) === tail) queues.delete(file);
334
+ });
335
+ return run;
336
+ }
337
+
338
+ /**
339
+ * @typedef {object} SavedReview
340
+ * @property {import('./comments').Comment[]} comments keyed
341
+ * @property {string|null} mode
342
+ * @property {object|null} range
343
+ * @property {boolean} [untracked]
344
+ * @property {string|null} lastUpdated
345
+ */
346
+
347
+ /**
348
+ * Change a repository's saved comments, safely against every other writer.
349
+ *
350
+ * Takes the lock, reads what is on disk now, and hands it to `update`, which
351
+ * returns the comments to write -- or null to write nothing, which is how a
352
+ * conditional save turns itself down. What it returns is written in one step
353
+ * with a `lastUpdated` strictly after the one it replaced. The scope fields
354
+ * (`mode`, `range`, `untracked`) are kept from disk unless `update` supplies
355
+ * them: a reply in a thread does not change what the review is of.
356
+ *
357
+ * @param {string} reviewsDir
358
+ * @param {string} repoPath
359
+ * @param {(current: SavedReview) => ({comments: object[], scope?: {mode: string|null, range: object|null, untracked?: boolean}}|null|Promise<object|null>)} update
360
+ * @returns {Promise<{written: boolean, comments: object[], lastUpdated: string|null}>}
361
+ */
362
+ async function updateSavedComments(reviewsDir, repoPath, update) {
363
+ const file = path.join(reviewsDir, commentsFilename(repoPath));
364
+
365
+ return serialize(file, async () => {
366
+ await fs.mkdir(reviewsDir, { recursive: true });
367
+
368
+ return withFileLock(file, async () => {
369
+ const current = await readSavedComments(reviewsDir, repoPath);
370
+ const change = await update(current);
371
+ if (!change) return { written: false, comments: current.comments, lastUpdated: current.lastUpdated };
372
+
373
+ const scope = change.scope ?? current;
374
+ const lastUpdated = nextLastUpdated(current.lastUpdated ?? null);
375
+ const data = {
376
+ repoPath,
377
+ lastUpdated,
378
+ mode: scope.mode ?? null,
379
+ range: scope.range ?? null,
380
+ ...(scope.mode === 'working' && scope.untracked === false ? { untracked: false } : {}),
381
+ comments: change.comments
382
+ };
383
+ await writeFileAtomic(file, `${JSON.stringify(data, null, 2)}\n`);
384
+
385
+ return { written: true, comments: change.comments, lastUpdated };
386
+ });
387
+ });
388
+ }
389
+
390
+ module.exports = {
391
+ readSavedComments,
392
+ updateSavedComments,
393
+ nextLastUpdated,
394
+ withFileLock,
395
+ writeFileAtomic,
396
+ isAlive,
397
+ ReviewOwnershipError,
398
+ belongsTo
399
+ };