git-reviewer 2.12.0 → 2.13.1

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/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.