git-reviewer 2.11.0 → 2.12.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/public/style.css CHANGED
@@ -1878,89 +1878,197 @@ button:disabled {
1878
1878
  text-align: center;
1879
1879
  }
1880
1880
 
1881
- /* What the review is of, and how to change it.
1881
+ /* ---------------------------------------------------------------------------
1882
+ * What the review is of, and how to change it.
1882
1883
  *
1883
- * Its own row under the status line rather than beside the repository
1884
- * controls: choosing a repository and choosing which of its commits to look
1885
- * at are two different decisions, and crowding them made the header read as
1886
- * one long form. */
1884
+ * Its own block under the status line rather than beside the repository
1885
+ * controls: choosing a repository and choosing which two points of it to look
1886
+ * between are different decisions, and crowding them into one row made the
1887
+ * header read as one long form.
1888
+ * ------------------------------------------------------------------------ */
1889
+
1887
1890
  .scope-bar {
1891
+ margin-top: 8px;
1892
+ padding: 10px 12px;
1893
+ border-radius: 6px;
1894
+ background: #f6f8fa;
1895
+ border: 1px solid #e1e4e8;
1896
+ font-size: 13px;
1897
+ color: #24292e;
1898
+ }
1899
+
1900
+ .scope-summary {
1888
1901
  display: flex;
1889
1902
  align-items: center;
1890
1903
  justify-content: space-between;
1891
1904
  flex-wrap: wrap;
1892
1905
  gap: 8px 16px;
1893
- margin-top: 8px;
1894
- padding: 8px 12px;
1895
- border-radius: 6px;
1896
- background: #f3f4f6;
1897
- border-left: 3px solid #6a737d;
1898
- font-size: 13px;
1899
1906
  }
1900
1907
 
1901
- /* The scope reads as a sentence rather than a field label, because the case
1902
- * that matters most is "9 commits, and no net change" — which has to be
1903
- * legible as an explanation, not as a value. */
1904
- .scope-label {
1905
- color: #24292e;
1908
+ /* The scope reads as a sentence -- "main → feature/auth" -- rather than as a
1909
+ * labelled field, because the names are the information. */
1910
+ .scope-text {
1911
+ display: flex;
1912
+ align-items: baseline;
1913
+ flex-wrap: wrap;
1914
+ gap: 4px 12px;
1906
1915
  min-width: 0;
1907
1916
  }
1908
1917
 
1909
- .scope-compare {
1918
+ .scope-label {
1919
+ font-size: 14px;
1920
+ overflow-wrap: anywhere;
1921
+ }
1922
+
1923
+ .scope-label strong {
1924
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1925
+ font-size: 13px;
1926
+ font-weight: 600;
1927
+ padding: 1px 6px;
1928
+ border-radius: 4px;
1929
+ background: #fff;
1930
+ border: 1px solid #d1d5da;
1931
+ }
1932
+
1933
+ .scope-meta {
1934
+ color: #586069;
1935
+ }
1936
+
1937
+ .scope-actions {
1910
1938
  display: flex;
1911
- align-items: center;
1912
1939
  gap: 8px;
1913
1940
  flex-shrink: 0;
1914
1941
  }
1915
1942
 
1916
- .scope-compare label {
1917
- color: #6a737d;
1918
- white-space: nowrap;
1943
+ /* Secondary buttons. The :hover rule is not optional: `button:hover` sets the
1944
+ * primary blue and outranks a single class on specificity, so a secondary
1945
+ * button without its own hover rule turns into dark text on dark blue the
1946
+ * moment the pointer reaches it. That shipped once, on "Back to latest". */
1947
+ .scope-action,
1948
+ .compare-swap {
1949
+ background: #fff;
1950
+ color: #24292e;
1951
+ border: 1px solid #d1d5da;
1952
+ padding: 5px 12px;
1953
+ font-size: 13px;
1919
1954
  }
1920
1955
 
1921
- .scope-compare input {
1922
- width: 190px;
1923
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1924
- font-size: 12px;
1956
+ .scope-action:hover,
1957
+ .compare-swap:hover {
1958
+ background: #f3f4f6;
1959
+ border-color: #b8bec6;
1925
1960
  }
1926
1961
 
1927
- .scope-reset {
1928
- background: transparent;
1929
- border: 1px solid #d1d5da;
1930
- color: #24292e;
1962
+ .scope-action:focus-visible,
1963
+ .compare-swap:focus-visible,
1964
+ .compare-end select:focus-visible,
1965
+ .compare-other:focus-visible {
1966
+ outline: 2px solid #0366d6;
1967
+ outline-offset: 1px;
1931
1968
  }
1932
1969
 
1933
- @media (max-width: 720px) {
1934
- .scope-compare input {
1935
- width: 120px;
1936
- }
1970
+ .compare-row {
1971
+ display: flex;
1972
+ align-items: flex-end;
1973
+ flex-wrap: wrap;
1974
+ gap: 8px 10px;
1975
+ margin-top: 10px;
1976
+ padding-top: 10px;
1977
+ border-top: 1px solid #e1e4e8;
1937
1978
  }
1938
1979
 
1939
- /* The picker carries the common case; the text box beside it carries
1940
- * everything else — a branch, a tag, `HEAD~5`, a sha from somewhere else.
1941
- * Both stay visible rather than one hiding behind a mode, because a control
1942
- * you cannot see is a feature people believe is missing. */
1943
- .scope-compare select {
1944
- max-width: 320px;
1945
- font-size: 12px;
1946
- padding: 4px 6px;
1980
+ .compare-end {
1981
+ display: flex;
1982
+ flex-direction: column;
1983
+ gap: 4px;
1984
+ flex: 1 1 240px;
1985
+ min-width: 0;
1986
+ }
1987
+
1988
+ .compare-end label {
1989
+ font-size: 11px;
1990
+ font-weight: 600;
1991
+ text-transform: uppercase;
1992
+ letter-spacing: 0.04em;
1993
+ color: #586069;
1994
+ }
1995
+
1996
+ .compare-end select,
1997
+ .compare-other {
1998
+ width: 100%;
1999
+ min-width: 0;
2000
+ font-size: 13px;
2001
+ padding: 6px 8px;
1947
2002
  border: 1px solid #d1d5da;
1948
2003
  border-radius: 6px;
1949
2004
  background: #fff;
1950
2005
  color: #24292e;
1951
2006
  }
1952
2007
 
1953
- .scope-compare select:disabled {
1954
- color: #6a737d;
1955
- background: #f3f4f6;
2008
+ .compare-other {
2009
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
2010
+ font-size: 12px;
1956
2011
  }
1957
2012
 
2013
+ .compare-swap {
2014
+ flex: 0 0 auto;
2015
+ font-size: 16px;
2016
+ line-height: 1;
2017
+ padding: 7px 10px;
2018
+ }
2019
+
2020
+ /* Why the file list looks the way it does. Its own line, in the same quiet
2021
+ * voice as the meta text: this is explanation, not a warning. An empty
2022
+ * comparison is a real answer, and it must not be dressed as an error. */
2023
+ .scope-note {
2024
+ margin: 10px 0 0;
2025
+ color: #586069;
2026
+ line-height: 1.45;
2027
+ }
2028
+
2029
+ .files-empty {
2030
+ padding: 16px 12px;
2031
+ color: #586069;
2032
+ font-size: 13px;
2033
+ line-height: 1.45;
2034
+ }
2035
+
2036
+ /* Narrow screens: the two ends stack, and the swap button sits between them
2037
+ * rather than overflowing the bar -- which the single-row version did, at
2038
+ * 760px, with the Compare button hanging off the right edge. */
1958
2039
  @media (max-width: 720px) {
1959
- .scope-compare {
1960
- flex-wrap: wrap;
2040
+ .compare-row {
2041
+ flex-direction: column;
2042
+ align-items: stretch;
1961
2043
  }
1962
2044
 
1963
- .scope-compare select {
1964
- max-width: 100%;
2045
+ .compare-end {
2046
+ flex: 1 1 auto;
1965
2047
  }
2048
+
2049
+ .compare-swap {
2050
+ align-self: center;
2051
+ transform: rotate(90deg);
2052
+ }
2053
+
2054
+ .scope-actions {
2055
+ width: 100%;
2056
+ }
2057
+ }
2058
+
2059
+ /* A comment on a file that is not in the current comparison. Kept, not
2060
+ * lost -- dimmed so it reads as "elsewhere", with a line saying so, because a
2061
+ * sidebar entry that looks attached but goes nowhere when clicked reads as a
2062
+ * bug. */
2063
+ .comment-item-elsewhere {
2064
+ opacity: 0.72;
2065
+ }
2066
+
2067
+ .comment-item-elsewhere-note {
2068
+ margin-top: 6px;
2069
+ font-size: 11px;
2070
+ font-weight: 600;
2071
+ text-transform: uppercase;
2072
+ letter-spacing: 0.04em;
2073
+ color: #6a737d;
1966
2074
  }
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,11 @@ 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
+
71
76
  function createApp(options = {}) {
72
77
  const {
73
78
  reviewsDir = resolveReviewsDir(),
@@ -128,10 +133,18 @@ function createApp(options = {}) {
128
133
  const absolutePath = resolveRepoFile(session.repoPath, filePath);
129
134
 
130
135
  if (session.range) {
136
+ // A range is two commits, and the working tree is neither of them. It
137
+ // used to be the fallback here, which was merely odd while the head of
138
+ // every range was HEAD and is wrong now that it can be another branch:
139
+ // Full Context on feature/billing, opened from a checkout of
140
+ // feature/auth, would show auth's copy of the file under billing's name.
141
+ //
142
+ // A path missing at the head was deleted inside the range, and the text
143
+ // worth showing is the text that was deleted.
131
144
  try {
132
145
  return await git.show([`${session.range.head}:${filePath}`]);
133
146
  } catch {
134
- return await fs.readFile(absolutePath, 'utf-8').catch(() => '');
147
+ return git.show([`${session.range.from}:${filePath}`]).catch(() => '');
135
148
  }
136
149
  }
137
150
 
@@ -205,7 +218,7 @@ function createApp(options = {}) {
205
218
 
206
219
  async function diffForFile(git, session, filePath) {
207
220
  if (session.range) {
208
- return git.diff([session.range.base, session.range.head, '--', filePath]);
221
+ return git.diff([session.range.from, session.range.head, '--', filePath]);
209
222
  }
210
223
  if (!(await hasCommits(git))) {
211
224
  return '';
@@ -251,7 +264,7 @@ function createApp(options = {}) {
251
264
  }
252
265
  }
253
266
 
254
- const resolve = async (label, ref) => {
267
+ const resolve = async ref => {
255
268
  try {
256
269
  return (await git.revparse([`${ref}^{commit}`])).trim();
257
270
  } catch {
@@ -259,19 +272,67 @@ function createApp(options = {}) {
259
272
  }
260
273
  };
261
274
 
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()
275
+ const baseSha = await resolve(base);
276
+ const headSha = await resolve(head);
277
+
278
+ // The diff starts at the merge base, not at `base` itself -- what a pull
279
+ // request shows, and for the same reason. Compare feature/auth against a
280
+ // main that has moved on since the branch was cut, and a plain
281
+ // `git diff main feature/auth` includes main's newer commits *reversed*:
282
+ // the review then shows the feature author "undoing" a change they never
283
+ // touched, and a reviewer comments on it. On straight-line history the
284
+ // merge base of an ancestor and its descendant is the ancestor, so this
285
+ // changes nothing for x1..x5.
286
+ //
287
+ // Unrelated histories have no merge base; the plain two-ended diff is the
288
+ // only answer there, and it is the one given.
289
+ const from = await git.raw(['merge-base', baseSha, headSha])
290
+ .then(out => out.trim() || baseSha)
291
+ .catch(() => baseSha);
292
+
293
+ // `base..head` is what head has and base does not -- exactly the commits
294
+ // whose changes are in the diff. `head..base` is the other direction: what
295
+ // base has moved on by, which the merge-base diff deliberately leaves out
296
+ // and which the page has to say it left out, or someone will go looking
297
+ // for main's change and conclude the tool lost it.
298
+ const count = async range => Number(
299
+ (await git.raw(['rev-list', '--count', range])).trim()
272
300
  );
301
+ const commits = await count(`${baseSha}..${headSha}`);
302
+ const behind = await count(`${headSha}..${baseSha}`);
303
+
304
+ // What the person asked for, kept alongside the SHAs. Nobody chose
305
+ // `3eb57959`; they chose `main`, and a scope bar that answers in SHAs has
306
+ // translated their question into one they did not ask.
307
+ const nameOf = async ref => {
308
+ if (ref !== 'HEAD') return ref;
309
+ const branch = await git.revparse(['--abbrev-ref', 'HEAD']).then(b => b.trim()).catch(() => 'HEAD');
310
+ return branch === 'HEAD' ? headSha.slice(0, 8) : branch;
311
+ };
273
312
 
274
- return { base: baseSha, head: headSha, commits: Number.isFinite(commits) ? commits : 0 };
313
+ return {
314
+ base: baseSha,
315
+ head: headSha,
316
+ from,
317
+ commits: Number.isFinite(commits) ? commits : 0,
318
+ behind: Number.isFinite(behind) ? behind : 0,
319
+ baseName: await nameOf(base),
320
+ headName: await nameOf(head)
321
+ };
322
+ }
323
+
324
+ /**
325
+ * The changed files of a range, with the status git gives each one.
326
+ *
327
+ * @param {import('simple-git').SimpleGit} git
328
+ * @param {{from: string, head: string}} range
329
+ */
330
+ async function collectRangeChanges(git, range) {
331
+ const [summary, nameStatus] = await Promise.all([
332
+ git.diffSummary([range.from, range.head]),
333
+ git.raw(['diff', '--name-status', '-z', '-M', range.from, range.head])
334
+ ]);
335
+ return collectCommitChanges(summary, parseNameStatus(nameStatus));
275
336
  }
276
337
 
277
338
  /**
@@ -288,15 +349,20 @@ function createApp(options = {}) {
288
349
  * @returns {string}
289
350
  */
290
351
  function describeRange(range, fileCount) {
291
- const commits = `${range.commits} commit${range.commits === 1 ? '' : 's'}`;
352
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
353
+ const ends = `${range.baseName ?? range.base.slice(0, 8)} \u2192 ${range.headName ?? range.head.slice(0, 8)}`;
292
354
 
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.`;
355
+ if (fileCount > 0) {
356
+ return `${ends}: ${plural(range.commits, 'commit')}, ${plural(fileCount, 'changed file')}`;
297
357
  }
298
358
 
299
- return `${commits}, ${fileCount} changed file${fileCount === 1 ? '' : 's'} between the two ends`;
359
+ if (range.commits === 0) {
360
+ return range.behind === 0
361
+ ? `${ends}: both point at the same commit, so there is nothing between them.`
362
+ : `${ends}: ${range.headName ?? 'the head'} has no commits that ${range.baseName ?? 'the base'} does not already have.`;
363
+ }
364
+
365
+ return `${ends}: ${plural(range.commits, 'commit')}, and no net change \u2014 every change in them was undone again inside the range.`;
300
366
  }
301
367
 
302
368
  /**
@@ -337,6 +403,66 @@ function createApp(options = {}) {
337
403
  * this reads history out of a repository, and the session is what says
338
404
  * which repository the caller has already been granted.
339
405
  */
406
+ /**
407
+ * Everything the compare pickers offer: branches, tags and recent commits.
408
+ *
409
+ * Branches come first in the page because comparing branches is what people
410
+ * reach for -- "what does my branch change against main" -- and a picker
411
+ * that only lists commits of the current branch makes that the one
412
+ * comparison you cannot pick. Remote-tracking branches are included because
413
+ * `origin/main` is the base most reviews actually want, and a local `main`
414
+ * that has not been pulled in a week is a quietly wrong one.
415
+ *
416
+ * Sorted by most recent activity, so the branch you are working on and the
417
+ * one you branched from are near the top rather than alphabetised into the
418
+ * middle of forty others.
419
+ */
420
+ app.get('/api/refs/:repoId', async (req, res) => {
421
+ const session = requireSession(res, req.params.repoId);
422
+ if (!session) return;
423
+
424
+ const git = gitFactory(session.repoPath);
425
+ const refList = async (pattern, limit) => {
426
+ try {
427
+ const raw = await git.raw([
428
+ 'for-each-ref', `--count=${limit}`, '--sort=-committerdate',
429
+ '--format=%(refname:short)%00%(objectname)%00%(committerdate:relative)%00%(subject)%00%(symref)',
430
+ pattern
431
+ ]);
432
+ return raw.split('\n').filter(Boolean).map(line => {
433
+ const [name, sha, when, subject, symref] = line.split('\u0000');
434
+ return { name, sha, when, subject, symref };
435
+ })
436
+ // A symbolic ref points at another ref rather than being one --
437
+ // `origin/HEAD` in every clone. It cannot be filtered by name:
438
+ // `refname:short` abbreviates it to plain `origin`, which is how it
439
+ // first slipped into the list as a "branch".
440
+ .filter(ref => !ref.symref)
441
+ .map(({ symref, ...ref }) => ref);
442
+ } catch {
443
+ return [];
444
+ }
445
+ };
446
+
447
+ const current = await git.revparse(['--abbrev-ref', 'HEAD']).then(b => b.trim()).catch(() => null);
448
+ const [branches, remotes, tags, commits] = await Promise.all([
449
+ refList('refs/heads', REF_LIST_LIMIT),
450
+ refList('refs/remotes', REF_LIST_LIMIT),
451
+ refList('refs/tags', REF_LIST_LIMIT),
452
+ recentCommits(git, COMMIT_LIST_LIMIT).catch(() => [])
453
+ ]);
454
+
455
+ res.json({
456
+ // `HEAD` from --abbrev-ref means detached; say so rather than pretend
457
+ // there is a branch called HEAD.
458
+ current: current === 'HEAD' ? null : current,
459
+ branches,
460
+ remotes,
461
+ tags,
462
+ commits
463
+ });
464
+ });
465
+
340
466
  app.get('/api/commits/:repoId', async (req, res) => {
341
467
  try {
342
468
  const session = requireSession(res, req.params.repoId);
@@ -505,7 +631,7 @@ function createApp(options = {}) {
505
631
  return res.status(400).json({ error: error.message });
506
632
  }
507
633
 
508
- files = collectCommitChanges(await git.diffSummary([range.base, range.head]));
634
+ files = await collectRangeChanges(git, range);
509
635
  mode = 'range';
510
636
  message = describeRange(range, files.length);
511
637
  } else {
@@ -517,9 +643,7 @@ function createApp(options = {}) {
517
643
  if (files.length === 0) {
518
644
  try {
519
645
  const lastCommit = await resolveRange(git, 'HEAD~1', 'HEAD');
520
- const commitFiles = collectCommitChanges(
521
- await git.diffSummary([lastCommit.base, lastCommit.head])
522
- );
646
+ const commitFiles = await collectRangeChanges(git, lastCommit);
523
647
  if (commitFiles.length > 0) {
524
648
  files = commitFiles;
525
649
  mode = 'lastCommit';