git-reviewer 2.12.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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![CI](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml/badge.svg)](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
5
  [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](package.json)
6
- [![Tests](https://img.shields.io/badge/tests-335-brightgreen.svg)](test/)
6
+ [![Tests](https://img.shields.io/badge/tests-355-brightgreen.svg)](test/)
7
7
  [![Dependencies](https://img.shields.io/badge/dependencies-3-brightgreen.svg)](package.json)
8
8
 
9
9
  When an agent writes the code, reading it becomes the bottleneck — and the tool
@@ -141,7 +141,7 @@ is nothing to configure:
141
141
  | Working directory clean | The last commit against its parent |
142
142
  | No commits yet | Every tracked and untracked file, as wholly new |
143
143
 
144
- ### Comparing branches
144
+ ### Comparing branches and commits
145
145
 
146
146
  Those defaults cover the common case with nothing to set up, but a branch you
147
147
  have worked on for a day is several commits deep, and the default scope only
@@ -178,6 +178,29 @@ commits that add a file and then delete it produce no diff between the ends —
178
178
  correctly — and that reads as *"2 commits undo each other"*, not as *"nothing
179
179
  to review"*.
180
180
 
181
+ #### Reviewing commit by commit
182
+
183
+ Whenever the review is made of commits, the sidebar lists them above the files:
184
+ the commits a comparison is made of, oldest first, or the branch's recent
185
+ history when you are looking at its last commit.
186
+
187
+ - **Click** a commit to review it on its own, with its full message above the
188
+ diff — often the only place the author says *why*.
189
+ - **Shift-click** a second to review the run between them, both ends included.
190
+ From the last commit, shift-clicking three rows down is "the last three
191
+ commits"; inside a comparison, it skips the ones you have already read.
192
+ - **`[`** and **`]`** step one commit older or newer, and **All commits** goes
193
+ back to the whole comparison.
194
+
195
+ That is the same gesture a pull request's commit picker uses, for the same
196
+ reasons: a large change reads better in the order it was written, and one
197
+ commit of renames is easier to skip than to read around.
198
+
199
+ A run has to be one unbroken line of history. To diff two points that are not
200
+ — a commit on one branch against another branch — use **Compare**; its pickers
201
+ list recent commits of whichever branch you are comparing, right after the
202
+ local branches.
203
+
181
204
  Comments are kept when you switch between comparisons. One on a file that is
182
205
  not in the current view stays in the comments list, marked **Not in this
183
206
  view**, and is back in place whenever you open a view that includes the file.
@@ -305,8 +328,9 @@ The UI is a client of this; nothing is hidden from you.
305
328
  | Endpoint | Purpose |
306
329
  |---|---|
307
330
  | `GET /api/health` | Liveness, plus the number of open sessions |
308
- | `POST /api/load-repo` | Open a repository; returns a `repoId` and the changed files. Pass `base` (and optionally `head`) to compare two refs from their merge base |
309
- | `GET /api/refs/:repoId` | Branches, remote branches, tags and recent commits, for choosing what to compare |
331
+ | `POST /api/load-repo` | Open a repository; returns a `repoId` and the changed files. Pass `base` (and optionally `head`) to compare two refs from their merge base, or `commits: { from, to }` to review one commit or an unbroken run of them, both ends included |
332
+ | `GET /api/refs/:repoId` | Branches, remote branches, tags and recent commits, for choosing what to compare. `?head=` makes the commits those of another branch |
333
+ | `GET /api/log/:repoId` | Commits with their messages: `?base=&head=` for a comparison's commits, oldest first, or `?head=` alone for recent history, newest first |
310
334
  | `GET /api/commits/:repoId` | Recent commits only; kept for callers of 2.11 |
311
335
  | `GET /api/alive` | Held open by the page; when the last one closes, a terminal run stops |
312
336
  | `GET /api/file/:repoId/:path` | The file's diff, as structured lines |
@@ -338,7 +362,7 @@ rather than a public issue.
338
362
 
339
363
  ```bash
340
364
  npm install # 3 dependencies, no build step, ~6MB
341
- npm test # 335 tests
365
+ npm test # 355 tests
342
366
  npm run test:watch
343
367
  npm run test:coverage
344
368
  ```
package/lib/agent.js CHANGED
@@ -205,6 +205,17 @@ function describeScope({ repository, mode, range }) {
205
205
  return `Reviewed: uncommitted changes in the working tree, against ${repository.branch ?? 'HEAD'}${at}.`;
206
206
  }
207
207
 
208
+ if (mode === 'range' && range?.kind === 'commits') {
209
+ // Commits themselves, picked from a list. Both ends are *in* the review,
210
+ // which is the difference from a comparison and worth saying: an agent
211
+ // told "between A and B" would reasonably assume A's own changes were
212
+ // outside it.
213
+ if (range.first === range.last) {
214
+ return `Reviewed: commit ${range.last}${range.subject ? ` ("${range.subject}")` : ''} on its own, against its parent.`;
215
+ }
216
+ return `Reviewed: ${range.commits} commits, ${range.first} through ${range.last} inclusive, as a single combined diff.`;
217
+ }
218
+
208
219
  if (mode === 'range' && range) {
209
220
  // Both ends, and the count, because the count is the only thing that
210
221
  // distinguishes "one commit of work" from "nine" once the diff has been
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-reviewer",
3
- "version": "2.12.0",
3
+ "version": "2.13.0",
4
4
  "description": "Review your coding agent's changes like a pull request — locally, with inline comments and threaded follow-ups, then hand the marked-up review back to the agent",
5
5
  "author": "Dheeraj Jha",
6
6
  "license": "MIT",
package/public/app.js CHANGED
@@ -166,9 +166,10 @@ const PICKERS = {
166
166
  * works; a repository whose refs will not list is not a reason to refuse to
167
167
  * compare it.
168
168
  */
169
- async function loadRefs(repoId) {
169
+ async function loadRefs(repoId, head) {
170
170
  try {
171
- const response = await fetch(`${API_BASE}/refs/${repoId}`);
171
+ const query = head ? `?${new URLSearchParams({ head })}` : '';
172
+ const response = await fetch(`${API_BASE}/refs/${repoId}${query}`);
172
173
  currentRefs = response.ok ? await response.json() : null;
173
174
  } catch {
174
175
  currentRefs = null;
@@ -207,12 +208,15 @@ function fillPicker(end) {
207
208
  `${branch.name}${branch.name === refs.current ? ' (current)' : ''} · ${branch.when}`,
208
209
  branch.name
209
210
  )));
210
- group('Remote branches', refs.remotes.map(branch => new Option(`${branch.name} · ${branch.when}`, branch.name)));
211
- group('Tags', refs.tags.map(tag => new Option(`${tag.name} · ${tag.when}`, tag.name)));
211
+ // Commits second, not last. In 2.12 they came after every remote branch
212
+ // and tag -- option 51 of 81 in this project's own repository -- which is
213
+ // why comparing a commit felt like a feature that had been removed.
212
214
  group(
213
- refs.current ? `Recent commits on ${refs.current}` : 'Recent commits',
215
+ 'Recent commits',
214
216
  refs.commits.map(commit => new Option(`${commit.short} · ${commit.subject} · ${commit.when}`, commit.sha))
215
217
  );
218
+ group('Remote branches', refs.remotes.map(branch => new Option(`${branch.name} · ${branch.when}`, branch.name)));
219
+ group('Tags', refs.tags.map(tag => new Option(`${tag.name} · ${tag.when}`, tag.name)));
216
220
  }
217
221
 
218
222
  select.appendChild(new Option('Other: a tag, HEAD~5, a sha…', OTHER_REF));
@@ -353,15 +357,89 @@ function renderScope(data) {
353
357
  el.textContent = text;
354
358
  return el;
355
359
  };
356
- const setNote = text => {
360
+ const setNote = (text, { message = false } = {}) => {
357
361
  note.textContent = text ?? '';
358
362
  note.classList.toggle('hidden', !text);
363
+ // A commit message keeps its line breaks; an explanation reflows.
364
+ note.classList.toggle('scope-note-message', Boolean(text) && message);
359
365
  };
360
366
 
367
+ const nav = document.getElementById('commitNav');
368
+ const position = document.getElementById('commitPosition');
369
+ const allCommits = document.getElementById('allCommitsBtn');
370
+ nav.classList.add('hidden');
371
+ allCommits.classList.add('hidden');
372
+ reset.textContent = 'Stop comparing';
373
+
361
374
  bar.classList.remove('hidden');
362
375
  label.replaceChildren();
363
376
  const branch = currentRefs?.current;
364
377
 
378
+ if (data.mode === 'range' && data.range?.kind === 'commits') {
379
+ // One commit, or a run of them, picked from the commits list.
380
+ const run = data.range;
381
+ const single = run.first === run.last;
382
+ const chrono = chronologicalCommits(commitContext);
383
+ const index = chrono.findIndex(commit => commit.sha === run.first);
384
+ const commit = single && index !== -1 ? chrono[index] : null;
385
+ const files = plural(data.files.length, 'file') + ' changed';
386
+
387
+ if (single) {
388
+ label.append('Commit ', strong(run.headName));
389
+ } else {
390
+ label.append(strong(run.baseName), ' \u2026 ', strong(run.headName));
391
+ }
392
+
393
+ const inComparison = commitContext?.kind === 'comparison';
394
+ if (inComparison) {
395
+ const outer = commitContext.range;
396
+ label.append(' in ', strong(displayRef(outer.baseName ?? outer.base)), ' \u2192 ',
397
+ strong(displayRef(outer.headName ?? outer.head)));
398
+ } else if (branch) {
399
+ label.append(' on ', strong(branch));
400
+ }
401
+
402
+ meta.textContent = single
403
+ ? `${commit?.subject ?? run.subject ?? ''} \u00b7 ${files}`
404
+ : `${plural(run.commits, 'commit')} \u00b7 ${files}`;
405
+
406
+ // The message is review material -- often the only place the author says
407
+ // why -- so a single commit shows its body, not just its subject line.
408
+ if (single && commit?.body) {
409
+ setNote(commit.body, { message: true });
410
+ } else if (!single) {
411
+ setNote(`Reviewing ${plural(run.commits, 'commit')} as one combined diff, from ${run.baseName} through ${run.headName}.`);
412
+ } else {
413
+ setNote(null);
414
+ }
415
+
416
+ if (single && index !== -1) {
417
+ nav.classList.remove('hidden');
418
+ // "2 of 3" means something inside a comparison; inside a branch's
419
+ // history it would count from wherever the list happened to stop.
420
+ position.textContent = inComparison ? `${index + 1} of ${chrono.length}` : '';
421
+ document.getElementById('prevCommitBtn').disabled = index === 0;
422
+ document.getElementById('nextCommitBtn').disabled = index === chrono.length - 1;
423
+ }
424
+
425
+ if (inComparison) {
426
+ row.classList.remove('hidden');
427
+ const outer = commitContext.range;
428
+ selectRef('base', outer.baseName, outer.base);
429
+ selectRef('head', outer.headName, outer.head);
430
+ allCommits.textContent = `All ${plural(chrono.length, 'commit')}`;
431
+ allCommits.classList.remove('hidden');
432
+ toggle.classList.add('hidden');
433
+ reset.classList.remove('hidden');
434
+ } else {
435
+ row.classList.add('hidden');
436
+ toggle.classList.remove('hidden');
437
+ reset.textContent = 'Back to latest';
438
+ reset.classList.remove('hidden');
439
+ }
440
+ return;
441
+ }
442
+
365
443
  if (data.mode === 'range' && data.range) {
366
444
  const range = data.range;
367
445
  const base = displayRef(range.baseName ?? range.base);
@@ -401,20 +479,265 @@ function renderScope(data) {
401
479
  if (data.mode === 'lastCommit') {
402
480
  label.append('Last commit');
403
481
  if (branch) label.append(' on ', strong(branch));
404
- const tip = currentRefs?.commits?.[0];
482
+ const tip = commitContext?.commits?.[0] ?? currentRefs?.commits?.[0];
405
483
  meta.textContent = tip ? `${tip.subject} · ${tip.short}` : '';
484
+ setNote(tip?.body || null, { message: true });
485
+
486
+ // One click back through history from here -- the first thing someone
487
+ // looking at the last commit is likely to want next.
488
+ if ((commitContext?.commits?.length ?? 0) > 1) {
489
+ nav.classList.remove('hidden');
490
+ position.textContent = '';
491
+ document.getElementById('prevCommitBtn').disabled = false;
492
+ document.getElementById('nextCommitBtn').disabled = true;
493
+ }
406
494
  } else {
407
495
  label.append('Uncommitted changes');
408
496
  if (branch) label.append(' on ', strong(branch));
409
497
  meta.textContent = plural(data.files.length, 'file') + ' changed';
498
+ setNote(null);
410
499
  }
411
500
 
412
- setNote(null);
413
501
  row.classList.add('hidden');
414
502
  toggle.classList.remove('hidden');
415
503
  reset.classList.add('hidden');
416
504
  }
417
505
 
506
+ // --- commits: reviewing them one at a time, or a run of them -----------------
507
+
508
+ // What the commits list is showing. `comparison` is the commits a Compare is
509
+ // made of, oldest first; `history` is the recent history of the branch when
510
+ // the view is its last commit, newest first. Null for the working tree, which
511
+ // has no commits to list. `range` keeps the outer comparison, so narrowing to
512
+ // one commit does not lose what it was a commit *of*.
513
+ let commitContext = null;
514
+
515
+ // Which of those commits the review currently shows, as `{from, to}` shas,
516
+ // oldest first; null for all of them.
517
+ let commitSelection = null;
518
+
519
+ // The commit a shift-click extends from.
520
+ let commitAnchor = null;
521
+
522
+ /** The context's commits, oldest first, whichever order the list shows them in. */
523
+ function chronologicalCommits(context) {
524
+ if (!context) return [];
525
+ return context.order === 'newest-first' ? [...context.commits].reverse() : context.commits;
526
+ }
527
+
528
+ /**
529
+ * The run between two commits of a list, oldest first -- whichever was
530
+ * clicked first. A shift-click upwards and one downwards select the same run.
531
+ *
532
+ * @param {{sha: string}[]} chronological commits, oldest first
533
+ * @returns {[string, string]|null} [from, to]
534
+ */
535
+ function orderedRun(chronological, a, b) {
536
+ const i = chronological.findIndex(commit => commit.sha === a);
537
+ const j = chronological.findIndex(commit => commit.sha === b);
538
+ if (i === -1 || j === -1) return null;
539
+ return i <= j
540
+ ? [chronological[i].sha, chronological[j].sha]
541
+ : [chronological[j].sha, chronological[i].sha];
542
+ }
543
+
544
+ /**
545
+ * Where a step lands, or -1 if there is nowhere to go.
546
+ *
547
+ * From "all commits" a step forward starts at the oldest -- the beginning of
548
+ * the story -- and a step back starts at the newest. Stepping never wraps:
549
+ * reaching the end of a branch and silently landing on its first commit
550
+ * again reads as the list having started over.
551
+ *
552
+ * @param {number} length
553
+ * @param {number} current index of the one commit shown, or -1 if not one
554
+ * @param {1|-1} direction +1 newer, -1 older
555
+ */
556
+ function stepIndex(length, current, direction) {
557
+ if (length === 0) return -1;
558
+ if (current === -1) return direction > 0 ? 0 : length - 1;
559
+ const next = current + direction;
560
+ return next >= 0 && next < length ? next : -1;
561
+ }
562
+
563
+ /**
564
+ * "3 hours ago" as "3h", for a list where the subject needs the width.
565
+ *
566
+ * Git's relative dates are written for a sentence, and in a 320px sidebar
567
+ * "2 seconds ago" took more room than the commit subject beside it. The full
568
+ * phrase stays in the row's tooltip. Anything not recognised is returned as
569
+ * it came, rather than guessed at.
570
+ *
571
+ * @param {string} when git's %cr, e.g. "3 weeks ago" or "1 year, 2 months ago"
572
+ */
573
+ function compactWhen(when) {
574
+ const match = /^(\d+) (second|minute|hour|day|week|month|year)s?(?:, .*)? ago$/.exec(when ?? '');
575
+ if (!match) return when ?? '';
576
+ const unit = { second: 's', minute: 'm', hour: 'h', day: 'd', week: 'w', month: 'mo', year: 'y' }[match[2]];
577
+ return `${match[1]}${unit}`;
578
+ }
579
+
580
+ /** Fetch a commits list for the context the review is in. */
581
+ async function fetchCommitContext(repoId, kind, { base, head, range }) {
582
+ const query = new URLSearchParams({ head });
583
+ if (base) query.set('base', base);
584
+
585
+ try {
586
+ const response = await fetch(`${API_BASE}/log/${repoId}?${query}`);
587
+ if (!response.ok) return null;
588
+ const log = await response.json();
589
+ return { kind, base, head, range, ...log };
590
+ } catch {
591
+ return null;
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Decide what the commits list shows after a load.
597
+ *
598
+ * A load that narrows to commits keeps the list it narrowed from; anything
599
+ * else rebuilds it. The branch history offered with "last commit" is the same
600
+ * gesture as a comparison's commits: this is where "review the changes since
601
+ * that commit", which 2.11 had as a dropdown and 2.12 buried under forty
602
+ * branches, lives now.
603
+ */
604
+ async function refreshCommitContext(data, scope) {
605
+ if (scope.commits && commitContext) {
606
+ commitSelection = { from: data.range.first, to: data.range.last };
607
+ return;
608
+ }
609
+
610
+ commitAnchor = null;
611
+ commitSelection = null;
612
+
613
+ if (data.mode === 'range' && data.range) {
614
+ commitContext = await fetchCommitContext(data.repoId, 'comparison', {
615
+ base: data.range.base, head: data.range.head, range: data.range
616
+ });
617
+ } else if (data.mode === 'lastCommit' && data.range) {
618
+ commitContext = await fetchCommitContext(data.repoId, 'history', { head: data.range.head });
619
+ // The last commit is itself one of the listed commits, so it is shown as
620
+ // the selected one, and shift-clicking further down selects "since then".
621
+ commitSelection = { from: data.range.head, to: data.range.head };
622
+ commitAnchor = data.range.head;
623
+ } else {
624
+ commitContext = null;
625
+ }
626
+ }
627
+
628
+ /** Draw the commits list, marking the ones the review is of. */
629
+ function renderCommitList() {
630
+ const section = document.getElementById('commitsSection');
631
+ const list = document.getElementById('commitsList');
632
+ const title = document.getElementById('commitsTitle');
633
+ const count = document.getElementById('commitCount');
634
+ const truncated = document.getElementById('commitsTruncated');
635
+
636
+ if (!commitContext || commitContext.commits.length === 0) {
637
+ section.classList.add('hidden');
638
+ return;
639
+ }
640
+
641
+ section.classList.remove('hidden');
642
+ title.textContent = commitContext.kind === 'comparison'
643
+ ? 'Commits in this comparison'
644
+ : `Recent commits${currentRefs?.current ? ` on ${currentRefs.current}` : ''}`;
645
+ count.textContent = String(commitContext.total);
646
+ truncated.classList.toggle('hidden', !commitContext.truncated);
647
+ truncated.textContent = commitContext.truncated
648
+ ? `Showing ${commitContext.commits.length} of ${commitContext.total}. Anything further back is still reachable by typing it into Compare.`
649
+ : '';
650
+
651
+ // Which rows are in the review: everything from `from` to `to` in time,
652
+ // or every row when there is no selection.
653
+ const chrono = chronologicalCommits(commitContext);
654
+ const inRun = new Set();
655
+ if (commitSelection) {
656
+ const i = chrono.findIndex(c => c.sha === commitSelection.from);
657
+ const j = chrono.findIndex(c => c.sha === commitSelection.to);
658
+ if (i !== -1 && j !== -1) chrono.slice(Math.min(i, j), Math.max(i, j) + 1).forEach(c => inRun.add(c.sha));
659
+ } else {
660
+ chrono.forEach(c => inRun.add(c.sha));
661
+ }
662
+
663
+ // Built from nodes: subjects and author names are whatever the repository
664
+ // says, and they are shown, never parsed as markup.
665
+ const rows = commitContext.commits.map(commit => {
666
+ const item = document.createElement('li');
667
+ item.className = 'commit-item';
668
+ item.dataset.sha = commit.sha;
669
+ item.tabIndex = 0;
670
+ item.setAttribute('role', 'option');
671
+ const selected = inRun.has(commit.sha) && commitSelection !== null;
672
+ item.setAttribute('aria-selected', String(selected));
673
+ if (selected) item.classList.add('commit-item-selected');
674
+ item.title = `${commit.subject}\n${commit.short} · ${commit.author} · ${commit.when}${commit.merge ? ' · merge' : ''}`;
675
+
676
+ const sha = document.createElement('span');
677
+ sha.className = 'commit-sha';
678
+ sha.textContent = commit.short;
679
+ const subject = document.createElement('span');
680
+ subject.className = 'commit-subject';
681
+ subject.textContent = commit.subject;
682
+ const when = document.createElement('span');
683
+ when.className = 'commit-when';
684
+ when.textContent = compactWhen(commit.when);
685
+
686
+ item.append(sha, subject);
687
+ if (commit.merge) {
688
+ const merge = document.createElement('span');
689
+ merge.className = 'commit-merge';
690
+ merge.textContent = 'merge';
691
+ item.append(merge);
692
+ }
693
+ item.append(when);
694
+ return item;
695
+ });
696
+ list.replaceChildren(...rows);
697
+
698
+ const current = list.querySelector('.commit-item-selected');
699
+ if (current) current.scrollIntoView({ block: 'nearest' });
700
+ }
701
+
702
+ /**
703
+ * Review one commit, or -- with shift -- the run from the anchor to it.
704
+ *
705
+ * @param {string} sha
706
+ * @param {boolean} extend
707
+ */
708
+ function selectCommit(sha, extend) {
709
+ if (!commitContext) return;
710
+
711
+ if (extend && commitAnchor) {
712
+ const run = orderedRun(chronologicalCommits(commitContext), commitAnchor, sha);
713
+ if (run) loadRepo({ commits: { from: run[0], to: run[1] } });
714
+ return;
715
+ }
716
+
717
+ commitAnchor = sha;
718
+ loadRepo({ commits: { from: sha, to: sha } });
719
+ }
720
+
721
+ /** Step one commit older (-1) or newer (+1): commit-by-commit review. */
722
+ function stepCommit(direction) {
723
+ if (!commitContext) return;
724
+ const chrono = chronologicalCommits(commitContext);
725
+ const single = commitSelection && commitSelection.from === commitSelection.to
726
+ ? chrono.findIndex(c => c.sha === commitSelection.from)
727
+ : -1;
728
+ const next = stepIndex(chrono.length, single, direction);
729
+ if (next !== -1) selectCommit(chrono[next].sha, false);
730
+ }
731
+
732
+ /** Back from one commit to the whole comparison it was part of. */
733
+ function showAllCommits() {
734
+ if (commitContext?.kind !== 'comparison') return;
735
+ // By the names that were picked, so the bar still says main -> feature/auth
736
+ // rather than two SHAs.
737
+ const { range } = commitContext;
738
+ loadRepo({ base: range.baseName ?? range.base, head: range.headName ?? range.head });
739
+ }
740
+
418
741
  /** Empty the diff pane, so a finished comparison cannot leave the last one's file on screen. */
419
742
  function clearCodePane() {
420
743
  currentFile = null;
@@ -435,9 +758,10 @@ function showEmptyFileList(message) {
435
758
  /**
436
759
  * Load a repository, optionally as a range between two commits.
437
760
  *
438
- * @param {{base?: string, head?: string}} [scope] omit for the default:
439
- * uncommitted changes, falling back to the last commit on a clean tree.
440
- * `head` defaults to HEAD on the server.
761
+ * @param {{base?: string, head?: string, commits?: {from: string, to: string}}} [scope]
762
+ * omit for the default: uncommitted changes, falling back to the last
763
+ * commit on a clean tree. `base`/`head` compare two refs from their merge
764
+ * base; `commits` reviews one commit or an unbroken run of them, inclusive.
441
765
  */
442
766
  async function loadRepo(scope = {}) {
443
767
  const repoPath = document.getElementById('repoPath').value.trim();
@@ -454,9 +778,11 @@ async function loadRepo(scope = {}) {
454
778
  const response = await fetch(`${API_BASE}/load-repo`, {
455
779
  method: 'POST',
456
780
  headers: { 'Content-Type': 'application/json' },
457
- body: JSON.stringify(scope.base
458
- ? { repoPath, base: scope.base, ...(scope.head ? { head: scope.head } : {}) }
459
- : { repoPath })
781
+ body: JSON.stringify(
782
+ scope.commits ? { repoPath, commits: scope.commits }
783
+ : scope.base ? { repoPath, base: scope.base, ...(scope.head ? { head: scope.head } : {}) }
784
+ : { repoPath }
785
+ )
460
786
  });
461
787
 
462
788
  const data = await response.json();
@@ -476,10 +802,14 @@ async function loadRepo(scope = {}) {
476
802
  setRepoButton(data.repoPath);
477
803
  }
478
804
 
479
- // Before renderScope, which needs the options present to select the
480
- // current ends among them, and the current branch to name it.
481
- await loadRefs(data.repoId);
805
+ // In this order: the commits list first, because it decides which head
806
+ // the pickers' commits should follow; the refs next, because renderScope
807
+ // needs the options present to select the current ends among them and
808
+ // the current branch to name it; then the bar and the list.
809
+ await refreshCommitContext(data, scope);
810
+ await loadRefs(data.repoId, commitContext?.head ?? data.range?.head);
482
811
  renderScope(data);
812
+ renderCommitList();
483
813
 
484
814
  if (data.files.length === 0) {
485
815
  // A range with no files is a real answer, not a failure: every change
@@ -493,7 +823,11 @@ async function loadRepo(scope = {}) {
493
823
  clearStatus();
494
824
  hidePicker();
495
825
  displayFiles([]);
496
- showEmptyFileList('No files differ between these two.');
826
+ showEmptyFileList(data.range?.kind !== 'commits'
827
+ ? 'No files differ between these two.'
828
+ : data.range.first === data.range.last
829
+ ? 'This commit changes no files.'
830
+ : 'These commits change no files between them.');
497
831
  updateCommentsSidebar();
498
832
  // Or the previous comparison's file stays on screen under a bar that
499
833
  // says there are no files, and reads as part of this one.
@@ -2082,7 +2416,11 @@ function getKeyboardShortcut(key, targetTagName, modifiers = {}) {
2082
2416
  // must be checked before the typing guard.
2083
2417
  if (key === 'Escape') return 'escape';
2084
2418
 
2085
- if (targetTagName === 'INPUT' || targetTagName === 'TEXTAREA') {
2419
+ // SELECT as well as the text fields. With focus in one of the compare
2420
+ // pickers, a letter is the browser's type-ahead -- "c" jumps to the first
2421
+ // option starting with c -- and it also opened a comment box, because only
2422
+ // INPUT and TEXTAREA were excluded here when the pickers arrived in 2.12.
2423
+ if (targetTagName === 'INPUT' || targetTagName === 'TEXTAREA' || targetTagName === 'SELECT') {
2086
2424
  return null;
2087
2425
  }
2088
2426
 
@@ -2094,6 +2432,8 @@ function getKeyboardShortcut(key, targetTagName, modifiers = {}) {
2094
2432
  'n': 'nextComment',
2095
2433
  'p': 'prevComment',
2096
2434
  'c': 'commentFocus',
2435
+ '[': 'prevCommit',
2436
+ ']': 'nextCommit',
2097
2437
  '?': 'help'
2098
2438
  };
2099
2439
 
@@ -2132,6 +2472,25 @@ document.addEventListener('DOMContentLoaded', () => {
2132
2472
  }
2133
2473
  });
2134
2474
 
2475
+ // The commits list: click reviews one, shift-click the run to it. Enter
2476
+ // and shift+Enter do the same from the keyboard, since every row is
2477
+ // focusable.
2478
+ const commitsList = document.getElementById('commitsList');
2479
+ commitsList.addEventListener('click', (e) => {
2480
+ const item = e.target.closest('.commit-item');
2481
+ if (!item) return;
2482
+ // Shift-click also extends the browser's text selection across the rows
2483
+ // it passes; that is noise here, not a gesture anyone meant.
2484
+ if (e.shiftKey) window.getSelection()?.removeAllRanges();
2485
+ selectCommit(item.dataset.sha, e.shiftKey);
2486
+ });
2487
+ commitsList.addEventListener('keydown', (e) => {
2488
+ const item = e.target.closest('.commit-item');
2489
+ if (!item || e.key !== 'Enter') return;
2490
+ e.preventDefault();
2491
+ selectCommit(item.dataset.sha, e.shiftKey);
2492
+ });
2493
+
2135
2494
  for (const end of ['base', 'head']) {
2136
2495
  document.getElementById(PICKERS[end].other).addEventListener('keypress', (e) => {
2137
2496
  if (e.key === 'Enter') {
@@ -2163,6 +2522,12 @@ document.addEventListener('DOMContentLoaded', () => {
2163
2522
  } else if (action === 'commentFocus') {
2164
2523
  e.preventDefault();
2165
2524
  handleCommentShortcut();
2525
+ } else if (action === 'prevCommit') {
2526
+ e.preventDefault();
2527
+ stepCommit(-1);
2528
+ } else if (action === 'nextCommit') {
2529
+ e.preventDefault();
2530
+ stepCommit(1);
2166
2531
  } else if (action === 'help') {
2167
2532
  e.preventDefault();
2168
2533
  document.getElementById('helpButton').click();
package/public/index.html CHANGED
@@ -62,7 +62,16 @@
62
62
  <span id="scopeMeta" class="scope-meta"></span>
63
63
  </div>
64
64
  <div class="scope-actions">
65
- <button id="compareToggle" class="scope-action" onclick="openCompare()">Compare branches</button>
65
+ <div id="commitNav" class="commit-nav hidden" role="group" aria-label="Step through commits">
66
+ <button id="prevCommitBtn" class="scope-action" onclick="stepCommit(-1)"
67
+ title="Older commit ( [ )" aria-label="Older commit">&#8249;</button>
68
+ <span id="commitPosition" class="commit-position"></span>
69
+ <button id="nextCommitBtn" class="scope-action" onclick="stepCommit(1)"
70
+ title="Newer commit ( ] )" aria-label="Newer commit">&#8250;</button>
71
+ </div>
72
+ <button id="allCommitsBtn" class="scope-action hidden" onclick="showAllCommits()">All commits</button>
73
+ <button id="compareToggle" class="scope-action" onclick="openCompare()"
74
+ title="Compare two branches, tags or commits">Compare</button>
66
75
  <button id="scopeResetBtn" class="scope-action hidden" onclick="resetScope()">Stop comparing</button>
67
76
  </div>
68
77
  </div>
@@ -186,7 +195,7 @@
186
195
  <span class="help-keys">
187
196
  <kbd>⌘</kbd><kbd>Enter</kbd> save · <kbd>Esc</kbd> cancel ·
188
197
  <kbd>↑</kbd><kbd>↓</kbd><kbd>j</kbd><kbd>k</kbd> files ·
189
- <kbd>n</kbd><kbd>p</kbd> comments · <kbd>c</kbd> new comment · <kbd>?</kbd> help
198
+ <kbd>n</kbd><kbd>p</kbd> comments · <kbd>[</kbd><kbd>]</kbd> commits · <kbd>c</kbd> new comment · <kbd>?</kbd> help
190
199
  </span>
191
200
  <button class="help-done" onclick="closeHelp()">Got it</button>
192
201
  </div>
@@ -194,6 +203,20 @@
194
203
  </div>
195
204
 
196
205
  <aside id="sidebar" class="sidebar hidden">
206
+ <!-- The commits a review is made of, when it is made of commits. Click
207
+ one to review it on its own; shift-click a second to review the run
208
+ between them -- the same gesture a pull request's commit picker
209
+ uses, so it does not have to be learned twice. -->
210
+ <section id="commitsSection" class="commits-section hidden" aria-labelledby="commitsTitle">
211
+ <div class="sidebar-header">
212
+ <h2 id="commitsTitle">Commits</h2>
213
+ <span id="commitCount" class="file-count"></span>
214
+ </div>
215
+ <p class="commits-hint">Click one · <kbd>Shift</kbd>-click a run · <kbd>[</kbd><kbd>]</kbd> step</p>
216
+ <ol id="commitsList" class="commits-list" role="listbox" aria-label="Commits" aria-multiselectable="true"></ol>
217
+ <p id="commitsTruncated" class="commits-truncated hidden"></p>
218
+ </section>
219
+
197
220
  <div class="sidebar-header">
198
221
  <h2>Changed Files</h2>
199
222
  <span id="fileCount" class="file-count"></span>
package/public/style.css CHANGED
@@ -2072,3 +2072,147 @@ button:disabled {
2072
2072
  letter-spacing: 0.04em;
2073
2073
  color: #6a737d;
2074
2074
  }
2075
+
2076
+ /* ---------------------------------------------------------------------------
2077
+ * Commits: the ones a review is made of, above the files they touch.
2078
+ * ------------------------------------------------------------------------ */
2079
+
2080
+ .commits-section {
2081
+ border-bottom: 1px solid #e1e4e8;
2082
+ }
2083
+
2084
+ .commits-hint {
2085
+ margin: 0;
2086
+ padding: 0 20px 8px;
2087
+ font-size: 11px;
2088
+ color: #6a737d;
2089
+ }
2090
+
2091
+ .commits-hint kbd {
2092
+ font-size: 10px;
2093
+ padding: 0 4px;
2094
+ border: 1px solid #d1d5da;
2095
+ border-bottom-width: 2px;
2096
+ border-radius: 3px;
2097
+ background: #fafbfc;
2098
+ }
2099
+
2100
+ /* A bounded height, so a branch of forty commits does not push the files it
2101
+ * touches off the bottom of the sidebar. */
2102
+ .commits-list {
2103
+ list-style: none;
2104
+ margin: 0;
2105
+ padding: 0 8px 8px;
2106
+ max-height: 34vh;
2107
+ overflow-y: auto;
2108
+ }
2109
+
2110
+ .commit-item {
2111
+ display: flex;
2112
+ align-items: baseline;
2113
+ gap: 8px;
2114
+ padding: 6px 10px;
2115
+ margin: 2px 0;
2116
+ border-radius: 6px;
2117
+ border-left: 3px solid transparent;
2118
+ font-size: 12px;
2119
+ cursor: pointer;
2120
+ user-select: none;
2121
+ }
2122
+
2123
+ .commit-item:hover {
2124
+ background: #f3f4f6;
2125
+ }
2126
+
2127
+ .commit-item:focus-visible {
2128
+ outline: 2px solid #0366d6;
2129
+ outline-offset: -2px;
2130
+ }
2131
+
2132
+ .commit-item-selected {
2133
+ background: #f1f8ff;
2134
+ border-left-color: #0366d6;
2135
+ }
2136
+
2137
+ .commit-sha {
2138
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
2139
+ font-size: 11px;
2140
+ color: #0366d6;
2141
+ flex-shrink: 0;
2142
+ }
2143
+
2144
+ .commit-subject {
2145
+ flex: 1;
2146
+ min-width: 0;
2147
+ overflow: hidden;
2148
+ text-overflow: ellipsis;
2149
+ white-space: nowrap;
2150
+ color: #24292e;
2151
+ }
2152
+
2153
+ .commit-when {
2154
+ flex-shrink: 0;
2155
+ font-size: 11px;
2156
+ color: #6a737d;
2157
+ }
2158
+
2159
+ .commit-merge {
2160
+ flex-shrink: 0;
2161
+ font-size: 10px;
2162
+ font-weight: 600;
2163
+ text-transform: uppercase;
2164
+ color: #6f42c1;
2165
+ }
2166
+
2167
+ .commits-truncated {
2168
+ margin: 0;
2169
+ padding: 0 20px 8px;
2170
+ font-size: 11px;
2171
+ color: #6a737d;
2172
+ }
2173
+
2174
+ /* Stepping one commit at a time, from the scope bar. */
2175
+ .commit-nav {
2176
+ display: flex;
2177
+ align-items: center;
2178
+ gap: 4px;
2179
+ }
2180
+
2181
+ .commit-nav .scope-action {
2182
+ padding: 5px 10px;
2183
+ font-size: 15px;
2184
+ line-height: 1;
2185
+ }
2186
+
2187
+ .commit-nav .scope-action:disabled {
2188
+ opacity: 0.4;
2189
+ cursor: default;
2190
+ }
2191
+
2192
+ .commit-nav .scope-action:disabled:hover {
2193
+ background: #fff;
2194
+ border-color: #d1d5da;
2195
+ }
2196
+
2197
+ .commit-position {
2198
+ min-width: 0;
2199
+ font-size: 12px;
2200
+ color: #586069;
2201
+ white-space: nowrap;
2202
+ }
2203
+
2204
+ .commit-position:empty {
2205
+ display: none;
2206
+ }
2207
+
2208
+ /* A commit message keeps its own line breaks; it is prose someone wrote in
2209
+ * paragraphs, and reflowing it into one line loses its lists. */
2210
+ .scope-note-message {
2211
+ white-space: pre-line;
2212
+ font-size: 13px;
2213
+ color: #24292e;
2214
+ padding-left: 10px;
2215
+ border-left: 3px solid #d1d5da;
2216
+ max-height: 8.5em;
2217
+ overflow-y: auto;
2218
+ }
package/server.js CHANGED
@@ -73,6 +73,11 @@ const COMMIT_LIST_LIMIT = 30;
73
73
  // cut are the least recently touched and are still reachable by typing.
74
74
  const REF_LIST_LIMIT = 40;
75
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
+
76
81
  function createApp(options = {}) {
77
82
  const {
78
83
  reviewsDir = resolveReviewsDir(),
@@ -321,6 +326,90 @@ function createApp(options = {}) {
321
326
  };
322
327
  }
323
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
+ }
383
+
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
+
324
413
  /**
325
414
  * The changed files of a range, with the status git gives each one.
326
415
  *
@@ -382,9 +471,9 @@ function createApp(options = {}) {
382
471
  * @param {import('simple-git').SimpleGit} git
383
472
  * @param {number} limit
384
473
  */
385
- async function recentCommits(git, limit) {
474
+ async function recentCommits(git, limit, head = 'HEAD') {
386
475
  const raw = await git.raw([
387
- '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, '--'
388
477
  ]);
389
478
 
390
479
  return raw
@@ -396,13 +485,6 @@ function createApp(options = {}) {
396
485
  });
397
486
  }
398
487
 
399
- /**
400
- * The commits of an open repository, newest first.
401
- *
402
- * Behind a session id like the file endpoints rather than taking a path:
403
- * this reads history out of a repository, and the session is what says
404
- * which repository the caller has already been granted.
405
- */
406
488
  /**
407
489
  * Everything the compare pickers offer: branches, tags and recent commits.
408
490
  *
@@ -445,11 +527,18 @@ function createApp(options = {}) {
445
527
  };
446
528
 
447
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
+
448
537
  const [branches, remotes, tags, commits] = await Promise.all([
449
538
  refList('refs/heads', REF_LIST_LIMIT),
450
539
  refList('refs/remotes', REF_LIST_LIMIT),
451
540
  refList('refs/tags', REF_LIST_LIMIT),
452
- recentCommits(git, COMMIT_LIST_LIMIT).catch(() => [])
541
+ recentCommits(git, COMMIT_LIST_LIMIT, head).catch(() => [])
453
542
  ]);
454
543
 
455
544
  res.json({
@@ -463,6 +552,77 @@ function createApp(options = {}) {
463
552
  });
464
553
  });
465
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.
621
+ *
622
+ * Behind a session id like the file endpoints rather than taking a path:
623
+ * this reads history out of a repository, and the session is what says
624
+ * which repository the caller has already been granted.
625
+ */
466
626
  app.get('/api/commits/:repoId', async (req, res) => {
467
627
  try {
468
628
  const session = requireSession(res, req.params.repoId);
@@ -608,7 +768,7 @@ function createApp(options = {}) {
608
768
  // Resolve to the root of the working tree before anything is keyed on
609
769
  // this path. A subdirectory passes `checkIsRepo()` quite happily and
610
770
  // then produces a file list whose diffs are all empty; see lib/repo.js.
611
- const { base, head } = req.body ?? {};
771
+ const { base, head, commits: run } = req.body ?? {};
612
772
  const root = await repoRoot(repoPath, gitFactory);
613
773
  if (!root) {
614
774
  return res.status(400).json({ error: 'Not a valid git repository' });
@@ -621,7 +781,18 @@ function createApp(options = {}) {
621
781
  let range = null;
622
782
  let message;
623
783
 
624
- 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() !== '') {
625
796
  // An explicit range. Nothing falls back to anything here: someone who
626
797
  // asked for x1..x3 and is silently shown their working tree instead
627
798
  // has been told a lie about what they are reviewing.