greview-cli 0.0.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +28 -0
  3. package/dist/cli.js +1425 -0
  4. package/package.json +34 -3
package/dist/cli.js ADDED
@@ -0,0 +1,1425 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { parseArgs } from "node:util";
5
+ import { readFileSync as readFileSync2 } from "node:fs";
6
+ import { spawn, spawnSync as spawnSync2 } from "node:child_process";
7
+
8
+ // src/anchor.ts
9
+ function splitLines(text) {
10
+ if (text === "") return [];
11
+ const lines = text.split("\n");
12
+ if (lines[lines.length - 1] === "") lines.pop();
13
+ return lines;
14
+ }
15
+ var DP_BUDGET = 2e6;
16
+ function lineMap(a, b) {
17
+ const map = new Int32Array(a.length + 1);
18
+ const max = Math.min(a.length, b.length);
19
+ let prefix = 0;
20
+ while (prefix < max && a[prefix] === b[prefix]) prefix++;
21
+ let suffix = 0;
22
+ while (suffix < max - prefix && a[a.length - 1 - suffix] === b[b.length - 1 - suffix]) suffix++;
23
+ for (let i2 = 0; i2 < prefix; i2++) map[i2 + 1] = i2 + 1;
24
+ for (let i2 = 0; i2 < suffix; i2++) map[a.length - i2] = b.length - i2;
25
+ const aMid = a.slice(prefix, a.length - suffix);
26
+ const bMid = b.slice(prefix, b.length - suffix);
27
+ if (aMid.length === 0 || bMid.length === 0) return map;
28
+ if (aMid.length * bMid.length > DP_BUDGET) return map;
29
+ const w = bMid.length + 1;
30
+ const dp = new Int32Array((aMid.length + 1) * w);
31
+ for (let i2 = aMid.length - 1; i2 >= 0; i2--) {
32
+ for (let j2 = bMid.length - 1; j2 >= 0; j2--) {
33
+ dp[i2 * w + j2] = aMid[i2] === bMid[j2] ? dp[(i2 + 1) * w + j2 + 1] + 1 : Math.max(dp[(i2 + 1) * w + j2], dp[i2 * w + j2 + 1]);
34
+ }
35
+ }
36
+ let i = 0;
37
+ let j = 0;
38
+ while (i < aMid.length && j < bMid.length) {
39
+ if (aMid[i] === bMid[j]) {
40
+ map[prefix + i + 1] = prefix + j + 1;
41
+ i++;
42
+ j++;
43
+ } else if (dp[(i + 1) * w + j] >= dp[i * w + j + 1]) {
44
+ i++;
45
+ } else {
46
+ j++;
47
+ }
48
+ }
49
+ return map;
50
+ }
51
+ function findBlock(b, block, limit) {
52
+ const out = [];
53
+ if (block.length === 0 || block.length > b.length) return out;
54
+ for (let i = 0; i + block.length <= b.length; i++) {
55
+ let hit = true;
56
+ for (let k = 0; k < block.length && hit; k++) hit = b[i + k] === block[k];
57
+ if (hit) {
58
+ out.push(i + 1);
59
+ if (out.length > limit) break;
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+ function mapRange(aText, bText, start, end) {
65
+ const a = splitLines(aText);
66
+ const b = splitLines(bText);
67
+ const s = Math.max(1, Math.min(start, a.length || 1));
68
+ const e = Math.max(s, Math.min(end, a.length || 1));
69
+ if (aText === bText) return { drift: "current", start: s, end: e };
70
+ const map = lineMap(a, b);
71
+ let intact2 = map[s] !== 0;
72
+ for (let l = s; intact2 && l < e; l++) {
73
+ if (map[l + 1] !== map[l] + 1) intact2 = false;
74
+ }
75
+ if (intact2) {
76
+ const ns2 = map[s];
77
+ const ne2 = map[e];
78
+ return { drift: ns2 === s && ne2 === e ? "current" : "moved", start: ns2, end: ne2 };
79
+ }
80
+ const block = a.slice(s - 1, e);
81
+ const found = findBlock(b, block, 4);
82
+ const distinctive = block.length >= 2 || block.join("").trim().length >= 12;
83
+ if (found.length === 1 || found.length > 1 && found.length <= 4 && distinctive) {
84
+ let best = found[0];
85
+ for (const o of found) {
86
+ if (Math.abs(o - s) < Math.abs(best - s)) best = o;
87
+ }
88
+ return { drift: "moved", start: best, end: best + block.length - 1 };
89
+ }
90
+ let ns = 0;
91
+ for (let l = s - 1; l >= 1; l--) {
92
+ if (map[l]) {
93
+ ns = map[l] + 1;
94
+ break;
95
+ }
96
+ }
97
+ if (ns === 0) ns = 1;
98
+ let ne = 0;
99
+ for (let l = e + 1; l <= a.length; l++) {
100
+ if (map[l]) {
101
+ ne = map[l] - 1;
102
+ break;
103
+ }
104
+ }
105
+ if (ne === 0) ne = b.length;
106
+ return { drift: "changed", start: ns, end: ne };
107
+ }
108
+ function sliceLines(text, start, end) {
109
+ if (end < start) return [];
110
+ return splitLines(text).slice(Math.max(0, start - 1), end);
111
+ }
112
+
113
+ // src/git.ts
114
+ import { spawnSync } from "node:child_process";
115
+ import { readFileSync } from "node:fs";
116
+ import { isAbsolute, join, relative, resolve } from "node:path";
117
+ var GitError = class extends Error {
118
+ };
119
+ function git(cwd, args) {
120
+ const r = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
121
+ if (r.error) throw new GitError(`failed to run git: ${r.error.message}`);
122
+ return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
123
+ }
124
+ function gitOk(cwd, args) {
125
+ const r = git(cwd, args);
126
+ if (r.status !== 0) throw new GitError(`git ${args.join(" ")} failed: ${r.stderr.trim()}`);
127
+ return r.stdout;
128
+ }
129
+ function findRepo(cwd) {
130
+ const root = gitOk(cwd, ["rev-parse", "--show-toplevel"]).trim();
131
+ const gitDir = gitOk(cwd, ["rev-parse", "--absolute-git-dir"]).trim();
132
+ if (!root) throw new GitError("not inside a git working tree");
133
+ return { root, gitDir };
134
+ }
135
+ function headSha(repo) {
136
+ const r = git(repo.root, ["rev-parse", "HEAD"]);
137
+ return r.status === 0 ? r.stdout.trim() : null;
138
+ }
139
+ function branchName(repo) {
140
+ const r = git(repo.root, ["symbolic-ref", "--short", "-q", "HEAD"]);
141
+ const name = r.stdout.trim();
142
+ return r.status === 0 && name ? name : null;
143
+ }
144
+ function toRepoPath(repo, input, cwd = process.cwd()) {
145
+ const r = git(cwd, ["ls-files", "--full-name", "-co", "--error-unmatch", "--", input]);
146
+ if (r.status === 0) {
147
+ const first = r.stdout.split("\n").find((l) => l.trim() !== "");
148
+ if (first) return first.trim();
149
+ }
150
+ const abs = isAbsolute(input) ? input : resolve(cwd, input);
151
+ const rel = relative(repo.root, abs);
152
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
153
+ throw new GitError(`${input} is outside ${repo.root}`);
154
+ }
155
+ return rel.split("\\").join("/");
156
+ }
157
+ function readVersion(repo, version, path) {
158
+ if (version === "worktree") {
159
+ try {
160
+ return readFileSync(join(repo.root, path), "utf8");
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+ const spec = version === "index" ? `:${path}` : `HEAD:${path}`;
166
+ const r = git(repo.root, ["cat-file", "blob", spec]);
167
+ return r.status === 0 ? r.stdout : null;
168
+ }
169
+ function versionFor(target, side) {
170
+ if (side === "new") return target === "index" ? "index" : "worktree";
171
+ return target === "worktree" ? "index" : "head";
172
+ }
173
+ var DIFF_ARGS = {
174
+ worktree: [],
175
+ index: ["--cached"],
176
+ head: ["HEAD"]
177
+ };
178
+ function hunks(repo, target, path) {
179
+ const r = git(repo.root, [
180
+ "--no-pager",
181
+ "diff",
182
+ ...DIFF_ARGS[target],
183
+ "--no-color",
184
+ "-U0",
185
+ "--",
186
+ path
187
+ ]);
188
+ if (r.status !== 0) return [];
189
+ const out = [];
190
+ for (const line of r.stdout.split("\n")) {
191
+ const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
192
+ if (!m) continue;
193
+ out.push({
194
+ header: line,
195
+ oldStart: Number(m[1]),
196
+ oldCount: m[2] === void 0 ? 1 : Number(m[2]),
197
+ newStart: Number(m[3]),
198
+ newCount: m[4] === void 0 ? 1 : Number(m[4])
199
+ });
200
+ }
201
+ return out;
202
+ }
203
+ function hunkHeaderAt(repo, target, side, path, line) {
204
+ for (const h of hunks(repo, target, path)) {
205
+ const start = side === "new" ? h.newStart : h.oldStart;
206
+ const count = side === "new" ? h.newCount : h.oldCount;
207
+ if (count === 0 ? line === start || line === start + 1 : line >= start && line < start + count) {
208
+ return h.header;
209
+ }
210
+ }
211
+ return null;
212
+ }
213
+ function renameOf(repo, path) {
214
+ const r = git(repo.root, [
215
+ "--no-pager",
216
+ "diff",
217
+ "HEAD",
218
+ "--name-status",
219
+ "--find-renames",
220
+ "--diff-filter=R"
221
+ ]);
222
+ if (r.status !== 0) return null;
223
+ for (const line of r.stdout.split("\n")) {
224
+ const parts = line.split(" ");
225
+ if (parts.length >= 3 && parts[0].startsWith("R") && parts[1] === path) return parts[2];
226
+ }
227
+ return null;
228
+ }
229
+
230
+ // src/store.ts
231
+ import { createHash } from "node:crypto";
232
+ import { mkdirSync } from "node:fs";
233
+ import { dirname, join as join2 } from "node:path";
234
+
235
+ // src/sqlite.ts
236
+ import { createRequire } from "node:module";
237
+ var cached = null;
238
+ function sqlite() {
239
+ if (cached) return cached;
240
+ const original = process.emitWarning;
241
+ process.emitWarning = ((warning, ...rest) => {
242
+ const text = typeof warning === "string" ? warning : String(warning?.message ?? "");
243
+ if (/SQLite is an experimental feature/i.test(text)) return;
244
+ return original(warning, ...rest);
245
+ });
246
+ try {
247
+ cached = createRequire(import.meta.url)("node:sqlite");
248
+ } finally {
249
+ process.emitWarning = original;
250
+ }
251
+ return cached;
252
+ }
253
+
254
+ // src/store.ts
255
+ var MIGRATIONS = [
256
+ `
257
+ CREATE TABLE IF NOT EXISTS blobs (
258
+ sha TEXT PRIMARY KEY,
259
+ content TEXT NOT NULL
260
+ );
261
+ CREATE TABLE IF NOT EXISTS threads (
262
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
263
+ file_path TEXT NOT NULL,
264
+ side TEXT NOT NULL,
265
+ target TEXT NOT NULL,
266
+ anchor_blob TEXT NOT NULL REFERENCES blobs(sha),
267
+ anchor_start INTEGER NOT NULL,
268
+ anchor_end INTEGER NOT NULL,
269
+ hunk_header TEXT,
270
+ status TEXT NOT NULL DEFAULT 'open',
271
+ created_at TEXT NOT NULL,
272
+ updated_at TEXT NOT NULL,
273
+ resolved_at TEXT,
274
+ resolved_by TEXT,
275
+ drift TEXT,
276
+ cur_start INTEGER,
277
+ cur_end INTEGER,
278
+ locations TEXT,
279
+ checked_at TEXT
280
+ );
281
+ CREATE INDEX IF NOT EXISTS threads_by_file ON threads(file_path);
282
+ CREATE TABLE IF NOT EXISTS comments (
283
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
284
+ thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
285
+ author TEXT NOT NULL,
286
+ author_kind TEXT NOT NULL,
287
+ body TEXT NOT NULL,
288
+ created_at TEXT NOT NULL
289
+ );
290
+ CREATE INDEX IF NOT EXISTS comments_by_thread ON comments(thread_id);
291
+ CREATE TABLE IF NOT EXISTS events (
292
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
293
+ thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
294
+ kind TEXT NOT NULL,
295
+ detail TEXT NOT NULL,
296
+ at TEXT NOT NULL
297
+ );
298
+ CREATE INDEX IF NOT EXISTS events_by_thread ON events(thread_id);
299
+ `,
300
+ "ALTER TABLE threads ADD COLUMN base_locations TEXT",
301
+ "ALTER TABLE comments ADD COLUMN edited_at TEXT",
302
+ `
303
+ CREATE TABLE IF NOT EXISTS onsubmit (
304
+ name TEXT PRIMARY KEY,
305
+ command TEXT NOT NULL,
306
+ created_at TEXT NOT NULL
307
+ );
308
+ `
309
+ ];
310
+ function one(v) {
311
+ return v ?? null;
312
+ }
313
+ function many(v) {
314
+ return v;
315
+ }
316
+ function dbPathFor(repo) {
317
+ return join2(repo.gitDir, "review", "comments.sqlite");
318
+ }
319
+ function sha256(text) {
320
+ return createHash("sha256").update(text, "utf8").digest("hex");
321
+ }
322
+ function nowIso() {
323
+ return (/* @__PURE__ */ new Date()).toISOString();
324
+ }
325
+ var Store = class {
326
+ db;
327
+ path;
328
+ constructor(repo) {
329
+ this.path = dbPathFor(repo);
330
+ mkdirSync(dirname(this.path), { recursive: true });
331
+ this.db = new (sqlite()).DatabaseSync(this.path);
332
+ this.db.exec("PRAGMA journal_mode = WAL");
333
+ this.db.exec("PRAGMA busy_timeout = 5000");
334
+ this.db.exec("PRAGMA foreign_keys = ON");
335
+ this.migrate();
336
+ }
337
+ close() {
338
+ this.db.close();
339
+ }
340
+ migrate() {
341
+ this.db.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
342
+ const found = Number(this.getMeta("schema_version") ?? "0");
343
+ if (found > MIGRATIONS.length) {
344
+ throw new Error(
345
+ `database schema v${found} is newer than this greview (v${MIGRATIONS.length}); upgrade the CLI`
346
+ );
347
+ }
348
+ for (let version = found; version < MIGRATIONS.length; version++) {
349
+ this.db.exec(MIGRATIONS[version]);
350
+ }
351
+ if (found !== MIGRATIONS.length) this.setMeta("schema_version", String(MIGRATIONS.length));
352
+ }
353
+ setMeta(key, value) {
354
+ this.db.prepare("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?").run(key, value, value);
355
+ }
356
+ getMeta(key) {
357
+ const row = this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
358
+ return row ? row.value : null;
359
+ }
360
+ /** Bumped on every write. */
361
+ bumpRevision() {
362
+ const next = Number(this.getMeta("revision") ?? "0") + 1;
363
+ this.setMeta("revision", String(next));
364
+ }
365
+ revision() {
366
+ return Number(this.getMeta("revision") ?? "0");
367
+ }
368
+ putBlob(content) {
369
+ const sha = sha256(content);
370
+ this.db.prepare("INSERT OR IGNORE INTO blobs (sha, content) VALUES (?, ?)").run(sha, content);
371
+ return sha;
372
+ }
373
+ getBlob(sha) {
374
+ const row = this.db.prepare("SELECT content FROM blobs WHERE sha = ?").get(sha);
375
+ return row ? row.content : null;
376
+ }
377
+ createThread(input) {
378
+ const at = nowIso();
379
+ const blob = this.putBlob(input.content);
380
+ const info = this.db.prepare(
381
+ `INSERT INTO threads
382
+ (file_path, side, target, anchor_blob, anchor_start, anchor_end, hunk_header,
383
+ status, created_at, updated_at)
384
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`
385
+ ).run(
386
+ input.filePath,
387
+ input.side,
388
+ input.target,
389
+ blob,
390
+ input.start,
391
+ input.end,
392
+ input.hunkHeader,
393
+ at,
394
+ at
395
+ );
396
+ const id = Number(info.lastInsertRowid);
397
+ this.addComment(id, input.author, input.authorKind, input.body);
398
+ this.addEvent(id, "created", { target: input.target, side: input.side });
399
+ this.bumpRevision();
400
+ return id;
401
+ }
402
+ addComment(threadId, author, authorKind, body) {
403
+ const at = nowIso();
404
+ const info = this.db.prepare(
405
+ `INSERT INTO comments (thread_id, author, author_kind, body, created_at)
406
+ VALUES (?, ?, ?, ?, ?)`
407
+ ).run(threadId, author, authorKind, body, at);
408
+ this.db.prepare("UPDATE threads SET updated_at = ? WHERE id = ?").run(at, threadId);
409
+ this.bumpRevision();
410
+ return Number(info.lastInsertRowid);
411
+ }
412
+ /** Rewrites a comment body. Returns the thread it belongs to, or null. */
413
+ editComment(commentId, body) {
414
+ const row = one(
415
+ this.db.prepare("SELECT thread_id FROM comments WHERE id = ?").get(commentId)
416
+ );
417
+ if (row === null) return null;
418
+ const at = nowIso();
419
+ this.db.prepare("UPDATE comments SET body = ?, edited_at = ? WHERE id = ?").run(body, at, commentId);
420
+ this.db.prepare("UPDATE threads SET updated_at = ? WHERE id = ?").run(at, row.thread_id);
421
+ this.bumpRevision();
422
+ return row.thread_id;
423
+ }
424
+ addEvent(threadId, kind, detail) {
425
+ this.db.prepare("INSERT INTO events (thread_id, kind, detail, at) VALUES (?, ?, ?, ?)").run(threadId, kind, JSON.stringify(detail), nowIso());
426
+ }
427
+ setStatus(threadId, status, by) {
428
+ const at = nowIso();
429
+ this.db.prepare("UPDATE threads SET status = ?, resolved_at = ?, resolved_by = ?, updated_at = ? WHERE id = ?").run(status, status === "resolved" ? at : null, status === "resolved" ? by : null, at, threadId);
430
+ this.addEvent(threadId, status === "resolved" ? "resolved" : "unresolved", { by });
431
+ this.bumpRevision();
432
+ }
433
+ /**
434
+ * Persists a fresh resolution, but only when it differs: the extension watches
435
+ * this file, so an unchanged row must not be stamped.
436
+ */
437
+ saveResolution(threadId, r) {
438
+ const locations = JSON.stringify(r.locations);
439
+ const current = one(
440
+ this.db.prepare("SELECT drift, cur_start, cur_end, locations FROM threads WHERE id = ?").get(threadId)
441
+ );
442
+ if (current !== null && current.drift === r.drift && current.cur_start === r.start && current.cur_end === r.end && current.locations === locations) {
443
+ return;
444
+ }
445
+ this.db.prepare("UPDATE threads SET drift = ?, cur_start = ?, cur_end = ?, locations = ?, checked_at = ? WHERE id = ?").run(r.drift, r.start, r.end, locations, nowIso(), threadId);
446
+ }
447
+ /** Written once, on the first sync. */
448
+ saveBaseLocations(threadId, locations) {
449
+ this.db.prepare("UPDATE threads SET base_locations = ? WHERE id = ? AND base_locations IS NULL").run(JSON.stringify(locations), threadId);
450
+ }
451
+ deleteThread(threadId) {
452
+ const info = this.db.prepare("DELETE FROM threads WHERE id = ?").run(threadId);
453
+ this.bumpRevision();
454
+ return info.changes > 0;
455
+ }
456
+ thread(id) {
457
+ return one(this.db.prepare("SELECT * FROM threads WHERE id = ?").get(id));
458
+ }
459
+ threads() {
460
+ return many(
461
+ this.db.prepare("SELECT * FROM threads ORDER BY file_path, anchor_start, id").all()
462
+ );
463
+ }
464
+ comments(threadId) {
465
+ return many(
466
+ this.db.prepare("SELECT * FROM comments WHERE thread_id = ? ORDER BY id").all(threadId)
467
+ );
468
+ }
469
+ events(threadId) {
470
+ return many(
471
+ this.db.prepare("SELECT * FROM events WHERE thread_id = ? ORDER BY id").all(threadId)
472
+ );
473
+ }
474
+ lastEvent(threadId, kind) {
475
+ return one(
476
+ this.db.prepare("SELECT * FROM events WHERE thread_id = ? AND kind = ? ORDER BY id DESC LIMIT 1").get(threadId, kind)
477
+ );
478
+ }
479
+ callbacks() {
480
+ return many(this.db.prepare("SELECT * FROM onsubmit ORDER BY name").all());
481
+ }
482
+ /** Adds or replaces a submit hook, keyed by name. */
483
+ putCallback(name, command) {
484
+ this.db.prepare(
485
+ `INSERT INTO onsubmit (name, command, created_at) VALUES (?, ?, ?)
486
+ ON CONFLICT(name) DO UPDATE SET command = excluded.command`
487
+ ).run(name, command, nowIso());
488
+ this.bumpRevision();
489
+ }
490
+ deleteCallback(name) {
491
+ const info = this.db.prepare("DELETE FROM onsubmit WHERE name = ?").run(name);
492
+ this.bumpRevision();
493
+ return info.changes > 0;
494
+ }
495
+ clearCallbacks() {
496
+ const info = this.db.prepare("DELETE FROM onsubmit").run();
497
+ this.bumpRevision();
498
+ return Number(info.changes);
499
+ }
500
+ transaction(fn) {
501
+ this.db.exec("BEGIN");
502
+ try {
503
+ const out = fn();
504
+ this.db.exec("COMMIT");
505
+ return out;
506
+ } catch (e) {
507
+ this.db.exec("ROLLBACK");
508
+ throw e;
509
+ }
510
+ }
511
+ };
512
+
513
+ // src/sync.ts
514
+ var VERSIONS = ["worktree", "index", "head"];
515
+ function intact(d) {
516
+ return d === "current" || d === "moved";
517
+ }
518
+ function sameRegion(a, b) {
519
+ return a !== null && b !== null && a.lines.join("\n") === b.lines.join("\n");
520
+ }
521
+ function resolveThread(repo, store, row) {
522
+ const anchor = store.getBlob(row.anchor_blob);
523
+ if (anchor === null) {
524
+ return {
525
+ drift: "orphaned",
526
+ region: null,
527
+ locations: { worktree: "orphaned", index: "orphaned", head: "orphaned" },
528
+ notes: [
529
+ {
530
+ code: "snapshot-missing",
531
+ text: "the recorded snapshot of this file is missing from the database"
532
+ }
533
+ ],
534
+ renamedTo: null
535
+ };
536
+ }
537
+ let path = row.file_path;
538
+ let renamedTo = null;
539
+ if (readVersion(repo, "worktree", path) === null) {
540
+ const moved = renameOf(repo, path);
541
+ if (moved && readVersion(repo, "worktree", moved) !== null) {
542
+ renamedTo = moved;
543
+ path = moved;
544
+ }
545
+ }
546
+ const locations = {};
547
+ const regions = {};
548
+ for (const v of VERSIONS) {
549
+ const readPath = v === "worktree" ? path : row.file_path;
550
+ const content = readVersion(repo, v, readPath);
551
+ if (content === null) {
552
+ locations[v] = "orphaned";
553
+ regions[v] = null;
554
+ continue;
555
+ }
556
+ const m = mapRange(anchor, content, row.anchor_start, row.anchor_end);
557
+ locations[v] = m.drift;
558
+ regions[v] = { start: m.start, end: m.end, lines: sliceLines(content, m.start, m.end) };
559
+ }
560
+ const primary = versionFor(row.target, row.side);
561
+ const drift = locations[primary];
562
+ const region = regions[primary];
563
+ const baseline = row.base_locations ? JSON.parse(row.base_locations) : null;
564
+ const wasIntact = (v) => intact(baseline === null ? locations[v] : baseline[v]);
565
+ const notes = [];
566
+ const note = (code, text, args) => {
567
+ notes.push(args ? { code, text, args } : { code, text });
568
+ };
569
+ if (renamedTo) note("renamed", `file renamed to ${renamedTo}`, { path: renamedTo });
570
+ if (drift === "changed") {
571
+ if (region.end < region.start) note("deleted", "the commented lines were deleted");
572
+ else note("edited", "the commented lines were edited");
573
+ }
574
+ if (drift === "orphaned") {
575
+ note("orphaned", `${row.file_path} does not exist in the ${primary} version`, {
576
+ path: row.file_path,
577
+ version: primary
578
+ });
579
+ }
580
+ if (row.side === "new" && row.target === "worktree") {
581
+ if (intact(locations.index)) {
582
+ if (!wasIntact("index")) note("staged", "content is now staged");
583
+ } else if (sameRegion(regions.worktree, regions.index)) {
584
+ note("replacement-staged", "the lines that replaced them are staged");
585
+ }
586
+ }
587
+ if (row.side === "new" && intact(locations.head) && !wasIntact("head")) {
588
+ note("committed", "content is now committed in HEAD");
589
+ }
590
+ if (row.side === "new" && row.target !== "worktree" && !intact(locations.worktree) && drift !== "orphaned") {
591
+ note("worktree-diverged", "the working tree has since diverged from this content");
592
+ }
593
+ return { drift, region, locations, notes, renamedTo };
594
+ }
595
+ function syncThread(repo, store, row) {
596
+ const r = resolveThread(repo, store, row);
597
+ const prev = row.locations ? JSON.parse(row.locations) : null;
598
+ if (row.base_locations === null) store.saveBaseLocations(row.id, r.locations);
599
+ if (row.drift !== null && row.drift !== r.drift) {
600
+ const anchorLines = splitLines(store.getBlob(row.anchor_blob) ?? "").slice(
601
+ row.anchor_start - 1,
602
+ row.anchor_end
603
+ );
604
+ store.addEvent(row.id, "drift", {
605
+ from: row.drift,
606
+ to: r.drift,
607
+ before: anchorLines,
608
+ after: r.region ? r.region.lines : [],
609
+ beforeRange: [row.anchor_start, row.anchor_end],
610
+ afterRange: r.region ? [r.region.start, r.region.end] : null
611
+ });
612
+ }
613
+ if (prev) {
614
+ if (!intact(prev.index) && intact(r.locations.index)) {
615
+ store.addEvent(row.id, "staged", { drift: r.locations.index });
616
+ } else if (intact(prev.index) && !intact(r.locations.index)) {
617
+ store.addEvent(row.id, "unstaged", { drift: r.locations.index });
618
+ }
619
+ if (!intact(prev.head) && intact(r.locations.head)) {
620
+ store.addEvent(row.id, "committed", { drift: r.locations.head });
621
+ }
622
+ }
623
+ store.saveResolution(row.id, {
624
+ drift: r.drift,
625
+ start: r.region ? r.region.start : null,
626
+ end: r.region ? r.region.end : null,
627
+ locations: r.locations
628
+ });
629
+ return r;
630
+ }
631
+ function syncAll(repo, store) {
632
+ const out = /* @__PURE__ */ new Map();
633
+ store.transaction(() => {
634
+ for (const row of store.threads()) out.set(row.id, syncThread(repo, store, row));
635
+ });
636
+ return out;
637
+ }
638
+ function toThread(store, row, r, opts = {}) {
639
+ const anchorText = store.getBlob(row.anchor_blob) ?? "";
640
+ const anchorLines = splitLines(anchorText).slice(row.anchor_start - 1, row.anchor_end);
641
+ const comments = store.comments(row.id).map((c) => ({
642
+ id: c.id,
643
+ threadId: c.thread_id,
644
+ author: c.author,
645
+ authorKind: c.author_kind,
646
+ body: c.body,
647
+ createdAt: c.created_at,
648
+ editedAt: c.edited_at
649
+ }));
650
+ const events = opts.events ? store.events(row.id).map((e) => ({
651
+ id: e.id,
652
+ threadId: e.thread_id,
653
+ kind: e.kind,
654
+ detail: JSON.parse(e.detail),
655
+ at: e.at
656
+ })) : [];
657
+ const displayPath = r.renamedTo ?? row.file_path;
658
+ const ref = r.region ? `${displayPath}:${r.region.start}${r.region.end > r.region.start ? `-${r.region.end}` : ""}` : `${row.file_path}:${row.anchor_start}${row.anchor_end > row.anchor_start ? `-${row.anchor_end}` : ""}`;
659
+ return {
660
+ id: row.id,
661
+ filePath: displayPath,
662
+ side: row.side,
663
+ target: row.target,
664
+ status: row.status,
665
+ createdAt: row.created_at,
666
+ updatedAt: row.updated_at,
667
+ resolvedAt: row.resolved_at,
668
+ resolvedBy: row.resolved_by,
669
+ anchor: {
670
+ blob: row.anchor_blob,
671
+ start: row.anchor_start,
672
+ end: row.anchor_end,
673
+ lines: anchorLines,
674
+ hunkHeader: row.hunk_header
675
+ },
676
+ current: {
677
+ drift: r.drift,
678
+ region: r.region,
679
+ locations: r.locations,
680
+ notes: r.notes,
681
+ checkedAt: row.checked_at ?? (/* @__PURE__ */ new Date()).toISOString()
682
+ },
683
+ ref,
684
+ comments,
685
+ events
686
+ };
687
+ }
688
+
689
+ // src/format.ts
690
+ var useColor = process.stdout.isTTY === true && process.env.NO_COLOR === void 0 && process.env.TERM !== "dumb";
691
+ var wrap = (code) => (s) => useColor ? `\x1B[${code}m${s}\x1B[0m` : s;
692
+ var dim = wrap("2");
693
+ var bold = wrap("1");
694
+ var red = wrap("31");
695
+ var green = wrap("32");
696
+ var yellow = wrap("33");
697
+ var blue = wrap("34");
698
+ var magenta = wrap("35");
699
+ var cyan = wrap("36");
700
+ var DRIFT_LABEL = {
701
+ current: "current",
702
+ moved: "moved",
703
+ changed: "changed",
704
+ orphaned: "orphaned"
705
+ };
706
+ function driftBadge(d) {
707
+ const label = DRIFT_LABEL[d];
708
+ if (d === "current") return green(label);
709
+ if (d === "moved") return dim(label);
710
+ if (d === "changed") return yellow(`! ${label}`);
711
+ return red(`! ${label}`);
712
+ }
713
+ var TARGET_LABEL = {
714
+ worktree: "unstaged diff",
715
+ index: "staged diff",
716
+ head: "HEAD..worktree diff"
717
+ };
718
+ function targetLabel(t) {
719
+ return TARGET_LABEL[t];
720
+ }
721
+ function sideLabel(s) {
722
+ return s === "new" ? "new side" : "old side";
723
+ }
724
+ function placeLabel(t) {
725
+ return `${targetLabel(t.target)} \xB7 ${sideLabel(t.side)}`;
726
+ }
727
+ function firstLine(body) {
728
+ const line = body.split("\n")[0] ?? "";
729
+ return line.length > 72 ? `${line.slice(0, 71)}\u2026` : line;
730
+ }
731
+ function gutter(lines, start, sign, paint) {
732
+ if (lines.length === 0) return [` ${dim("(no lines \u2014 region deleted)")}`];
733
+ const width = String(start + lines.length - 1).length;
734
+ return lines.map((l, i) => {
735
+ const n = String(start + i).padStart(width);
736
+ return ` ${dim(n)} ${dim("\u2502")} ${paint(`${sign}${l}`)}`;
737
+ });
738
+ }
739
+ function threadLine(t) {
740
+ const status = t.status === "resolved" ? green("resolved") : bold("open");
741
+ const head = `${magenta(`#${t.id}`)} ${cyan(t.ref)}`;
742
+ const meta = [status, driftBadge(t.current.drift), dim(placeLabel(t))].join(" ");
743
+ const who = t.comments[0]?.author ?? "?";
744
+ const n = t.comments.length > 1 ? dim(` (+${t.comments.length - 1})`) : "";
745
+ return `${head} ${meta}
746
+ ${dim(`${who}:`)} ${firstLine(t.comments[0]?.body ?? "")}${n}`;
747
+ }
748
+ function eventLine(e) {
749
+ const at = dim(e.at.replace("T", " ").slice(0, 19));
750
+ switch (e.kind) {
751
+ case "drift":
752
+ return `${at} ${yellow("drift")} ${e.detail.from} \u2192 ${e.detail.to}`;
753
+ case "staged":
754
+ return `${at} ${blue("staged")} the commented content entered the index`;
755
+ case "unstaged":
756
+ return `${at} ${blue("unstaged")} the commented content left the index`;
757
+ case "committed":
758
+ return `${at} ${blue("committed")} the commented content is in HEAD`;
759
+ case "resolved":
760
+ return `${at} ${green("resolved")} by ${e.detail.by}`;
761
+ case "unresolved":
762
+ return `${at} ${yellow("reopened")} by ${e.detail.by}`;
763
+ default:
764
+ return `${at} ${e.kind}`;
765
+ }
766
+ }
767
+ function threadDetail(t) {
768
+ const out = [];
769
+ const status = t.status === "resolved" ? green("resolved") : bold("open");
770
+ out.push(
771
+ `${magenta(`#${t.id}`)} ${cyan(t.ref)} ${status} ${driftBadge(t.current.drift)} ${dim(
772
+ placeLabel(t)
773
+ )}`
774
+ );
775
+ for (const note of t.current.notes) out.push(` ${yellow("\u2022")} ${note.text}`);
776
+ if (t.anchor.hunkHeader) out.push(` ${dim(t.anchor.hunkHeader)}`);
777
+ out.push("");
778
+ if (t.current.drift === "current" || t.current.drift === "moved") {
779
+ const region = t.current.region;
780
+ const start = region ? region.start : t.anchor.start;
781
+ const end = region ? region.end : t.anchor.end;
782
+ out.push(` ${bold("commented lines")} ${dim(`(lines ${start}-${end})`)}`);
783
+ out.push(...gutter(region ? region.lines : t.anchor.lines, start, " ", (s) => s));
784
+ } else {
785
+ out.push(` ${bold("when commented")} ${dim(`(lines ${t.anchor.start}-${t.anchor.end})`)}`);
786
+ out.push(...gutter(t.anchor.lines, t.anchor.start, t.current.drift === "changed" ? "-" : " ", red));
787
+ out.push("");
788
+ if (t.current.region) {
789
+ const r = t.current.region;
790
+ out.push(` ${bold("now")} ${dim(`(lines ${r.start}-${r.end})`)}`);
791
+ out.push(...gutter(r.lines, r.start, t.current.drift === "changed" ? "+" : " ", green));
792
+ } else {
793
+ out.push(` ${bold("now")} ${red("the file is gone")}`);
794
+ }
795
+ }
796
+ out.push("");
797
+ out.push(` ${dim("\u2500\u2500 comments \u2500\u2500")}`);
798
+ for (const c of t.comments) {
799
+ const kind = c.authorKind === "agent" ? dim(" (agent)") : "";
800
+ const edited = c.editedAt === null ? "" : dim(" \xB7 edited");
801
+ const at = dim(c.createdAt.replace("T", " ").slice(0, 19));
802
+ out.push(` ${bold(c.author)}${kind} ${at} ${dim(`\xB7 comment #${c.id}`)}${edited}`);
803
+ for (const line of c.body.split("\n")) out.push(` ${line}`);
804
+ }
805
+ const interesting = t.events.filter((e) => e.kind !== "created");
806
+ if (interesting.length > 0) {
807
+ out.push("");
808
+ out.push(` ${dim("\u2500\u2500 history \u2500\u2500")}`);
809
+ for (const e of interesting) out.push(` ${eventLine(e)}`);
810
+ }
811
+ return out.join("\n");
812
+ }
813
+
814
+ // src/cli.ts
815
+ function packageVersion() {
816
+ const pkg = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
817
+ if (typeof pkg.version !== "string" || pkg.version === "") {
818
+ throw new Error("greview package version is missing");
819
+ }
820
+ return pkg.version;
821
+ }
822
+ var VERSION = packageVersion();
823
+ var USAGE = `greview ${VERSION} \u2014 review threads anchored to git diffs
824
+
825
+ Usage: greview <command> [options]
826
+
827
+ Commands
828
+ list List threads (open ones by default)
829
+ show <id> One thread in full, with before/after and history
830
+ add Start a thread on a line range
831
+ reply <id> -m <text> Add a comment to a thread
832
+ edit <comment-id> -m <t> Rewrite one comment (ids shown by "show")
833
+ resolve <id> Mark resolved unresolve <id> reopen
834
+ rm <id> Delete a thread and its comments
835
+ sync Re-anchor every thread and record what moved
836
+ stats Counts, for status lines and hooks
837
+ repo Repo root, git dir and database path
838
+ install-skill Install the greview skill for coding agents
839
+ onsubmit <sub> Commands to run when the reviewer presses Submit:
840
+ list show this worktree's hooks
841
+ add <name> <command> add or replace one
842
+ delete <name> remove one
843
+ clear remove all
844
+ run run them all, concurrently
845
+
846
+ Options
847
+ --cwd <dir> Run as if in this directory (default: cwd)
848
+ --json Machine-readable output; every response is
849
+ {"ok":true,"data":...} or {"ok":false,"error":"..."}
850
+
851
+ list/show
852
+ --all Include resolved threads
853
+ --resolved Only resolved threads
854
+ --file <path> Restrict to one file
855
+ --target <t> Restrict to worktree | index | head
856
+ --events Include the event history in --json output
857
+ --no-sync Skip re-anchoring; report the last known positions
858
+
859
+ add
860
+ --file <path> File to comment on (required)
861
+ --line <n|n-m> Line range in the current version (required)
862
+ --side new|old Diff side (default: new)
863
+ --target worktree|index|head
864
+ Which diff the comment belongs to (default: worktree)
865
+ -m, --message <text> Comment body; use "-" to read stdin
866
+
867
+ add/reply/edit/resolve
868
+ --author <name> Default: $GREVIEW_AUTHOR, then git config user.name for
869
+ a person, or the agent's own name for an agent
870
+ --agent Record the author as an agent rather than a human.
871
+ Implied when $AI_AGENT or $CLAUDECODE is set; override
872
+ with GREVIEW_AUTHOR_KIND=human. An agent is never
873
+ recorded under git config user.name.
874
+
875
+ Submit hooks are per-worktree and run through the shell with no arguments. They
876
+ are how something outside greview hears that a review is ready; greview does not
877
+ care what they do.
878
+
879
+ Anchoring: a thread stores the exact text of the lines it was written against.
880
+ Later, "drift" says what became of them \u2014 current, moved, changed or orphaned.
881
+ Nothing is ever auto-resolved; resolving is a human decision.
882
+ `;
883
+ var UsageError = class extends Error {
884
+ };
885
+ var SKILL_INSTALL_ARGS = [
886
+ "--yes",
887
+ "skills",
888
+ "add",
889
+ "YouXam/greview",
890
+ "--skill",
891
+ "greview",
892
+ "--global",
893
+ "--yes"
894
+ ];
895
+ function fail(message) {
896
+ throw new UsageError(message);
897
+ }
898
+ function cmdInstallSkill() {
899
+ const executable = process.platform === "win32" ? "npx.cmd" : "npx";
900
+ const result = spawnSync2(executable, SKILL_INSTALL_ARGS, { stdio: "inherit" });
901
+ if (result.error) {
902
+ process.stderr.write(`greview: could not start npx: ${result.error.message}
903
+ `);
904
+ return 1;
905
+ }
906
+ if (result.signal) {
907
+ process.stderr.write(`greview: skill installer stopped by ${result.signal}
908
+ `);
909
+ return 1;
910
+ }
911
+ return result.status ?? 1;
912
+ }
913
+ function parse(argv) {
914
+ return parseArgs({
915
+ args: argv,
916
+ allowPositionals: true,
917
+ strict: true,
918
+ options: {
919
+ cwd: { type: "string" },
920
+ json: { type: "boolean", default: false },
921
+ all: { type: "boolean", default: false },
922
+ resolved: { type: "boolean", default: false },
923
+ file: { type: "string" },
924
+ line: { type: "string" },
925
+ side: { type: "string" },
926
+ target: { type: "string" },
927
+ message: { type: "string", short: "m" },
928
+ author: { type: "string" },
929
+ agent: { type: "boolean", default: false },
930
+ by: { type: "string" },
931
+ events: { type: "boolean", default: false },
932
+ "no-sync": { type: "boolean", default: false },
933
+ help: { type: "boolean", short: "h", default: false },
934
+ version: { type: "boolean", short: "v", default: false }
935
+ }
936
+ });
937
+ }
938
+ function agentName() {
939
+ const declared = process.env.AI_AGENT?.trim();
940
+ if (declared) {
941
+ const head = declared.split("_")[0]?.trim();
942
+ if (head) return head;
943
+ }
944
+ if (process.env.CLAUDECODE === "1") return "claude-code";
945
+ return null;
946
+ }
947
+ function looksLikeAgent() {
948
+ return process.env.AI_AGENT !== void 0 || process.env.CLAUDECODE === "1";
949
+ }
950
+ function authorOf(repo, values) {
951
+ const declaredKind = process.env.GREVIEW_AUTHOR_KIND;
952
+ const kind = values.agent || declaredKind === "agent" || declaredKind !== "human" && looksLikeAgent() ? "agent" : "human";
953
+ const explicit = (values.author ?? process.env.GREVIEW_AUTHOR)?.trim();
954
+ if (explicit) return { author: explicit, kind };
955
+ if (kind === "agent") {
956
+ const inferred = agentName();
957
+ if (inferred) return { author: inferred, kind };
958
+ return fail(
959
+ "an agent must name itself: pass --author <name> (for example claude-code or codex), or set GREVIEW_AUTHOR. git config user.name belongs to the human reviewer and will not be used for an agent comment."
960
+ );
961
+ }
962
+ const r = spawnSync2("git", ["config", "user.name"], { cwd: repo.root, encoding: "utf8" });
963
+ return { author: (r.stdout ?? "").trim() || "unknown", kind };
964
+ }
965
+ function messageOf(values) {
966
+ const raw = values.message;
967
+ if (raw === void 0) fail('a comment body is required: -m "<text>" (or -m - to read stdin)');
968
+ const body = raw === "-" ? readFileSync2(0, "utf8") : raw;
969
+ if (body.trim() === "") fail("the comment body is empty");
970
+ return body.replace(/\s+$/, "");
971
+ }
972
+ function parseSide(v) {
973
+ if (v === void 0) return "new";
974
+ if (v === "new" || v === "old") return v;
975
+ return fail(`--side must be new or old, got ${v}`);
976
+ }
977
+ function parseTarget(v, dflt) {
978
+ if (v === void 0) {
979
+ if (dflt === null) return fail("--target is required");
980
+ return dflt;
981
+ }
982
+ if (v === "worktree" || v === "index" || v === "head") return v;
983
+ return fail(`--target must be worktree, index or head, got ${v}`);
984
+ }
985
+ function parseRange(v) {
986
+ if (v === void 0) fail("--line <n|n-m> is required");
987
+ const m = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(v.trim());
988
+ if (!m) fail(`--line must look like 12 or 12-18, got ${v}`);
989
+ const start = Number(m[1]);
990
+ const end = m[2] === void 0 ? start : Number(m[2]);
991
+ if (start < 1) fail("line numbers start at 1");
992
+ if (end < start) fail(`--line range is inverted: ${v}`);
993
+ return { start, end };
994
+ }
995
+ function threadIdOf(positionals) {
996
+ const raw = positionals[1];
997
+ if (raw === void 0) fail("a thread id is required");
998
+ const id = Number(raw.replace(/^#/, ""));
999
+ if (!Number.isInteger(id) || id < 1) fail(`not a thread id: ${raw}`);
1000
+ return id;
1001
+ }
1002
+ function loadThread(ctx, id, opts) {
1003
+ const row = ctx.store.thread(id);
1004
+ if (row === null) fail(`no thread #${id}`);
1005
+ const r = opts.sync ? ctx.store.transaction(() => syncThread(ctx.repo, ctx.store, row)) : resolveThread(ctx.repo, ctx.store, row);
1006
+ const fresh = ctx.store.thread(id);
1007
+ return toThread(ctx.store, fresh, r, { events: opts.events });
1008
+ }
1009
+ function collect(ctx, values) {
1010
+ const sync = values["no-sync"] !== true;
1011
+ const resolutions = sync ? syncAll(ctx.repo, ctx.store) : null;
1012
+ const target = values.target === void 0 ? null : parseTarget(values.target, null);
1013
+ const file = values.file === void 0 ? null : toRepoPath(ctx.repo, values.file, cwdOf(values));
1014
+ const out = [];
1015
+ for (const row of ctx.store.threads()) {
1016
+ if (values.resolved && row.status !== "resolved") continue;
1017
+ if (!values.resolved && !values.all && row.status !== "open") continue;
1018
+ if (target !== null && row.target !== target) continue;
1019
+ if (file !== null && row.file_path !== file) continue;
1020
+ const r = resolutions?.get(row.id) ?? resolveThread(ctx.repo, ctx.store, row);
1021
+ out.push(toThread(ctx.store, row, r, { events: values.events }));
1022
+ }
1023
+ const weight = (t) => t.status === "resolved" ? 4 : t.current.drift === "changed" || t.current.drift === "orphaned" ? 1 : 3;
1024
+ return out.sort((a, b) => weight(a) - weight(b) || a.filePath.localeCompare(b.filePath) || a.id - b.id);
1025
+ }
1026
+ function cwdOf(values) {
1027
+ return values.cwd ?? process.env.GREVIEW_CWD ?? process.cwd();
1028
+ }
1029
+ function emit(ctx, json, data, human) {
1030
+ if (json) {
1031
+ process.stdout.write(`${JSON.stringify({ ok: true, data }, null, 2)}
1032
+ `);
1033
+ } else {
1034
+ human();
1035
+ }
1036
+ ctx?.store.close();
1037
+ }
1038
+ function cmdRepo(ctx) {
1039
+ const info = {
1040
+ root: ctx.repo.root,
1041
+ gitDir: ctx.repo.gitDir,
1042
+ dbPath: dbPathFor(ctx.repo),
1043
+ head: headSha(ctx.repo),
1044
+ branch: branchName(ctx.repo)
1045
+ };
1046
+ emit(ctx, ctx.json, info, () => {
1047
+ console.log(`${dim("root ")} ${info.root}`);
1048
+ console.log(`${dim("gitdir")} ${info.gitDir}`);
1049
+ console.log(`${dim("db ")} ${info.dbPath}`);
1050
+ console.log(`${dim("branch")} ${info.branch ?? "(detached)"} ${dim(info.head?.slice(0, 12) ?? "")}`);
1051
+ });
1052
+ }
1053
+ function cmdAdd(ctx, values) {
1054
+ if (values.file === void 0) fail("--file <path> is required");
1055
+ const filePath = toRepoPath(ctx.repo, values.file, cwdOf(values));
1056
+ const side = parseSide(values.side);
1057
+ const target = parseTarget(values.target, "worktree");
1058
+ const { start, end } = parseRange(values.line);
1059
+ const body = messageOf(values);
1060
+ const { author, kind } = authorOf(ctx.repo, values);
1061
+ const version = versionFor(target, side);
1062
+ const content = readVersion(ctx.repo, version, filePath);
1063
+ if (content === null) {
1064
+ fail(`${filePath} does not exist in the ${version} version, so there is nothing to anchor to`);
1065
+ }
1066
+ if (content.includes("\0")) fail(`${filePath} looks binary; only text files can be commented on`);
1067
+ const total = splitLines(content).length;
1068
+ if (total === 0) fail(`${filePath} is empty in the ${version} version`);
1069
+ if (start > total) fail(`${filePath} has ${total} lines in the ${version} version; --line ${start} is past the end`);
1070
+ const clampedEnd = Math.min(end, total);
1071
+ const hunkHeader = hunkHeaderAt(ctx.repo, target, side, filePath, start);
1072
+ const id = ctx.store.transaction(
1073
+ () => ctx.store.createThread({
1074
+ filePath,
1075
+ side,
1076
+ target,
1077
+ content,
1078
+ start,
1079
+ end: clampedEnd,
1080
+ hunkHeader,
1081
+ author,
1082
+ authorKind: kind,
1083
+ body
1084
+ })
1085
+ );
1086
+ const thread = loadThread(ctx, id, { sync: true, events: true });
1087
+ emit(ctx, ctx.json, thread, () => {
1088
+ console.log(`${bold("created")} ${cyan(`#${id}`)} ${thread.ref}`);
1089
+ if (hunkHeader === null) {
1090
+ console.log(
1091
+ yellow("note: those lines are not part of that diff \u2014 the comment is anchored to them anyway")
1092
+ );
1093
+ }
1094
+ });
1095
+ }
1096
+ function cmdList(ctx, values) {
1097
+ const threads = collect(ctx, values);
1098
+ emit(ctx, ctx.json, threads, () => {
1099
+ if (threads.length === 0) {
1100
+ console.log(dim(values.resolved ? "no resolved threads" : "no open threads"));
1101
+ return;
1102
+ }
1103
+ let file = "";
1104
+ for (const t of threads) {
1105
+ if (t.filePath !== file) {
1106
+ file = t.filePath;
1107
+ console.log(`
1108
+ ${bold(file)}`);
1109
+ }
1110
+ console.log(threadLine(t));
1111
+ for (const note of t.current.notes) console.log(` ${yellow("\u2022")} ${note.text}`);
1112
+ }
1113
+ console.log("");
1114
+ });
1115
+ }
1116
+ function cmdShow(ctx, values, positionals) {
1117
+ const thread = loadThread(ctx, threadIdOf(positionals), {
1118
+ sync: values["no-sync"] !== true,
1119
+ events: true
1120
+ });
1121
+ emit(ctx, ctx.json, thread, () => console.log(threadDetail(thread)));
1122
+ }
1123
+ function cmdReply(ctx, values, positionals) {
1124
+ const id = threadIdOf(positionals);
1125
+ if (ctx.store.thread(id) === null) fail(`no thread #${id}`);
1126
+ const body = messageOf(values);
1127
+ const { author, kind } = authorOf(ctx.repo, values);
1128
+ ctx.store.addComment(id, author, kind, body);
1129
+ const thread = loadThread(ctx, id, { sync: true, events: true });
1130
+ emit(ctx, ctx.json, thread, () => console.log(`${bold("replied to")} ${cyan(`#${id}`)}`));
1131
+ }
1132
+ function cmdEdit(ctx, values, positionals) {
1133
+ const raw = positionals[1];
1134
+ if (raw === void 0) fail("a comment id is required (see comments[].id in `show --json`)");
1135
+ const commentId = Number(raw.replace(/^#/, ""));
1136
+ if (!Number.isInteger(commentId) || commentId < 1) fail(`not a comment id: ${raw}`);
1137
+ const body = messageOf(values);
1138
+ const threadId = ctx.store.editComment(commentId, body);
1139
+ if (threadId === null) fail(`no comment #${commentId}`);
1140
+ const thread = loadThread(ctx, threadId, { sync: true, events: true });
1141
+ emit(
1142
+ ctx,
1143
+ ctx.json,
1144
+ thread,
1145
+ () => console.log(`${bold("edited")} comment ${cyan(`#${commentId}`)} ${dim(`in thread #${threadId}`)}`)
1146
+ );
1147
+ }
1148
+ function cmdSetStatus(ctx, values, positionals, resolved) {
1149
+ const id = threadIdOf(positionals);
1150
+ if (ctx.store.thread(id) === null) fail(`no thread #${id}`);
1151
+ const by = values.by ?? authorOf(ctx.repo, values).author;
1152
+ ctx.store.setStatus(id, resolved ? "resolved" : "open", by);
1153
+ const thread = loadThread(ctx, id, { sync: false, events: true });
1154
+ emit(
1155
+ ctx,
1156
+ ctx.json,
1157
+ thread,
1158
+ () => console.log(`${bold(resolved ? "resolved" : "reopened")} ${cyan(`#${id}`)} ${dim(`by ${by}`)}`)
1159
+ );
1160
+ }
1161
+ function cmdRm(ctx, positionals) {
1162
+ const id = threadIdOf(positionals);
1163
+ if (!ctx.store.deleteThread(id)) fail(`no thread #${id}`);
1164
+ emit(ctx, ctx.json, { id, deleted: true }, () => console.log(`${bold("deleted")} ${cyan(`#${id}`)}`));
1165
+ }
1166
+ function cmdSync(ctx) {
1167
+ const before = new Map(ctx.store.threads().map((r) => [r.id, r.drift]));
1168
+ const resolutions = syncAll(ctx.repo, ctx.store);
1169
+ const changed = [];
1170
+ for (const [id, r] of resolutions) {
1171
+ const from = before.get(id) ?? null;
1172
+ if (from !== r.drift) changed.push({ id, from, to: r.drift });
1173
+ }
1174
+ emit(ctx, ctx.json, { checked: resolutions.size, changed }, () => {
1175
+ console.log(`${bold("checked")} ${resolutions.size} thread(s)`);
1176
+ for (const c of changed) console.log(` ${cyan(`#${c.id}`)} ${c.from ?? "new"} \u2192 ${c.to}`);
1177
+ });
1178
+ }
1179
+ var CALLBACK_TIMEOUT_MS = 6e4;
1180
+ function toCallback(row) {
1181
+ return { name: row.name, command: row.command, createdAt: row.created_at };
1182
+ }
1183
+ function runCallback(cwd, callback) {
1184
+ return new Promise((resolve2) => {
1185
+ const started = Date.now();
1186
+ const child = spawn(callback.command, {
1187
+ cwd,
1188
+ shell: true,
1189
+ stdio: ["ignore", "pipe", "pipe"]
1190
+ });
1191
+ let stdout = "";
1192
+ let stderr = "";
1193
+ let timedOut = false;
1194
+ child.stdout?.on("data", (chunk) => {
1195
+ stdout += chunk.toString();
1196
+ });
1197
+ child.stderr?.on("data", (chunk) => {
1198
+ stderr += chunk.toString();
1199
+ });
1200
+ const timer = setTimeout(() => {
1201
+ timedOut = true;
1202
+ child.kill("SIGKILL");
1203
+ }, CALLBACK_TIMEOUT_MS);
1204
+ child.on("error", (e) => {
1205
+ clearTimeout(timer);
1206
+ resolve2({
1207
+ ...callback,
1208
+ code: null,
1209
+ signal: null,
1210
+ timedOut,
1211
+ stdout,
1212
+ stderr: stderr || e.message,
1213
+ durationMs: Date.now() - started
1214
+ });
1215
+ });
1216
+ child.on("close", (code, signal) => {
1217
+ clearTimeout(timer);
1218
+ resolve2({
1219
+ ...callback,
1220
+ code,
1221
+ signal: signal ?? null,
1222
+ timedOut,
1223
+ stdout: stdout.trimEnd(),
1224
+ stderr: stderr.trimEnd(),
1225
+ durationMs: Date.now() - started
1226
+ });
1227
+ });
1228
+ });
1229
+ }
1230
+ function cmdOnsubmit(ctx, values, positionals) {
1231
+ const sub = positionals[1] ?? "list";
1232
+ switch (sub) {
1233
+ case "list": {
1234
+ const list = ctx.store.callbacks().map(toCallback);
1235
+ return emit(ctx, ctx.json, list, () => {
1236
+ if (list.length === 0) {
1237
+ console.log(dim("no submit hooks in this worktree"));
1238
+ return;
1239
+ }
1240
+ for (const c of list) console.log(`${cyan(c.name)} ${c.command}`);
1241
+ });
1242
+ }
1243
+ case "add": {
1244
+ const name = positionals[2];
1245
+ const command = positionals[3];
1246
+ if (name === void 0 || name.trim() === "") fail("a hook name is required");
1247
+ if (command === void 0 || command.trim() === "") {
1248
+ fail('a command is required: greview onsubmit add <name> "<command>"');
1249
+ }
1250
+ ctx.store.putCallback(name, command);
1251
+ const list = ctx.store.callbacks().map(toCallback);
1252
+ return emit(ctx, ctx.json, list, () => console.log(`${bold("added")} hook ${cyan(name)}`));
1253
+ }
1254
+ case "delete":
1255
+ case "rm": {
1256
+ const name = positionals[2];
1257
+ if (name === void 0) fail("which hook? greview onsubmit delete <name>");
1258
+ if (!ctx.store.deleteCallback(name)) fail(`no hook named ${name}`);
1259
+ const list = ctx.store.callbacks().map(toCallback);
1260
+ return emit(ctx, ctx.json, list, () => console.log(`${bold("deleted")} hook ${cyan(name)}`));
1261
+ }
1262
+ case "clear": {
1263
+ const removed = ctx.store.clearCallbacks();
1264
+ return emit(
1265
+ ctx,
1266
+ ctx.json,
1267
+ { removed },
1268
+ () => console.log(`${bold("cleared")} ${removed} hook(s)`)
1269
+ );
1270
+ }
1271
+ case "run": {
1272
+ const list = ctx.store.callbacks().map(toCallback);
1273
+ const root = ctx.repo.root;
1274
+ const json = ctx.json;
1275
+ return Promise.all(list.map((c) => runCallback(root, c))).then((results) => {
1276
+ const failed = results.filter((r) => r.code !== 0);
1277
+ if (json) {
1278
+ process.stdout.write(`${JSON.stringify({ ok: true, data: results }, null, 2)}
1279
+ `);
1280
+ } else if (results.length === 0) {
1281
+ console.log(dim("no submit hooks in this worktree"));
1282
+ } else {
1283
+ for (const r of results) {
1284
+ const status = r.timedOut ? yellow("timed out") : r.code === 0 ? bold("ok") : `${yellow("exit")} ${r.code ?? r.signal}`;
1285
+ console.log(`${cyan(r.name)} ${status} ${dim(`${r.durationMs}ms`)}`);
1286
+ for (const line of [...r.stdout.split("\n"), ...r.stderr.split("\n")]) {
1287
+ if (line.trim() !== "") console.log(` ${dim(line)}`);
1288
+ }
1289
+ }
1290
+ }
1291
+ if (failed.length > 0) process.exitCode = 1;
1292
+ });
1293
+ }
1294
+ default:
1295
+ return fail(`unknown onsubmit subcommand "${sub}" (list, add, delete, clear, run)`);
1296
+ }
1297
+ }
1298
+ function cmdStats(ctx) {
1299
+ const resolutions = syncAll(ctx.repo, ctx.store);
1300
+ const stats = { open: 0, resolved: 0, changed: 0, orphaned: 0 };
1301
+ for (const row of ctx.store.threads()) {
1302
+ if (row.status === "resolved") {
1303
+ stats.resolved++;
1304
+ continue;
1305
+ }
1306
+ stats.open++;
1307
+ const drift = resolutions.get(row.id)?.drift ?? row.drift;
1308
+ if (drift === "changed") stats.changed++;
1309
+ if (drift === "orphaned") stats.orphaned++;
1310
+ }
1311
+ emit(
1312
+ ctx,
1313
+ ctx.json,
1314
+ stats,
1315
+ () => console.log(
1316
+ `${stats.open} open, ${stats.resolved} resolved, ${stats.changed} changed under you, ${stats.orphaned} orphaned`
1317
+ )
1318
+ );
1319
+ }
1320
+ function main(argv) {
1321
+ let values;
1322
+ let positionals;
1323
+ try {
1324
+ ({ values, positionals } = parse(argv));
1325
+ } catch (e) {
1326
+ process.stderr.write(`greview: ${e.message}
1327
+ `);
1328
+ return 2;
1329
+ }
1330
+ const command = positionals[0] ?? (values.version ? "version" : "help");
1331
+ if (values.help || command === "help") {
1332
+ process.stdout.write(USAGE);
1333
+ return 0;
1334
+ }
1335
+ if (command === "version") {
1336
+ process.stdout.write(`${VERSION}
1337
+ `);
1338
+ return 0;
1339
+ }
1340
+ if (command === "install-skill") return cmdInstallSkill();
1341
+ let ctx = null;
1342
+ try {
1343
+ const repo = findRepo(cwdOf(values));
1344
+ ctx = { repo, store: new Store(repo), json: values.json === true };
1345
+ switch (command) {
1346
+ case "repo":
1347
+ cmdRepo(ctx);
1348
+ break;
1349
+ case "add":
1350
+ cmdAdd(ctx, values);
1351
+ break;
1352
+ case "list":
1353
+ case "ls":
1354
+ cmdList(ctx, values);
1355
+ break;
1356
+ case "show":
1357
+ cmdShow(ctx, values, positionals);
1358
+ break;
1359
+ case "reply":
1360
+ cmdReply(ctx, values, positionals);
1361
+ break;
1362
+ case "edit":
1363
+ cmdEdit(ctx, values, positionals);
1364
+ break;
1365
+ case "resolve":
1366
+ cmdSetStatus(ctx, values, positionals, true);
1367
+ break;
1368
+ case "unresolve":
1369
+ case "reopen":
1370
+ cmdSetStatus(ctx, values, positionals, false);
1371
+ break;
1372
+ case "rm":
1373
+ case "delete":
1374
+ cmdRm(ctx, positionals);
1375
+ break;
1376
+ case "sync":
1377
+ cmdSync(ctx);
1378
+ break;
1379
+ case "stats":
1380
+ cmdStats(ctx);
1381
+ break;
1382
+ case "onsubmit": {
1383
+ const pending = cmdOnsubmit(ctx, values, positionals);
1384
+ if (pending) return pending.then(() => 0);
1385
+ break;
1386
+ }
1387
+ default:
1388
+ process.stderr.write(`greview: unknown command "${command}"
1389
+ Try: greview help
1390
+ `);
1391
+ return 2;
1392
+ }
1393
+ return 0;
1394
+ } catch (e) {
1395
+ const message = e instanceof Error ? e.message : String(e);
1396
+ if (values.json) {
1397
+ process.stdout.write(`${JSON.stringify({ ok: false, error: message })}
1398
+ `);
1399
+ } else {
1400
+ process.stderr.write(`greview: ${message}
1401
+ `);
1402
+ }
1403
+ return e instanceof UsageError ? 2 : e instanceof GitError ? 3 : 1;
1404
+ } finally {
1405
+ try {
1406
+ ctx?.store.close();
1407
+ } catch {
1408
+ }
1409
+ }
1410
+ }
1411
+ var outcome = main(process.argv.slice(2));
1412
+ if (typeof outcome === "number") {
1413
+ process.exitCode = outcome;
1414
+ } else {
1415
+ void outcome.then(
1416
+ (code) => {
1417
+ if (process.exitCode === void 0 || process.exitCode === 0) process.exitCode = code;
1418
+ },
1419
+ (e) => {
1420
+ process.stderr.write(`greview: ${e instanceof Error ? e.message : String(e)}
1421
+ `);
1422
+ process.exitCode = 1;
1423
+ }
1424
+ );
1425
+ }