diffsplain 0.1.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,1003 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync, spawnSync } from 'node:child_process';
4
+ import { createHash } from 'node:crypto';
5
+ import {
6
+ existsSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ renameSync,
10
+ statSync,
11
+ writeFileSync,
12
+ } from 'node:fs';
13
+ import { dirname, relative, resolve } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { summaryPath } from './summary-path.mjs';
16
+
17
+ const args = process.argv.slice(2);
18
+ const fail = (message) => {
19
+ console.error(message);
20
+ process.exit(2);
21
+ };
22
+ const option = (name) => {
23
+ const index = args.indexOf(name);
24
+ if (index === -1) return undefined;
25
+ const value = args[index + 1];
26
+ if (!value || value.startsWith('--')) fail(`${name} needs a value`);
27
+ return value;
28
+ };
29
+ const has = (name) => args.includes(name);
30
+
31
+ if (has('--help')) {
32
+ console.log(`Usage: node scripts/build-diff-data.mjs [target] [options]
33
+
34
+ Targets:
35
+ --pr NUMBER|URL Fetch and show a GitHub pull request
36
+ --branch NAME Fetch and show a remote branch
37
+ --checkout Show the current checkout against its default branch
38
+ --base REF --head REF
39
+ Show an exact local Git range
40
+ (no target) Show worktree changes against HEAD
41
+
42
+ Options:
43
+ --repo PATH Local Git workspace (default: current directory)
44
+ --remote NAME|URL Remote for --pr or --branch (default: origin)
45
+ --base REF Remote base branch with --branch
46
+ --summaries FILE Agent note file
47
+ --output FILE JSON output
48
+ --cache-dir PATH Bare cache for fetched Git objects
49
+ --watch Keep the data current`);
50
+ process.exit(0);
51
+ }
52
+
53
+ const repo = resolve(option('--repo') || process.cwd());
54
+ const output = resolve(option('--output') || '.cache/diff-data.json');
55
+ const excludedOutput = option('--exclude-output');
56
+ const baseOption = option('--base');
57
+ const headOption = option('--head');
58
+ const prOption = option('--pr');
59
+ const branchOption = option('--branch');
60
+ const checkoutOption = has('--checkout');
61
+ const worktreeOption = has('--worktree');
62
+ const remoteOption = option('--remote') || 'origin';
63
+ const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
64
+ const summariesPath = summaryPath({
65
+ projectRoot,
66
+ callerDirectory: process.cwd(),
67
+ repo,
68
+ explicit: option('--summaries'),
69
+ pr: prOption,
70
+ branch: branchOption,
71
+ checkout: checkoutOption,
72
+ base: baseOption,
73
+ head: headOption,
74
+ remote: remoteOption,
75
+ });
76
+ const cacheOption = option('--cache-dir');
77
+ const cacheRoot = cacheOption
78
+ ? resolve(cacheOption)
79
+ : resolve(projectRoot, '.cache/git');
80
+ const remoteMode = Boolean(prOption || branchOption);
81
+ const watching = has('--watch');
82
+
83
+ if (prOption && branchOption) fail('--pr and --branch cannot be used together');
84
+ if (prOption && (baseOption || headOption)) fail('--pr cannot be used with --base or --head');
85
+ if (branchOption && headOption) fail('--branch cannot be used with --head');
86
+ if (
87
+ checkoutOption &&
88
+ (prOption || branchOption || headOption || worktreeOption)
89
+ ) {
90
+ fail('--checkout cannot be combined with another target');
91
+ }
92
+ if (
93
+ worktreeOption &&
94
+ (prOption || branchOption || baseOption || headOption)
95
+ ) {
96
+ fail('--worktree cannot be combined with another target');
97
+ }
98
+ if (
99
+ !prOption &&
100
+ !branchOption &&
101
+ !checkoutOption &&
102
+ Boolean(baseOption) !== Boolean(headOption)
103
+ ) {
104
+ fail('--base and --head must be used together');
105
+ }
106
+
107
+ const repoPath = (file) => {
108
+ const path = relative(repo, file).replaceAll('\\', '/');
109
+ return path && path !== '..' && !path.startsWith('../') ? path : undefined;
110
+ };
111
+ const excludedPaths = new Set(
112
+ [
113
+ repoPath(summariesPath),
114
+ repoPath(output),
115
+ excludedOutput ? repoPath(resolve(excludedOutput)) : undefined,
116
+ ].filter(Boolean),
117
+ );
118
+
119
+ function command(commandName, commandArgs, options = {}) {
120
+ return execFileSync(commandName, commandArgs, {
121
+ cwd: options.cwd,
122
+ encoding: 'utf8',
123
+ stdio: ['ignore', 'pipe', 'pipe'],
124
+ });
125
+ }
126
+
127
+ const runRepo = (gitArgs) => command('git', ['-C', repo, ...gitArgs]);
128
+ const tryRepo = (gitArgs) => {
129
+ try {
130
+ return runRepo(gitArgs).trim();
131
+ } catch {
132
+ return '';
133
+ }
134
+ };
135
+ const runRepoWithDiffExit = (gitArgs) => {
136
+ const result = spawnSync('git', ['-C', repo, ...gitArgs], {
137
+ encoding: 'utf8',
138
+ stdio: ['ignore', 'pipe', 'pipe'],
139
+ });
140
+ if (result.status !== 0 && result.status !== 1) {
141
+ throw new Error(result.stderr.trim() || `git ${gitArgs[0]} failed`);
142
+ }
143
+ return result.stdout;
144
+ };
145
+ const readJson = (file, fallback) => {
146
+ try {
147
+ return JSON.parse(readFileSync(file, 'utf8'));
148
+ } catch {
149
+ return fallback;
150
+ }
151
+ };
152
+ const cleanText = (value, fallback) =>
153
+ typeof value === 'string' && value.trim() ? value.trim() : fallback;
154
+ const cleanList = (value) =>
155
+ Array.isArray(value)
156
+ ? value
157
+ .filter((item) => typeof item === 'string' && item.trim())
158
+ .map((item) => item.trim())
159
+ : [];
160
+ const completeList = (value) =>
161
+ Array.isArray(value) && value.every((item) => typeof item === 'string');
162
+ const completeText = (value) =>
163
+ typeof value === 'string' && Boolean(value.trim());
164
+ const completeFileSummary = (value) =>
165
+ value &&
166
+ completeText(value.title) &&
167
+ completeText(value.what) &&
168
+ completeText(value.why) &&
169
+ completeList(value.details) &&
170
+ completeList(value.risks);
171
+ const completeChangeSummary = (value) =>
172
+ value &&
173
+ completeText(value.title) &&
174
+ completeText(value.summary) &&
175
+ completeText(value.why) &&
176
+ completeList(value.highlights) &&
177
+ completeList(value.risks);
178
+
179
+ function fileSummary(path, value) {
180
+ return {
181
+ title: cleanText(value?.title, path),
182
+ what: cleanText(value?.what, 'Shows the current Git patch.'),
183
+ why: cleanText(
184
+ value?.why,
185
+ 'Start Diffsplain with --agent to generate this note.',
186
+ ),
187
+ details: cleanList(value?.details),
188
+ risks: cleanList(value?.risks),
189
+ };
190
+ }
191
+
192
+ function changeSummary(value, defaults = {}) {
193
+ const number = Number.isInteger(value?.number)
194
+ ? value.number
195
+ : defaults.number;
196
+ const url =
197
+ typeof value?.url === 'string' && value.url ? value.url : defaults.url;
198
+ return {
199
+ title: cleanText(value?.title, defaults.title || 'Local changes'),
200
+ ...(Number.isInteger(number) ? { number } : {}),
201
+ ...(url ? { url } : {}),
202
+ summary: cleanText(
203
+ value?.summary,
204
+ defaults.summary || 'Changes in the selected Git range.',
205
+ ),
206
+ why: cleanText(value?.why, defaults.why || 'Shows the current review set.'),
207
+ highlights: cleanList(value?.highlights || defaults.highlights),
208
+ risks: cleanList(value?.risks || defaults.risks),
209
+ };
210
+ }
211
+
212
+ function parseNameStatus(raw) {
213
+ const fields = raw.split('\0').filter(Boolean);
214
+ const files = [];
215
+ for (let index = 0; index < fields.length; ) {
216
+ const code = fields[index++];
217
+ const kind = code[0];
218
+ if (kind === 'R' || kind === 'C') {
219
+ const oldPath = fields[index++];
220
+ files.push({ path: fields[index++], oldPath, status: 'renamed' });
221
+ } else {
222
+ files.push({
223
+ path: fields[index++],
224
+ status:
225
+ kind === 'A' ? 'added' : kind === 'D' ? 'deleted' : 'modified',
226
+ });
227
+ }
228
+ }
229
+ return files;
230
+ }
231
+
232
+ function parseNumstat(raw) {
233
+ const out = new Map();
234
+ const fields = raw.split('\0').filter(Boolean);
235
+ for (let index = 0; index < fields.length; ) {
236
+ const row = fields[index++];
237
+ const [add, del, inlinePath] = row.split('\t');
238
+ let oldPath;
239
+ let path = inlinePath;
240
+ if (!path) {
241
+ oldPath = fields[index++];
242
+ path = fields[index++];
243
+ }
244
+ out.set(path, {
245
+ additions: add === '-' ? 0 : Number(add),
246
+ deletions: del === '-' ? 0 : Number(del),
247
+ isBinary: add === '-' || del === '-',
248
+ oldPath,
249
+ });
250
+ }
251
+ return out;
252
+ }
253
+
254
+ function compactSnippet(patch, limit = 180) {
255
+ const lines = patch.split('\n');
256
+ if (lines.length <= limit) return patch;
257
+ const header = lines
258
+ .filter(
259
+ (line) =>
260
+ !line.startsWith('@@') &&
261
+ !line.startsWith('+') &&
262
+ !line.startsWith('-') &&
263
+ !line.startsWith(' '),
264
+ )
265
+ .slice(0, 8);
266
+ const hunks = [];
267
+ let open = false;
268
+ for (const line of lines) {
269
+ if (line.startsWith('@@')) {
270
+ if (hunks.length >= 72) break;
271
+ open = true;
272
+ hunks.push(line);
273
+ continue;
274
+ }
275
+ if (open && hunks.length < 72) hunks.push(line);
276
+ }
277
+ return [
278
+ ...header,
279
+ ...hunks,
280
+ '... diff truncated; see patch for full content ...',
281
+ ].join('\n');
282
+ }
283
+
284
+ function normalizeBranch(value, remoteName) {
285
+ let branch = value;
286
+ if (branch.startsWith('refs/heads/')) branch = branch.slice(11);
287
+ if (branch.startsWith(`${remoteName}/`)) {
288
+ branch = branch.slice(remoteName.length + 1);
289
+ }
290
+ const result = spawnSync('git', ['check-ref-format', `refs/heads/${branch}`], {
291
+ encoding: 'utf8',
292
+ stdio: ['ignore', 'pipe', 'pipe'],
293
+ });
294
+ if (result.status !== 0) throw new Error(`Invalid remote branch: ${value}`);
295
+ return branch;
296
+ }
297
+
298
+ function resolveRemote() {
299
+ const configured = tryRepo(['remote', 'get-url', remoteOption]);
300
+ if (configured) return { name: remoteOption, url: configured };
301
+ if (remoteOption === 'origin') {
302
+ throw new Error(`Git remote "origin" was not found in ${repo}`);
303
+ }
304
+ return { name: remoteOption, url: remoteOption };
305
+ }
306
+
307
+ function bareCache(remoteUrl) {
308
+ const key = createHash('sha256').update(remoteUrl).digest('hex').slice(0, 20);
309
+ const path = resolve(cacheRoot, key);
310
+ if (!existsSync(resolve(path, 'HEAD'))) {
311
+ mkdirSync(cacheRoot, { recursive: true });
312
+ command('git', ['init', '--bare', '--quiet', path]);
313
+ }
314
+ const run = (gitArgs) => command('git', ['--git-dir', path, ...gitArgs]);
315
+ return { path, run };
316
+ }
317
+
318
+ function fetchInto(cache, remoteUrl, refspecs) {
319
+ try {
320
+ cache.run([
321
+ 'fetch',
322
+ '--quiet',
323
+ '--no-tags',
324
+ '--no-write-fetch-head',
325
+ '--no-auto-maintenance',
326
+ '--force',
327
+ remoteUrl,
328
+ ...refspecs,
329
+ ]);
330
+ } catch (error) {
331
+ const detail = error?.stderr?.toString().trim();
332
+ throw new Error(
333
+ `Could not fetch the remote target${detail ? `: ${detail}` : ''}`,
334
+ );
335
+ }
336
+ }
337
+
338
+ function uniqueMergeBase(runGit, base, head) {
339
+ let raw;
340
+ try {
341
+ raw = runGit(['merge-base', '--all', base, head]).trim();
342
+ } catch {
343
+ throw new Error('The target branch and base branch have no common commit');
344
+ }
345
+ const bases = raw.split('\n').filter(Boolean);
346
+ if (bases.length !== 1) {
347
+ throw new Error(
348
+ bases.length
349
+ ? 'The target has more than one merge base'
350
+ : 'The target branch and base branch have no common commit',
351
+ );
352
+ }
353
+ return bases[0];
354
+ }
355
+
356
+ function remoteDefaultBranchInfo(remoteUrl) {
357
+ let raw;
358
+ try {
359
+ raw = runRepo(['ls-remote', '--symref', remoteUrl, 'HEAD']);
360
+ } catch {
361
+ throw new Error('Could not read the remote default branch');
362
+ }
363
+ const match = raw.match(/^ref:\s+refs\/heads\/([^\t\n]+)\s+HEAD$/m);
364
+ if (!match) {
365
+ throw new Error('The remote has no default branch; pass --base NAME');
366
+ }
367
+ const oid = raw.match(new RegExp(`^([a-f0-9]+)\\s+HEAD$`, 'm'))?.[1];
368
+ return { name: match[1], oid };
369
+ }
370
+
371
+ function remoteDefaultBranch(remoteUrl) {
372
+ return remoteDefaultBranchInfo(remoteUrl).name;
373
+ }
374
+
375
+ function localDefaultBranch(remote) {
376
+ if (baseOption) return { name: baseOption };
377
+
378
+ if (remote) {
379
+ const symbolic = tryRepo([
380
+ 'symbolic-ref',
381
+ '--quiet',
382
+ `refs/remotes/${remote.name}/HEAD`,
383
+ ]);
384
+ const prefix = `refs/remotes/${remote.name}/`;
385
+ if (symbolic.startsWith(prefix)) {
386
+ return { name: symbolic.slice(prefix.length) };
387
+ }
388
+ try {
389
+ return remoteDefaultBranchInfo(remote.url);
390
+ } catch {}
391
+ }
392
+
393
+ const configured = tryRepo(['config', '--get', 'init.defaultBranch']);
394
+ const candidates = [configured, 'main', 'master'].filter(Boolean);
395
+ for (const name of candidates) {
396
+ if (tryRepo(['rev-parse', '--verify', `refs/heads/${name}^{commit}`])) {
397
+ return { name };
398
+ }
399
+ }
400
+ throw new Error(
401
+ 'Could not find the default branch. Fetch it or pass --base NAME.',
402
+ );
403
+ }
404
+
405
+ function localBaseCommit(base, remote) {
406
+ const candidates = [
407
+ remote ? `refs/remotes/${remote.name}/${base.name}` : undefined,
408
+ `refs/heads/${base.name}`,
409
+ base.name,
410
+ base.oid,
411
+ ].filter(Boolean);
412
+ for (const candidate of candidates) {
413
+ const oid = tryRepo(['rev-parse', '--verify', `${candidate}^{commit}`]);
414
+ if (oid) return oid;
415
+ }
416
+ throw new Error(
417
+ `The default branch "${base.name}" is not in this checkout. Run git fetch or pass --base REF.`,
418
+ );
419
+ }
420
+
421
+ function githubRepository(remoteUrl) {
422
+ if (!remoteUrl) return undefined;
423
+ let host;
424
+ let path;
425
+ const scp = remoteUrl.match(/^(?:[^@]+@)?([^:/]+):(.+)$/);
426
+ if (scp && !remoteUrl.includes('://')) {
427
+ host = scp[1];
428
+ path = scp[2];
429
+ } else {
430
+ try {
431
+ const parsed = new URL(remoteUrl);
432
+ host = parsed.hostname;
433
+ path = parsed.pathname.replace(/^\/+/, '');
434
+ } catch {
435
+ return undefined;
436
+ }
437
+ }
438
+ const parts = path.replace(/\.git$/, '').split('/').filter(Boolean);
439
+ if (!host || parts.length < 2) return undefined;
440
+ const ownerRepo = `${parts.at(-2)}/${parts.at(-1)}`;
441
+ return {
442
+ name: ownerRepo,
443
+ selector: host === 'github.com' ? ownerRepo : `${host}/${ownerRepo}`,
444
+ webUrl: `https://${host}/${ownerRepo}`,
445
+ };
446
+ }
447
+
448
+ function pullRequestInfo(pr, remote) {
449
+ const repository = githubRepository(remote.url);
450
+ const fields = [
451
+ 'number',
452
+ 'title',
453
+ 'url',
454
+ 'state',
455
+ 'updatedAt',
456
+ 'isCrossRepository',
457
+ 'baseRefName',
458
+ 'baseRefOid',
459
+ 'headRefName',
460
+ 'headRefOid',
461
+ 'headRepository',
462
+ 'headRepositoryOwner',
463
+ ].join(',');
464
+ const ghArgs = ['pr', 'view', pr, '--json', fields];
465
+ if (repository?.selector) ghArgs.push('--repo', repository.selector);
466
+ try {
467
+ const value = JSON.parse(command('gh', ghArgs, { cwd: repo }));
468
+ for (const key of [
469
+ 'number',
470
+ 'title',
471
+ 'url',
472
+ 'baseRefName',
473
+ 'baseRefOid',
474
+ 'headRefName',
475
+ 'headRefOid',
476
+ ]) {
477
+ if (value[key] === undefined || value[key] === '') {
478
+ throw new Error(`gh returned no ${key}`);
479
+ }
480
+ }
481
+ return { value, repository };
482
+ } catch (error) {
483
+ const detail = error?.stderr?.toString().trim() || error?.message;
484
+ throw new Error(
485
+ `Could not read pull request ${pr} with gh${detail ? `: ${detail}` : ''}. Check gh auth status.`,
486
+ );
487
+ }
488
+ }
489
+
490
+ function resolveBranchTarget() {
491
+ const remote = resolveRemote();
492
+ const branch = normalizeBranch(branchOption, remote.name);
493
+ const baseBranch = normalizeBranch(
494
+ baseOption || remoteDefaultBranch(remote.url),
495
+ remote.name,
496
+ );
497
+ const cache = bareCache(remote.url);
498
+ const key = createHash('sha256')
499
+ .update(`${baseBranch}\0${branch}`)
500
+ .digest('hex')
501
+ .slice(0, 16);
502
+ const baseRef = `refs/diffsplain/branch/${key}/base`;
503
+ const headRef = `refs/diffsplain/branch/${key}/head`;
504
+ fetchInto(cache, remote.url, [
505
+ `+refs/heads/${baseBranch}:${baseRef}`,
506
+ `+refs/heads/${branch}:${headRef}`,
507
+ ]);
508
+ const baseOid = cache.run(['rev-parse', `${baseRef}^{commit}`]).trim();
509
+ const headOid = cache.run(['rev-parse', `${headRef}^{commit}`]).trim();
510
+ const mergeBaseOid = uniqueMergeBase(cache.run, baseOid, headOid);
511
+ const repository = githubRepository(remote.url);
512
+ return {
513
+ kind: 'branch',
514
+ runGit: cache.run,
515
+ range: [mergeBaseOid, headOid],
516
+ base: mergeBaseOid,
517
+ head: headOid,
518
+ branch,
519
+ baseBranch,
520
+ remote,
521
+ sourceRepositoryUrl: repository?.webUrl,
522
+ baseRepositoryUrl: repository?.webUrl,
523
+ target: {
524
+ kind: 'branch',
525
+ remote: remote.name,
526
+ base: { ref: baseBranch, oid: baseOid },
527
+ head: { ref: branch, oid: headOid },
528
+ mergeBaseOid,
529
+ },
530
+ changeDefaults: {
531
+ title: `Compare ${branch} to ${baseBranch}`,
532
+ summary: `Shows changes on ${branch} since it split from ${baseBranch}.`,
533
+ why: 'Reviews the remote branch without changing the local checkout.',
534
+ highlights: [],
535
+ risks: [],
536
+ },
537
+ };
538
+ }
539
+
540
+ function resolvePullRequestTarget() {
541
+ const remote = resolveRemote();
542
+ const { value: pr, repository } = pullRequestInfo(prOption, remote);
543
+ const cache = bareCache(remote.url);
544
+ const key = createHash('sha256')
545
+ .update(String(pr.number))
546
+ .digest('hex')
547
+ .slice(0, 16);
548
+ const baseRef = `refs/diffsplain/pr/${key}/base`;
549
+ const headRef = `refs/diffsplain/pr/${key}/head`;
550
+ fetchInto(cache, remote.url, [
551
+ `+refs/heads/${pr.baseRefName}:${baseRef}`,
552
+ `+refs/pull/${pr.number}/head:${headRef}`,
553
+ ]);
554
+ try {
555
+ cache.run(['cat-file', '-e', `${pr.baseRefOid}^{commit}`]);
556
+ cache.run(['cat-file', '-e', `${pr.headRefOid}^{commit}`]);
557
+ } catch {
558
+ throw new Error('The pull request changed while it was being read; run again');
559
+ }
560
+ const mergeBaseOid = uniqueMergeBase(
561
+ cache.run,
562
+ pr.baseRefOid,
563
+ pr.headRefOid,
564
+ );
565
+ const headRepository =
566
+ pr.headRepository?.nameWithOwner ||
567
+ (pr.headRepositoryOwner?.login && pr.headRepository?.name
568
+ ? `${pr.headRepositoryOwner.login}/${pr.headRepository.name}`
569
+ : undefined);
570
+ const repositoryOrigin = repository?.webUrl
571
+ ? new URL(repository.webUrl).origin
572
+ : 'https://github.com';
573
+ return {
574
+ kind: 'pull-request',
575
+ runGit: cache.run,
576
+ range: [mergeBaseOid, pr.headRefOid],
577
+ base: mergeBaseOid,
578
+ head: pr.headRefOid,
579
+ branch: pr.headRefName,
580
+ baseBranch: pr.baseRefName,
581
+ remote,
582
+ sourceRepositoryUrl: headRepository
583
+ ? `${repositoryOrigin}/${headRepository}`
584
+ : repository?.webUrl ||
585
+ pr.url.replace(/\/pull\/\d+(?:\/.*)?$/, ''),
586
+ baseRepositoryUrl:
587
+ repository?.webUrl || pr.url.replace(/\/pull\/\d+(?:\/.*)?$/, ''),
588
+ target: {
589
+ kind: 'pull-request',
590
+ remote: remote.name,
591
+ repository: repository?.selector,
592
+ pullRequest: {
593
+ number: pr.number,
594
+ url: pr.url,
595
+ state: pr.state,
596
+ updatedAt: pr.updatedAt,
597
+ isCrossRepository: pr.isCrossRepository,
598
+ },
599
+ base: { ref: pr.baseRefName, oid: pr.baseRefOid },
600
+ head: {
601
+ ref: pr.headRefName,
602
+ oid: pr.headRefOid,
603
+ ...(headRepository ? { repository: headRepository } : {}),
604
+ },
605
+ mergeBaseOid,
606
+ },
607
+ changeDefaults: {
608
+ title: pr.title,
609
+ number: pr.number,
610
+ url: pr.url,
611
+ summary: `Shows pull request #${pr.number} from ${pr.headRefName} into ${pr.baseRefName}.`,
612
+ why: 'Reviews the remote pull request without changing the local checkout.',
613
+ highlights: [],
614
+ risks: [],
615
+ },
616
+ };
617
+ }
618
+
619
+ function resolveCheckoutTarget() {
620
+ const currentHead = tryRepo(['rev-parse', '--verify', 'HEAD']);
621
+ if (!currentHead) return resolveLocalTarget();
622
+
623
+ const branch = tryRepo(['branch', '--show-current']) || undefined;
624
+ const remoteUrl = tryRepo(['remote', 'get-url', remoteOption]) || undefined;
625
+ const remote = remoteUrl
626
+ ? { name: remoteOption, url: remoteUrl }
627
+ : undefined;
628
+ const defaultBranch = localDefaultBranch(remote);
629
+ const defaultHead = localBaseCommit(defaultBranch, remote);
630
+ const mergeBaseOid = uniqueMergeBase(runRepo, defaultHead, currentHead);
631
+ const repository = githubRepository(remoteUrl);
632
+ const headLabel = branch || currentHead;
633
+
634
+ return {
635
+ kind: 'checkout',
636
+ runGit: runRepo,
637
+ range: [mergeBaseOid],
638
+ base: mergeBaseOid,
639
+ head: currentHead,
640
+ branch,
641
+ baseBranch: defaultBranch.name,
642
+ remote,
643
+ sourceRepositoryUrl: repository?.webUrl,
644
+ baseRepositoryUrl: repository?.webUrl,
645
+ target: {
646
+ kind: 'checkout',
647
+ ...(remote ? { remote: remote.name } : {}),
648
+ base: { ref: defaultBranch.name, oid: defaultHead },
649
+ head: { ref: headLabel, oid: currentHead },
650
+ mergeBaseOid,
651
+ },
652
+ changeDefaults: {
653
+ title: `Compare ${headLabel} to ${defaultBranch.name}`,
654
+ summary: `Shows changes in the current checkout since it split from ${defaultBranch.name}.`,
655
+ why: 'Reviews the checked-out work without changing the repo.',
656
+ highlights: [],
657
+ risks: [],
658
+ },
659
+ };
660
+ }
661
+
662
+ function resolveLocalTarget() {
663
+ const currentHead = tryRepo(['rev-parse', '--verify', 'HEAD']);
664
+ const worktree = !baseOption && !headOption;
665
+ let range;
666
+ if (worktree) {
667
+ range = currentHead ? [currentHead] : [runRepo(['mktree']).trim()];
668
+ } else {
669
+ range = [
670
+ runRepo(['rev-parse', `${baseOption}^{commit}`]).trim(),
671
+ runRepo(['rev-parse', `${headOption}^{commit}`]).trim(),
672
+ ];
673
+ }
674
+ const resolvedBase = range[0];
675
+ const resolvedHead = worktree ? currentHead || 'WORKTREE' : range[1];
676
+ const remoteUrl = tryRepo(['remote', 'get-url', 'origin']) || undefined;
677
+ return {
678
+ kind: worktree ? 'worktree' : 'range',
679
+ runGit: runRepo,
680
+ range,
681
+ base: resolvedBase,
682
+ head: resolvedHead,
683
+ branch: tryRepo(['branch', '--show-current']) || undefined,
684
+ remote: remoteUrl ? { name: 'origin', url: remoteUrl } : undefined,
685
+ sourceRepositoryUrl: worktree
686
+ ? undefined
687
+ : githubRepository(remoteUrl)?.webUrl,
688
+ baseRepositoryUrl: worktree
689
+ ? undefined
690
+ : githubRepository(remoteUrl)?.webUrl,
691
+ target: worktree
692
+ ? { kind: 'worktree', base: { ref: 'HEAD', oid: currentHead || null } }
693
+ : {
694
+ kind: 'range',
695
+ base: { ref: baseOption, oid: resolvedBase },
696
+ head: { ref: headOption, oid: resolvedHead },
697
+ },
698
+ changeDefaults: {},
699
+ };
700
+ }
701
+
702
+ function resolveTarget() {
703
+ if (prOption) return resolvePullRequestTarget();
704
+ if (branchOption) return resolveBranchTarget();
705
+ if (checkoutOption) return resolveCheckoutTarget();
706
+ return resolveLocalTarget();
707
+ }
708
+
709
+ function filePatch(file, target) {
710
+ if (file.untracked) {
711
+ return runRepoWithDiffExit([
712
+ 'diff',
713
+ '--no-index',
714
+ '--no-ext-diff',
715
+ '--no-textconv',
716
+ '--binary',
717
+ '--',
718
+ '/dev/null',
719
+ file.path,
720
+ ]);
721
+ }
722
+ const pathspec = file.oldPath ? [file.oldPath, file.path] : [file.path];
723
+ return target.runGit([
724
+ 'diff',
725
+ '--no-ext-diff',
726
+ '--no-textconv',
727
+ '--binary',
728
+ '--find-renames',
729
+ ...target.range,
730
+ '--',
731
+ ...pathspec,
732
+ ]);
733
+ }
734
+
735
+ function untrackedStat(path) {
736
+ const raw = runRepoWithDiffExit([
737
+ 'diff',
738
+ '--no-index',
739
+ '--no-ext-diff',
740
+ '--no-textconv',
741
+ '--numstat',
742
+ '--',
743
+ '/dev/null',
744
+ path,
745
+ ]).trim();
746
+ const [add = '0', del = '0'] = raw.split('\t');
747
+ return {
748
+ additions: add === '-' ? 0 : Number(add),
749
+ deletions: del === '-' ? 0 : Number(del),
750
+ isBinary: add === '-' || del === '-',
751
+ };
752
+ }
753
+
754
+ function githubFileUrl(repositoryUrl, ref, path) {
755
+ if (!repositoryUrl || !ref || ref === 'WORKTREE') return undefined;
756
+ const filePath = path.split('/').map(encodeURIComponent).join('/');
757
+ return `${repositoryUrl}/blob/${encodeURIComponent(ref)}/${filePath}`;
758
+ }
759
+
760
+ function build() {
761
+ const localWorkspace =
762
+ tryRepo(['rev-parse', '--is-inside-work-tree']) === 'true';
763
+ if (!remoteMode && !localWorkspace) {
764
+ throw new Error(`${repo} is not a Git checkout`);
765
+ }
766
+ const target = resolveTarget();
767
+ const remoteRepository = githubRepository(target.remote?.url);
768
+ const summaryDoc = readJson(summariesPath, {}) || {};
769
+ const nameStatus = parseNameStatus(
770
+ target.runGit([
771
+ 'diff',
772
+ '--no-ext-diff',
773
+ '--no-textconv',
774
+ '--name-status',
775
+ '-z',
776
+ '--find-renames',
777
+ ...target.range,
778
+ ]),
779
+ ).filter((file) => !excludedPaths.has(file.path));
780
+ const numstat = parseNumstat(
781
+ target.runGit([
782
+ 'diff',
783
+ '--no-ext-diff',
784
+ '--no-textconv',
785
+ '--numstat',
786
+ '-z',
787
+ '--find-renames',
788
+ ...target.range,
789
+ ]),
790
+ );
791
+
792
+ if (target.kind === 'worktree' || target.kind === 'checkout') {
793
+ const trackedPaths = new Set(nameStatus.map((file) => file.path));
794
+ const untracked = tryRepo([
795
+ 'ls-files',
796
+ '--others',
797
+ '--exclude-standard',
798
+ '-z',
799
+ ])
800
+ .split('\0')
801
+ .filter((path) => path && !excludedPaths.has(path));
802
+ for (const path of untracked) {
803
+ if (!trackedPaths.has(path)) {
804
+ nameStatus.push({ path, status: 'added', untracked: true });
805
+ }
806
+ }
807
+ nameStatus.sort((left, right) => left.path.localeCompare(right.path));
808
+ }
809
+
810
+ const filesWithoutSummaries = nameStatus.map((file) => {
811
+ const stat = file.untracked
812
+ ? untrackedStat(file.path)
813
+ : numstat.get(file.path) || {
814
+ additions: 0,
815
+ deletions: 0,
816
+ isBinary: false,
817
+ };
818
+ const patch = filePatch(file, target);
819
+ const binary =
820
+ stat.isBinary ||
821
+ patch.includes('Binary files ') ||
822
+ patch.includes('GIT binary patch');
823
+ const textPatch = binary ? '' : patch;
824
+ const sourceUrl = githubFileUrl(
825
+ file.status === 'deleted'
826
+ ? target.baseRepositoryUrl
827
+ : target.sourceRepositoryUrl,
828
+ file.status === 'deleted' ? target.base : target.head,
829
+ file.path,
830
+ );
831
+ return {
832
+ path: file.path,
833
+ ...(file.oldPath ? { oldPath: file.oldPath } : {}),
834
+ status: binary ? 'binary' : file.status,
835
+ additions: stat.additions,
836
+ deletions: stat.deletions,
837
+ isBinary: binary,
838
+ isTruncated: !binary && textPatch.split('\n').length > 180,
839
+ totalDiffLines: textPatch ? textPatch.split('\n').length - 1 : 0,
840
+ patch: textPatch,
841
+ snippet: binary ? '' : compactSnippet(textPatch),
842
+ ...(sourceUrl ? { sourceUrl } : {}),
843
+ };
844
+ });
845
+
846
+ const reviewFingerprint = createHash('sha256')
847
+ .update(
848
+ JSON.stringify({
849
+ repo: {
850
+ base: target.base,
851
+ head: target.head,
852
+ branch: target.branch,
853
+ baseBranch: target.baseBranch,
854
+ remote: target.remote?.name,
855
+ targetKind: target.kind,
856
+ },
857
+ files: filesWithoutSummaries.map((file) => ({
858
+ path: file.path,
859
+ oldPath: file.oldPath,
860
+ status: file.status,
861
+ additions: file.additions,
862
+ deletions: file.deletions,
863
+ isBinary: file.isBinary,
864
+ patch: file.patch,
865
+ })),
866
+ }),
867
+ )
868
+ .digest('hex');
869
+ const generatedFor =
870
+ typeof summaryDoc.meta?.reviewFingerprint === 'string'
871
+ ? summaryDoc.meta.reviewFingerprint
872
+ : undefined;
873
+ const summariesAreFresh = !generatedFor || generatedFor === reviewFingerprint;
874
+ const sourceSummaries = summariesAreFresh ? summaryDoc : {};
875
+ const summariesAreComplete =
876
+ summariesAreFresh &&
877
+ completeChangeSummary(sourceSummaries.change) &&
878
+ filesWithoutSummaries.every((file) =>
879
+ completeFileSummary(sourceSummaries.files?.[file.path]),
880
+ );
881
+ const completedFiles = filesWithoutSummaries.filter((file) =>
882
+ completeFileSummary(sourceSummaries.files?.[file.path]),
883
+ ).length;
884
+ const storedStatus = summaryDoc.meta?.status;
885
+ const noteStatus = summariesAreComplete
886
+ ? 'complete'
887
+ : !summariesAreFresh
888
+ ? 'stale'
889
+ : ['generating', 'failed'].includes(storedStatus)
890
+ ? storedStatus
891
+ : 'idle';
892
+ const files = filesWithoutSummaries.map((file) => ({
893
+ ...file,
894
+ summary: fileSummary(file.path, sourceSummaries.files?.[file.path]),
895
+ noteReady: Boolean(
896
+ completeFileSummary(sourceSummaries.files?.[file.path]),
897
+ ),
898
+ }));
899
+ const change = changeSummary(sourceSummaries.change, target.changeDefaults);
900
+ const content = {
901
+ repo: {
902
+ name: remoteRepository?.name || repo.split('/').pop(),
903
+ root: localWorkspace ? repo : target.remote?.url || repo,
904
+ base: target.base,
905
+ head: target.head,
906
+ ...(target.branch ? { branch: target.branch } : {}),
907
+ ...(target.baseBranch ? { baseBranch: target.baseBranch } : {}),
908
+ ...(target.remote
909
+ ? { remote: target.remote.name, remoteUrl: target.remote.url }
910
+ : {}),
911
+ target: target.target,
912
+ },
913
+ change,
914
+ files,
915
+ notes: {
916
+ reviewFingerprint,
917
+ ...(generatedFor ? { generatedFor } : {}),
918
+ fresh: summariesAreFresh,
919
+ complete: summariesAreComplete,
920
+ status: noteStatus,
921
+ completedFiles,
922
+ totalFiles: filesWithoutSummaries.length,
923
+ ...(typeof summaryDoc.meta?.model === 'string'
924
+ ? { model: summaryDoc.meta.model }
925
+ : {}),
926
+ ...(typeof summaryDoc.meta?.reasoning === 'string'
927
+ ? { reasoning: summaryDoc.meta.reasoning }
928
+ : {}),
929
+ },
930
+ };
931
+ const version = createHash('sha256')
932
+ .update(JSON.stringify(content))
933
+ .digest('hex')
934
+ .slice(0, 12);
935
+ const payload = {
936
+ version,
937
+ generatedAt: new Date().toISOString(),
938
+ ...content,
939
+ };
940
+ const old = readJson(output, null);
941
+ if (old) {
942
+ const prior = { ...old };
943
+ const next = { ...payload };
944
+ delete prior.generatedAt;
945
+ delete next.generatedAt;
946
+ if (JSON.stringify(prior) === JSON.stringify(next)) return false;
947
+ }
948
+ mkdirSync(dirname(output), { recursive: true });
949
+ const temp = `${output}.${process.pid}.tmp`;
950
+ writeFileSync(temp, `${JSON.stringify(payload, null, 2)}\n`);
951
+ renameSync(temp, output);
952
+ return true;
953
+ }
954
+
955
+ function fingerprint() {
956
+ let summariesTime = '';
957
+ try {
958
+ summariesTime = String(statSync(summariesPath).mtimeMs);
959
+ } catch {}
960
+ if (remoteMode) return summariesTime;
961
+ if (baseOption && headOption) {
962
+ return [
963
+ tryRepo(['rev-parse', baseOption]),
964
+ tryRepo(['rev-parse', headOption]),
965
+ summariesTime,
966
+ ].join('|');
967
+ }
968
+ return [
969
+ tryRepo(['rev-parse', 'HEAD']),
970
+ tryRepo(['status', '--porcelain=v1', '--untracked-files=all']),
971
+ summariesTime,
972
+ ].join('|');
973
+ }
974
+
975
+ const refresh = () => {
976
+ try {
977
+ const wrote = build();
978
+ console.log(wrote ? `Wrote ${output}` : 'No diff-data changes');
979
+ return true;
980
+ } catch (error) {
981
+ console.error(error.message);
982
+ if (!watching) process.exitCode = 1;
983
+ return false;
984
+ }
985
+ };
986
+
987
+ const started = refresh();
988
+ if (watching && started) {
989
+ let last = fingerprint();
990
+ let remoteWait = 0;
991
+ setInterval(() => {
992
+ const next = fingerprint();
993
+ remoteWait += 2_000;
994
+ const remoteDue = remoteMode && remoteWait >= 30_000;
995
+ if (next !== last || remoteDue) {
996
+ last = next;
997
+ remoteWait = 0;
998
+ refresh();
999
+ }
1000
+ }, 2_000);
1001
+ } else if (watching) {
1002
+ process.exitCode = 1;
1003
+ }