git-reviewer 2.11.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.js CHANGED
@@ -12,7 +12,7 @@ const {
12
12
  resolveRepoFile,
13
13
  PathEscapeError
14
14
  } = require('./lib/paths');
15
- const { collectWorkingChanges, collectCommitChanges } = require('./lib/changes');
15
+ const { collectWorkingChanges, collectCommitChanges, parseNameStatus } = require('./lib/changes');
16
16
  const { SessionStore } = require('./lib/sessions');
17
17
  const { normalizeComments } = require('./lib/comments');
18
18
  const { formatReview, reviewFilename, commentsFilename } = require('./lib/review');
@@ -68,6 +68,16 @@ class FileNotFoundError extends Error {
68
68
  // typing a ref, which is what the text box is for.
69
69
  const COMMIT_LIST_LIMIT = 30;
70
70
 
71
+ // Branches and tags offered per group. A repository with two hundred stale
72
+ // branches should still produce a list someone can scan; the ones past the
73
+ // cut are the least recently touched and are still reachable by typing.
74
+ const REF_LIST_LIMIT = 40;
75
+
76
+ // How many commits the commits list shows. A branch of more than this is a
77
+ // review nobody is doing commit by commit; the list says it is truncated
78
+ // rather than pretending the rest are not there.
79
+ const LOG_LIMIT = 200;
80
+
71
81
  function createApp(options = {}) {
72
82
  const {
73
83
  reviewsDir = resolveReviewsDir(),
@@ -128,10 +138,18 @@ function createApp(options = {}) {
128
138
  const absolutePath = resolveRepoFile(session.repoPath, filePath);
129
139
 
130
140
  if (session.range) {
141
+ // A range is two commits, and the working tree is neither of them. It
142
+ // used to be the fallback here, which was merely odd while the head of
143
+ // every range was HEAD and is wrong now that it can be another branch:
144
+ // Full Context on feature/billing, opened from a checkout of
145
+ // feature/auth, would show auth's copy of the file under billing's name.
146
+ //
147
+ // A path missing at the head was deleted inside the range, and the text
148
+ // worth showing is the text that was deleted.
131
149
  try {
132
150
  return await git.show([`${session.range.head}:${filePath}`]);
133
151
  } catch {
134
- return await fs.readFile(absolutePath, 'utf-8').catch(() => '');
152
+ return git.show([`${session.range.from}:${filePath}`]).catch(() => '');
135
153
  }
136
154
  }
137
155
 
@@ -205,7 +223,7 @@ function createApp(options = {}) {
205
223
 
206
224
  async function diffForFile(git, session, filePath) {
207
225
  if (session.range) {
208
- return git.diff([session.range.base, session.range.head, '--', filePath]);
226
+ return git.diff([session.range.from, session.range.head, '--', filePath]);
209
227
  }
210
228
  if (!(await hasCommits(git))) {
211
229
  return '';
@@ -251,7 +269,7 @@ function createApp(options = {}) {
251
269
  }
252
270
  }
253
271
 
254
- const resolve = async (label, ref) => {
272
+ const resolve = async ref => {
255
273
  try {
256
274
  return (await git.revparse([`${ref}^{commit}`])).trim();
257
275
  } catch {
@@ -259,19 +277,151 @@ function createApp(options = {}) {
259
277
  }
260
278
  };
261
279
 
262
- const baseSha = await resolve('base', base);
263
- const headSha = await resolve('head', head);
264
-
265
- // `base..head` counts what head has and base does not, which is the
266
- // number a reviewer means by "how many commits am I looking at". It is
267
- // not symmetric, and it is not the same as the file count -- a range
268
- // whose changes cancel out has commits and no files, which is the whole
269
- // reason this number is reported.
270
- const commits = Number(
271
- (await git.raw(['rev-list', '--count', `${baseSha}..${headSha}`])).trim()
280
+ const baseSha = await resolve(base);
281
+ const headSha = await resolve(head);
282
+
283
+ // The diff starts at the merge base, not at `base` itself -- what a pull
284
+ // request shows, and for the same reason. Compare feature/auth against a
285
+ // main that has moved on since the branch was cut, and a plain
286
+ // `git diff main feature/auth` includes main's newer commits *reversed*:
287
+ // the review then shows the feature author "undoing" a change they never
288
+ // touched, and a reviewer comments on it. On straight-line history the
289
+ // merge base of an ancestor and its descendant is the ancestor, so this
290
+ // changes nothing for x1..x5.
291
+ //
292
+ // Unrelated histories have no merge base; the plain two-ended diff is the
293
+ // only answer there, and it is the one given.
294
+ const from = await git.raw(['merge-base', baseSha, headSha])
295
+ .then(out => out.trim() || baseSha)
296
+ .catch(() => baseSha);
297
+
298
+ // `base..head` is what head has and base does not -- exactly the commits
299
+ // whose changes are in the diff. `head..base` is the other direction: what
300
+ // base has moved on by, which the merge-base diff deliberately leaves out
301
+ // and which the page has to say it left out, or someone will go looking
302
+ // for main's change and conclude the tool lost it.
303
+ const count = async range => Number(
304
+ (await git.raw(['rev-list', '--count', range])).trim()
272
305
  );
306
+ const commits = await count(`${baseSha}..${headSha}`);
307
+ const behind = await count(`${headSha}..${baseSha}`);
308
+
309
+ // What the person asked for, kept alongside the SHAs. Nobody chose
310
+ // `3eb57959`; they chose `main`, and a scope bar that answers in SHAs has
311
+ // translated their question into one they did not ask.
312
+ const nameOf = async ref => {
313
+ if (ref !== 'HEAD') return ref;
314
+ const branch = await git.revparse(['--abbrev-ref', 'HEAD']).then(b => b.trim()).catch(() => 'HEAD');
315
+ return branch === 'HEAD' ? headSha.slice(0, 8) : branch;
316
+ };
317
+
318
+ return {
319
+ base: baseSha,
320
+ head: headSha,
321
+ from,
322
+ commits: Number.isFinite(commits) ? commits : 0,
323
+ behind: Number.isFinite(behind) ? behind : 0,
324
+ baseName: await nameOf(base),
325
+ headName: await nameOf(head)
326
+ };
327
+ }
328
+
329
+ /**
330
+ * The range for reviewing commits themselves: one, or an unbroken run.
331
+ *
332
+ * Different from comparing two refs. `from` and `to` are both *in* the
333
+ * review -- clicking one commit means that commit's changes, and
334
+ * shift-clicking a second means everything from the first through the
335
+ * second -- so the diff starts at the parent of `from`, not at `from`.
336
+ * That is what GitHub does when you pick commits inside a pull request, and
337
+ * what "review commit 3 of 5" has to mean.
338
+ *
339
+ * `from` has to come before `to` on one line of history. Two commits on
340
+ * different branches have no run between them; that is a comparison, and
341
+ * the compare pickers exist for it.
342
+ *
343
+ * @throws {Error} with a message meant for the page
344
+ */
345
+ async function resolveCommitRun(git, fromRef, toRef) {
346
+ for (const [label, ref] of [['first commit', fromRef], ['last commit', toRef]]) {
347
+ if (!looksLikeRef(ref)) throw new Error(`That ${label} does not look like a commit.`);
348
+ }
349
+
350
+ const resolve = async ref => {
351
+ try {
352
+ return (await git.revparse([`${ref}^{commit}`])).trim();
353
+ } catch {
354
+ throw new Error(`No commit called "${ref}" in this repository.`);
355
+ }
356
+ };
357
+ const first = await resolve(fromRef);
358
+ const last = await resolve(toRef);
359
+
360
+ // Ancestry through output, not exit code: `merge-base --is-ancestor`
361
+ // answers only by exiting 1, and simple-git resolves a silent non-zero
362
+ // exit -- so it reported every pair as ordered, including main before a
363
+ // branch that main was not an ancestor of. The merge base of an ancestor
364
+ // and its descendant is the ancestor, and that is checkable.
365
+ const mergeBase = (await git.raw(['merge-base', first, last]).catch(() => '')).trim();
366
+ if (mergeBase !== first) {
367
+ throw new Error(
368
+ `${first.slice(0, 7)} does not come before ${last.slice(0, 7)} on one line of history, ` +
369
+ 'so there is no run of commits between them. Use Compare to diff two unrelated points.'
370
+ );
371
+ }
372
+
373
+ // A root commit has no parent. Its "before" is the empty tree, which is
374
+ // also how `git show` presents a root commit: every file added.
375
+ let from;
376
+ let hasParent = true;
377
+ try {
378
+ from = (await git.revparse([`${first}^`])).trim();
379
+ } catch {
380
+ hasParent = false;
381
+ from = (await git.raw(['hash-object', '-t', 'tree', '/dev/null'])).trim();
382
+ }
273
383
 
274
- return { base: baseSha, head: headSha, commits: Number.isFinite(commits) ? commits : 0 };
384
+ const count = Number((await git.raw(['rev-list', '--count', hasParent ? `${from}..${last}` : last, '--'])).trim());
385
+ const subject = first === last
386
+ ? (await git.raw(['log', '-1', '--format=%s', last, '--']).catch(() => '')).trim()
387
+ : null;
388
+
389
+ return {
390
+ kind: 'commits',
391
+ first,
392
+ last,
393
+ base: from,
394
+ head: last,
395
+ from,
396
+ commits: Number.isFinite(count) ? count : 1,
397
+ behind: 0,
398
+ baseName: first.slice(0, 7),
399
+ headName: last.slice(0, 7),
400
+ ...(subject ? { subject } : {})
401
+ };
402
+ }
403
+
404
+ /** What to say about a commit or a run of them once loaded. */
405
+ function describeCommitRun(range, fileCount) {
406
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
407
+ const files = fileCount === 0 ? 'no files changed' : plural(fileCount, 'changed file');
408
+ return range.first === range.last
409
+ ? `Commit ${range.headName}${range.subject ? `: ${range.subject}` : ''} — ${files}`
410
+ : `${plural(range.commits, 'commit')}, ${range.baseName} through ${range.headName} — ${files}`;
411
+ }
412
+
413
+ /**
414
+ * The changed files of a range, with the status git gives each one.
415
+ *
416
+ * @param {import('simple-git').SimpleGit} git
417
+ * @param {{from: string, head: string}} range
418
+ */
419
+ async function collectRangeChanges(git, range) {
420
+ const [summary, nameStatus] = await Promise.all([
421
+ git.diffSummary([range.from, range.head]),
422
+ git.raw(['diff', '--name-status', '-z', '-M', range.from, range.head])
423
+ ]);
424
+ return collectCommitChanges(summary, parseNameStatus(nameStatus));
275
425
  }
276
426
 
277
427
  /**
@@ -288,15 +438,20 @@ function createApp(options = {}) {
288
438
  * @returns {string}
289
439
  */
290
440
  function describeRange(range, fileCount) {
291
- const commits = `${range.commits} commit${range.commits === 1 ? '' : 's'}`;
441
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
442
+ const ends = `${range.baseName ?? range.base.slice(0, 8)} \u2192 ${range.headName ?? range.head.slice(0, 8)}`;
443
+
444
+ if (fileCount > 0) {
445
+ return `${ends}: ${plural(range.commits, 'commit')}, ${plural(fileCount, 'changed file')}`;
446
+ }
292
447
 
293
- if (fileCount === 0) {
294
- return range.commits === 0
295
- ? 'Those two commits are the same, so there is nothing between them.'
296
- : `${commits}, and no net change between the two ends — every change in them was undone again inside the range.`;
448
+ if (range.commits === 0) {
449
+ return range.behind === 0
450
+ ? `${ends}: both point at the same commit, so there is nothing between them.`
451
+ : `${ends}: ${range.headName ?? 'the head'} has no commits that ${range.baseName ?? 'the base'} does not already have.`;
297
452
  }
298
453
 
299
- return `${commits}, ${fileCount} changed file${fileCount === 1 ? '' : 's'} between the two ends`;
454
+ return `${ends}: ${plural(range.commits, 'commit')}, and no net change \u2014 every change in them was undone again inside the range.`;
300
455
  }
301
456
 
302
457
  /**
@@ -316,9 +471,9 @@ function createApp(options = {}) {
316
471
  * @param {import('simple-git').SimpleGit} git
317
472
  * @param {number} limit
318
473
  */
319
- async function recentCommits(git, limit) {
474
+ async function recentCommits(git, limit, head = 'HEAD') {
320
475
  const raw = await git.raw([
321
- 'log', `--max-count=${limit}`, '--format=%H%x00%h%x00%s%x00%cr'
476
+ 'log', `--max-count=${limit}`, '--format=%H%x00%h%x00%s%x00%cr', head, '--'
322
477
  ]);
323
478
 
324
479
  return raw
@@ -331,7 +486,138 @@ function createApp(options = {}) {
331
486
  }
332
487
 
333
488
  /**
334
- * The commits of an open repository, newest first.
489
+ * Everything the compare pickers offer: branches, tags and recent commits.
490
+ *
491
+ * Branches come first in the page because comparing branches is what people
492
+ * reach for -- "what does my branch change against main" -- and a picker
493
+ * that only lists commits of the current branch makes that the one
494
+ * comparison you cannot pick. Remote-tracking branches are included because
495
+ * `origin/main` is the base most reviews actually want, and a local `main`
496
+ * that has not been pulled in a week is a quietly wrong one.
497
+ *
498
+ * Sorted by most recent activity, so the branch you are working on and the
499
+ * one you branched from are near the top rather than alphabetised into the
500
+ * middle of forty others.
501
+ */
502
+ app.get('/api/refs/:repoId', async (req, res) => {
503
+ const session = requireSession(res, req.params.repoId);
504
+ if (!session) return;
505
+
506
+ const git = gitFactory(session.repoPath);
507
+ const refList = async (pattern, limit) => {
508
+ try {
509
+ const raw = await git.raw([
510
+ 'for-each-ref', `--count=${limit}`, '--sort=-committerdate',
511
+ '--format=%(refname:short)%00%(objectname)%00%(committerdate:relative)%00%(subject)%00%(symref)',
512
+ pattern
513
+ ]);
514
+ return raw.split('\n').filter(Boolean).map(line => {
515
+ const [name, sha, when, subject, symref] = line.split('\u0000');
516
+ return { name, sha, when, subject, symref };
517
+ })
518
+ // A symbolic ref points at another ref rather than being one --
519
+ // `origin/HEAD` in every clone. It cannot be filtered by name:
520
+ // `refname:short` abbreviates it to plain `origin`, which is how it
521
+ // first slipped into the list as a "branch".
522
+ .filter(ref => !ref.symref)
523
+ .map(({ symref, ...ref }) => ref);
524
+ } catch {
525
+ return [];
526
+ }
527
+ };
528
+
529
+ const current = await git.revparse(['--abbrev-ref', 'HEAD']).then(b => b.trim()).catch(() => null);
530
+
531
+ // The commits offered are the history of whatever is being compared, not
532
+ // always of the checkout: comparing main against feature/billing from a
533
+ // checkout of feature/auth should offer billing's commits to pick from.
534
+ const requested = typeof req.query.head === 'string' ? req.query.head.trim() : '';
535
+ const head = requested && looksLikeRef(requested) ? requested : 'HEAD';
536
+
537
+ const [branches, remotes, tags, commits] = await Promise.all([
538
+ refList('refs/heads', REF_LIST_LIMIT),
539
+ refList('refs/remotes', REF_LIST_LIMIT),
540
+ refList('refs/tags', REF_LIST_LIMIT),
541
+ recentCommits(git, COMMIT_LIST_LIMIT, head).catch(() => [])
542
+ ]);
543
+
544
+ res.json({
545
+ // `HEAD` from --abbrev-ref means detached; say so rather than pretend
546
+ // there is a branch called HEAD.
547
+ current: current === 'HEAD' ? null : current,
548
+ branches,
549
+ remotes,
550
+ tags,
551
+ commits
552
+ });
553
+ });
554
+
555
+ /**
556
+ * The commits a review can be narrowed to, one at a time or as a run.
557
+ *
558
+ * With a base: the commits the comparison is made of -- what `base..head`
559
+ * has -- oldest first, because a branch reads as a story in the order it
560
+ * was written, and stepping through it commit by commit is the point.
561
+ * Without one: the recent history of `head`, newest first, the way `git
562
+ * log` reads, because there is no story there, only "how far back".
563
+ *
564
+ * Each commit carries its full message. The subject is what fits in a
565
+ * list, but the body is often where the author explains the change, and a
566
+ * reviewer looking at one commit should not have to leave the tool to read
567
+ * why it was made.
568
+ *
569
+ * Records are split on %x1e rather than on newlines, because a commit body
570
+ * is made of newlines.
571
+ */
572
+ app.get('/api/log/:repoId', async (req, res) => {
573
+ const session = requireSession(res, req.params.repoId);
574
+ if (!session) return;
575
+
576
+ const git = gitFactory(session.repoPath);
577
+ const pick = name => (typeof req.query[name] === 'string' ? req.query[name].trim() : '');
578
+ const head = pick('head') || 'HEAD';
579
+ const base = pick('base');
580
+
581
+ for (const ref of base ? [base, head] : [head]) {
582
+ if (!looksLikeRef(ref)) {
583
+ return res.status(400).json({ error: 'That does not look like a commit or branch.' });
584
+ }
585
+ }
586
+
587
+ try {
588
+ const range = base ? `${base}..${head}` : head;
589
+ const total = Number((await git.raw(['rev-list', '--count', range, '--'])).trim()) || 0;
590
+ const raw = await git.raw([
591
+ 'log', `--max-count=${LOG_LIMIT}`, ...(base ? ['--reverse'] : []),
592
+ '--format=%H%x00%h%x00%s%x00%an%x00%cr%x00%P%x00%b%x1e', range, '--'
593
+ ]);
594
+
595
+ const commits = raw.split('\u001e').map(record => record.replace(/^\n/, '')).filter(Boolean).map(record => {
596
+ const [sha, short, subject, author, when, parents, body] = record.split('\u0000');
597
+ return {
598
+ sha, short, subject, author, when,
599
+ body: (body ?? '').trim(),
600
+ // A merge is shown against its first parent when reviewed alone --
601
+ // what it brought in -- and the list says it is one, because that
602
+ // diff can be surprisingly large.
603
+ merge: (parents ?? '').trim().split(/\s+/).filter(Boolean).length > 1
604
+ };
605
+ });
606
+
607
+ res.json({
608
+ order: base ? 'oldest-first' : 'newest-first',
609
+ total,
610
+ truncated: total > commits.length,
611
+ commits
612
+ });
613
+ } catch {
614
+ res.status(400).json({ error: `Could not read the history of "${base ? `${base}..${head}` : head}".` });
615
+ }
616
+ });
617
+
618
+ /**
619
+ * The commits of an open repository, newest first. Kept for 2.11 callers;
620
+ * the page itself uses /api/refs and /api/log.
335
621
  *
336
622
  * Behind a session id like the file endpoints rather than taking a path:
337
623
  * this reads history out of a repository, and the session is what says
@@ -482,7 +768,7 @@ function createApp(options = {}) {
482
768
  // Resolve to the root of the working tree before anything is keyed on
483
769
  // this path. A subdirectory passes `checkIsRepo()` quite happily and
484
770
  // then produces a file list whose diffs are all empty; see lib/repo.js.
485
- const { base, head } = req.body ?? {};
771
+ const { base, head, commits: run } = req.body ?? {};
486
772
  const root = await repoRoot(repoPath, gitFactory);
487
773
  if (!root) {
488
774
  return res.status(400).json({ error: 'Not a valid git repository' });
@@ -495,7 +781,18 @@ function createApp(options = {}) {
495
781
  let range = null;
496
782
  let message;
497
783
 
498
- if (base !== undefined && base !== null && `${base}`.trim() !== '') {
784
+ if (run && typeof run === 'object') {
785
+ // Commits themselves, one or a run: see resolveCommitRun.
786
+ try {
787
+ range = await resolveCommitRun(git, `${run.from ?? ''}`.trim(), `${run.to ?? run.from ?? ''}`.trim());
788
+ } catch (error) {
789
+ return res.status(400).json({ error: error.message });
790
+ }
791
+
792
+ files = await collectRangeChanges(git, range);
793
+ mode = 'range';
794
+ message = describeCommitRun(range, files.length);
795
+ } else if (base !== undefined && base !== null && `${base}`.trim() !== '') {
499
796
  // An explicit range. Nothing falls back to anything here: someone who
500
797
  // asked for x1..x3 and is silently shown their working tree instead
501
798
  // has been told a lie about what they are reviewing.
@@ -505,7 +802,7 @@ function createApp(options = {}) {
505
802
  return res.status(400).json({ error: error.message });
506
803
  }
507
804
 
508
- files = collectCommitChanges(await git.diffSummary([range.base, range.head]));
805
+ files = await collectRangeChanges(git, range);
509
806
  mode = 'range';
510
807
  message = describeRange(range, files.length);
511
808
  } else {
@@ -517,9 +814,7 @@ function createApp(options = {}) {
517
814
  if (files.length === 0) {
518
815
  try {
519
816
  const lastCommit = await resolveRange(git, 'HEAD~1', 'HEAD');
520
- const commitFiles = collectCommitChanges(
521
- await git.diffSummary([lastCommit.base, lastCommit.head])
522
- );
817
+ const commitFiles = await collectRangeChanges(git, lastCommit);
523
818
  if (commitFiles.length > 0) {
524
819
  files = commitFiles;
525
820
  mode = 'lastCommit';