git-reviewer 2.10.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/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-310-brightgreen.svg)](test/)
6
+ [![Tests](https://img.shields.io/badge/tests-335-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,6 +141,47 @@ 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
145
+
146
+ Those defaults cover the common case with nothing to set up, but a branch you
147
+ have worked on for a day is several commits deep, and the default scope only
148
+ ever shows the most recent one.
149
+
150
+ **Compare branches** in the header opens the comparison your pull request would
151
+ show. If the repository has an obvious base — `origin/main`, then `main`, then
152
+ `master` or `develop` — it compares your current branch against it straight
153
+ away; otherwise it asks. Remote branches come first because a local `main` that
154
+ has not been pulled in a week is a quietly wrong base.
155
+
156
+ Both ends are pickers: **Base** and **Compare**, the same two words a pull
157
+ request uses. Each lists branches, remote branches, tags and recent commits, so
158
+ you do not have to go and find a SHA. **Other…** at the bottom of either takes
159
+ anything else git understands — `HEAD~5`, a tag, a SHA from somewhere else. The
160
+ ⇄ button swaps them, which answers the other question: what has `main` got that
161
+ my branch has not?
162
+
163
+ **It shows what the branch changes, not everything that differs.** If `main`
164
+ has moved on since your branch was cut, a plain two-ended diff would include
165
+ `main`'s newer work *reversed*, as though your branch had undone it. The
166
+ comparison starts from where the two diverged instead, exactly as a pull
167
+ request does, and says what it left out:
168
+
169
+ ```
170
+ main → feature/auth 3 commits · 2 files changed
171
+
172
+ main has 1 newer commit that is not on feature/auth. It is left out: this shows
173
+ only what feature/auth changes since the two diverged, as a pull request would.
174
+ ```
175
+
176
+ An empty comparison explains itself rather than showing a blank list. Two
177
+ commits that add a file and then delete it produce no diff between the ends —
178
+ correctly — and that reads as *"2 commits undo each other"*, not as *"nothing
179
+ to review"*.
180
+
181
+ Comments are kept when you switch between comparisons. One on a file that is
182
+ not in the current view stays in the comments list, marked **Not in this
183
+ view**, and is back in place whenever you open a view that includes the file.
184
+
144
185
  Modified, added, deleted, renamed, and binary files are all listed, each marked
145
186
  with its git status letter. Deleted lines are commentable too — the most useful
146
187
  review note is often about the code someone removed. A file deleted outright is
@@ -264,7 +305,10 @@ The UI is a client of this; nothing is hidden from you.
264
305
  | Endpoint | Purpose |
265
306
  |---|---|
266
307
  | `GET /api/health` | Liveness, plus the number of open sessions |
267
- | `POST /api/load-repo` | Open a repository; returns a `repoId` and the changed files |
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 |
310
+ | `GET /api/commits/:repoId` | Recent commits only; kept for callers of 2.11 |
311
+ | `GET /api/alive` | Held open by the page; when the last one closes, a terminal run stops |
268
312
  | `GET /api/file/:repoId/:path` | The file's diff, as structured lines |
269
313
  | `GET /api/file-full/:repoId/:path` | Every line of the file, for context |
270
314
  | `POST /api/save-comments` | Persist the current comments |
@@ -294,7 +338,7 @@ rather than a public issue.
294
338
 
295
339
  ```bash
296
340
  npm install # 3 dependencies, no build step, ~6MB
297
- npm test # 310 tests
341
+ npm test # 335 tests
298
342
  npm run test:watch
299
343
  npm run test:coverage
300
344
  ```
package/lib/agent.js CHANGED
@@ -210,8 +210,28 @@ function describeScope({ repository, mode, range }) {
210
210
  // distinguishes "one commit of work" from "nine" once the diff has been
211
211
  // flattened -- and an agent reading a flattened diff of nine commits
212
212
  // should know that is what it is holding.
213
- const commits = `${range.commits} commit${range.commits === 1 ? '' : 's'}`;
214
- return `Reviewed: everything between ${range.base} and ${range.head} (${commits}), as a single combined diff.`;
213
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
214
+ const commits = plural(range.commits, 'commit');
215
+
216
+ // Ranges saved before 2.12 carry neither names nor a merge base. Say what
217
+ // they can support and no more.
218
+ if (!range.from || !range.baseName || !range.headName) {
219
+ return `Reviewed: everything between ${range.base} and ${range.head} (${commits}), as a single combined diff.`;
220
+ }
221
+
222
+ const ends = `${range.baseName} \u2192 ${range.headName}`;
223
+ if (range.from === range.base) {
224
+ return `Reviewed: ${ends} (${range.base}..${range.head}), ${commits} as a single combined diff.`;
225
+ }
226
+
227
+ // Diverged. The agent needs the left-out half as much as the reviewed
228
+ // half: code on the base that differs from the head is not something the
229
+ // reviewer asked anyone to change, and an agent that "fixes" it has
230
+ // reverted someone else's work.
231
+ const behind = range.behind > 0
232
+ ? ` ${range.baseName} has ${plural(range.behind, 'newer commit')} that ${range.behind === 1 ? 'is' : 'are'} deliberately not part of this review; leave that code alone.`
233
+ : '';
234
+ return `Reviewed: what ${range.headName} changes since it diverged from ${range.baseName} (merge base ${range.from}, head ${range.head}), ${commits} as a single combined diff.${behind}`;
215
235
  }
216
236
 
217
237
  if (mode === 'lastCommit') {
package/lib/changes.js CHANGED
@@ -191,12 +191,80 @@ function classifyCommitFile(file) {
191
191
  * @param {import('simple-git').DiffResult} diffSummary
192
192
  * @returns {ChangedFile[]}
193
193
  */
194
- function collectCommitChanges(diffSummary) {
194
+ /**
195
+ * Read `git diff --name-status -z` into what git itself says happened to
196
+ * each path.
197
+ *
198
+ * This exists because `classifyCommitFile` above has to guess from line
199
+ * counts, and the guess is wrong for a large share of ordinary edits: a
200
+ * change that only adds lines to an existing file reads as `A`, and one that
201
+ * only removes lines reads as `D` -- a reviewer shown "deleted" on a file
202
+ * that lost one line has been told something false by the badge meant to
203
+ * orient them. Git knows the answer; ask it.
204
+ *
205
+ * `-z` rather than the default output because the default quotes and
206
+ * octal-escapes non-ASCII paths the same way `--stat` does, and those were
207
+ * #20's bug the first time. With `-z` every field is NUL-terminated and the
208
+ * path is raw bytes, so there is nothing to unquote.
209
+ *
210
+ * Renames and copies (`R100`, `C75`) carry two paths; the second is the one
211
+ * the file now lives at, which is the one the rest of the app keys on.
212
+ *
213
+ * @param {string} raw output of `git diff --name-status -z -M <from> <to>`
214
+ * @returns {Map<string, 'A'|'M'|'D'|'R'>} path -> status
215
+ */
216
+ function parseNameStatus(raw) {
217
+ const statuses = new Map();
218
+ const fields = String(raw ?? '').split('\0');
219
+
220
+ for (let i = 0; i < fields.length; ) {
221
+ const code = fields[i];
222
+ if (!code) { i += 1; continue; }
223
+
224
+ const letter = code[0];
225
+ if (letter === 'R' || letter === 'C') {
226
+ const to = fields[i + 2];
227
+ // A copy is a new file that happens to resemble an old one; from the
228
+ // reviewer's side it is an addition.
229
+ if (to) statuses.set(to, letter === 'R' ? 'R' : 'A');
230
+ i += 3;
231
+ continue;
232
+ }
233
+
234
+ const path = fields[i + 1];
235
+ if (path) {
236
+ // T is a type change (a file became a symlink, or the reverse). The
237
+ // path still exists on both sides, so for review purposes it is a
238
+ // modification.
239
+ statuses.set(path, letter === 'A' || letter === 'D' ? letter : 'M');
240
+ }
241
+ i += 2;
242
+ }
243
+
244
+ return statuses;
245
+ }
246
+
247
+ /**
248
+ * The flat file list for a commit or range review.
249
+ *
250
+ * Paths still come from `diffSummary`, whose rename and quoting handling was
251
+ * earned the hard way (#20, #25, #64). Only the status letter comes from
252
+ * `statuses` when it is given -- which every caller in the server does. The
253
+ * count-based guess remains as the fallback for a caller that has nothing
254
+ * better, not as the answer.
255
+ *
256
+ * @param {import('simple-git').DiffResult} diffSummary
257
+ * @param {Map<string, string>} [statuses] from `parseNameStatus`
258
+ */
259
+ function collectCommitChanges(diffSummary, statuses) {
195
260
  const files = diffSummary?.files ?? [];
196
261
  return files.map(file => {
197
262
  const { path, isRename } = parseCommitFilePath(file?.file);
198
- const status = isRename ? 'R' : classifyCommitFile(file);
199
- return { path, status };
263
+ if (isRename) return { path, status: 'R' };
264
+ if (file?.binary) return { path, status: 'B' };
265
+
266
+ const known = statuses?.get(path);
267
+ return { path, status: known ?? classifyCommitFile(file) };
200
268
  });
201
269
  }
202
270
 
@@ -205,5 +273,6 @@ module.exports = {
205
273
  collectCommitChanges,
206
274
  classifyCommitFile,
207
275
  unquoteGitPath,
208
- parseCommitFilePath
276
+ parseCommitFilePath,
277
+ parseNameStatus
209
278
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-reviewer",
3
- "version": "2.10.0",
3
+ "version": "2.12.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
@@ -137,63 +137,307 @@ function shortSha(sha) {
137
137
  return typeof sha === 'string' ? sha.slice(0, 8) : '';
138
138
  }
139
139
 
140
+ /** A full sha is shortened for display; a branch or tag name is left alone. */
141
+ function displayRef(ref) {
142
+ return /^[0-9a-f]{40}$/i.test(ref ?? '') ? shortSha(ref) : (ref ?? '');
143
+ }
144
+
145
+ /** `n thing` or `n things`, instead of the `thing(s)` that reads like a form. */
146
+ function plural(n, word) {
147
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
148
+ }
149
+
150
+ // What the compare pickers can offer, from /api/refs. Refetched on every
151
+ // load, because a comparison opens a new session and branches move.
152
+ let currentRefs = null;
153
+
154
+ // The "type something else" choice at the foot of either picker.
155
+ const OTHER_REF = '__other__';
156
+
157
+ const PICKERS = {
158
+ base: { select: 'compareBaseSelect', other: 'compareBaseOther' },
159
+ head: { select: 'compareHeadSelect', other: 'compareHeadOther' }
160
+ };
161
+
140
162
  /**
141
- * Say what the review is of, above the files.
163
+ * Fetch the branches, tags and commits the pickers offer, and fill both.
142
164
  *
143
- * The empty-range case is the reason this exists. `git diff x3 x5` over work
144
- * that was added and then removed is correctly empty, and a file list showing
145
- * nothing is indistinguishable from a repository with nothing to review. The
146
- * commit count stays on screen so that an empty list reads as "9 commits, no
147
- * net change" rather than as "nothing happened".
165
+ * A failure leaves both pickers with only "Other…" in them, which still
166
+ * works; a repository whose refs will not list is not a reason to refuse to
167
+ * compare it.
168
+ */
169
+ async function loadRefs(repoId) {
170
+ try {
171
+ const response = await fetch(`${API_BASE}/refs/${repoId}`);
172
+ currentRefs = response.ok ? await response.json() : null;
173
+ } catch {
174
+ currentRefs = null;
175
+ }
176
+ fillPicker('base');
177
+ fillPicker('head');
178
+ }
179
+
180
+ /**
181
+ * Fill one picker, grouped the way people look for things.
148
182
  *
149
- * @param {{mode: string, range: ?{base: string, head: string, commits: number}}} data
183
+ * Branches first, because "my branch against main" is the comparison people
184
+ * reach for. Every label goes in through `new Option`, which sets text, not
185
+ * markup: branch names and commit subjects come from whatever repository is
186
+ * open, and a ref name may legally contain `<` and `>`.
150
187
  */
151
- function renderScope(data) {
152
- const bar = document.getElementById('scopeBar');
153
- const label = document.getElementById('scopeLabel');
154
- const reset = document.getElementById('scopeResetBtn');
188
+ function fillPicker(end) {
189
+ const select = document.getElementById(PICKERS[end].select);
190
+ const refs = currentRefs;
191
+ select.innerHTML = '';
192
+
193
+ const placeholder = new Option(end === 'base' ? 'Choose a base…' : 'Choose what to compare…', '');
194
+ placeholder.disabled = true;
195
+ select.appendChild(placeholder);
196
+
197
+ const group = (label, options) => {
198
+ if (options.length === 0) return;
199
+ const optgroup = document.createElement('optgroup');
200
+ optgroup.label = label;
201
+ options.forEach(option => optgroup.appendChild(option));
202
+ select.appendChild(optgroup);
203
+ };
155
204
 
156
- bar.classList.remove('hidden');
157
- reset.classList.toggle('hidden', data.mode !== 'range');
205
+ if (refs) {
206
+ group('Branches', refs.branches.map(branch => new Option(
207
+ `${branch.name}${branch.name === refs.current ? ' (current)' : ''} · ${branch.when}`,
208
+ branch.name
209
+ )));
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)));
212
+ group(
213
+ refs.current ? `Recent commits on ${refs.current}` : 'Recent commits',
214
+ refs.commits.map(commit => new Option(`${commit.short} · ${commit.subject} · ${commit.when}`, commit.sha))
215
+ );
216
+ }
158
217
 
159
- if (data.mode === 'range' && data.range) {
160
- const { base, head, commits } = data.range;
161
- label.textContent =
162
- `Comparing ${shortSha(base)} \u2192 ${shortSha(head)} \u2014 ` +
163
- `${commits} commit${commits === 1 ? '' : 's'}, shown as one combined diff`;
164
- document.getElementById('compareBase').value = shortSha(base);
218
+ select.appendChild(new Option('Other: a tag, HEAD~5, a sha…', OTHER_REF));
219
+ select.value = '';
220
+ }
221
+
222
+ /**
223
+ * Point a picker at a ref, by name if it is offered, by sha if the name is
224
+ * not but the commit is, and otherwise through "Other…" with the text shown.
225
+ *
226
+ * A range opened by typing `HEAD~2` resolves to a commit that may well be in
227
+ * the list; showing it selected there is more useful than a picker stuck on
228
+ * "Other" around a value that is also sitting three rows down.
229
+ */
230
+ function selectRef(end, name, sha) {
231
+ const select = document.getElementById(PICKERS[end].select);
232
+ const other = document.getElementById(PICKERS[end].other);
233
+ const offered = value => [...select.options].some(option => option.value === value);
234
+
235
+ if (name && offered(name)) {
236
+ select.value = name;
237
+ } else if (sha && offered(sha)) {
238
+ select.value = sha;
239
+ } else if (name || sha) {
240
+ select.value = OTHER_REF;
241
+ other.value = name || displayRef(sha);
242
+ other.classList.remove('hidden');
165
243
  return;
244
+ } else {
245
+ select.value = '';
246
+ }
247
+ other.classList.add('hidden');
248
+ other.value = '';
249
+ }
250
+
251
+ /** The ref one end of the comparison currently names, or '' if none yet. */
252
+ function pickedRef(end) {
253
+ const select = document.getElementById(PICKERS[end].select);
254
+ return select.value === OTHER_REF
255
+ ? document.getElementById(PICKERS[end].other).value.trim()
256
+ : select.value;
257
+ }
258
+
259
+ /** Either picker changed: compare as soon as both ends are known. */
260
+ function onPickerChange(end) {
261
+ const select = document.getElementById(PICKERS[end].select);
262
+ const other = document.getElementById(PICKERS[end].other);
263
+
264
+ if (select.value === OTHER_REF) {
265
+ other.classList.remove('hidden');
266
+ other.focus();
267
+ return; // Enter in the box compares; see the listeners in DOMContentLoaded.
166
268
  }
167
269
 
168
- label.textContent = data.mode === 'lastCommit'
169
- ? 'Reviewing the last commit'
170
- : 'Reviewing uncommitted changes';
171
- document.getElementById('compareBase').value = '';
270
+ other.classList.add('hidden');
271
+ compareNow();
172
272
  }
173
273
 
174
- /** Reload the repository as a range starting at whatever was typed. */
175
- function compareFrom() {
176
- const base = document.getElementById('compareBase').value.trim();
274
+ function compareNow() {
275
+ const base = pickedRef('base');
276
+ const head = pickedRef('head');
277
+ if (base && head) loadRepo({ base, head });
278
+ }
279
+
280
+ /** Reverse the comparison: "what does main have that my branch does not". */
281
+ function swapCompare() {
282
+ const base = pickedRef('base');
283
+ const head = pickedRef('head');
284
+ if (base && head) loadRepo({ base: head, head: base });
285
+ }
286
+
287
+ /**
288
+ * The base most people mean, if the repository makes it obvious.
289
+ *
290
+ * The remote comes first: a local `main` that has not been pulled in a week
291
+ * is a quietly wrong base, and `origin/main` is what the pull request will be
292
+ * measured against. Never the branch you are on -- comparing a branch with
293
+ * itself is always empty, and offering it as a default would make the first
294
+ * click of the feature look broken.
295
+ */
296
+ function defaultBase() {
297
+ const refs = currentRefs;
298
+ if (!refs) return null;
299
+
300
+ const known = new Set([...refs.branches, ...refs.remotes].map(ref => ref.name));
301
+ const candidates = ['origin/main', 'main', 'origin/master', 'master', 'origin/develop', 'develop'];
302
+ return candidates.find(name => known.has(name) && name !== refs.current) ?? null;
303
+ }
304
+
305
+ /**
306
+ * Open the compare row. With an obvious base, compare straight away: one
307
+ * click to the view a pull request would give you. Without one, show the
308
+ * pickers and wait -- a guessed comparison nobody asked for is worse than
309
+ * asking.
310
+ */
311
+ function openCompare() {
312
+ document.getElementById('compareRow').classList.remove('hidden');
313
+ const head = currentRefs?.current ?? 'HEAD';
314
+ const base = defaultBase();
177
315
 
178
- if (!base) {
179
- showStatus('Enter a commit, tag or branch to compare from', 'error');
316
+ if (base) {
317
+ loadRepo({ base, head });
180
318
  return;
181
319
  }
182
320
 
183
- loadRepo({ base });
321
+ selectRef('base', null, null);
322
+ selectRef('head', head, null);
323
+ document.getElementById('compareToggle').classList.add('hidden');
324
+ document.getElementById('scopeResetBtn').classList.remove('hidden');
325
+ document.getElementById(PICKERS.base.select).focus();
184
326
  }
185
327
 
186
328
  /** Back to the default scope: uncommitted changes, or the last commit. */
187
329
  function resetScope() {
188
- document.getElementById('compareBase').value = '';
330
+ document.getElementById('compareRow').classList.add('hidden');
189
331
  loadRepo();
190
332
  }
191
333
 
334
+ /**
335
+ * Say what the review is of, in the names the reviewer used.
336
+ *
337
+ * Built from DOM nodes rather than markup: branch names are chosen by
338
+ * whoever made the repository, and one may legally contain `<`.
339
+ *
340
+ * @param {{mode: string, range: ?object, files: object[]}} data
341
+ */
342
+ function renderScope(data) {
343
+ const bar = document.getElementById('scopeBar');
344
+ const label = document.getElementById('scopeLabel');
345
+ const meta = document.getElementById('scopeMeta');
346
+ const note = document.getElementById('scopeNote');
347
+ const row = document.getElementById('compareRow');
348
+ const toggle = document.getElementById('compareToggle');
349
+ const reset = document.getElementById('scopeResetBtn');
350
+
351
+ const strong = text => {
352
+ const el = document.createElement('strong');
353
+ el.textContent = text;
354
+ return el;
355
+ };
356
+ const setNote = text => {
357
+ note.textContent = text ?? '';
358
+ note.classList.toggle('hidden', !text);
359
+ };
360
+
361
+ bar.classList.remove('hidden');
362
+ label.replaceChildren();
363
+ const branch = currentRefs?.current;
364
+
365
+ if (data.mode === 'range' && data.range) {
366
+ const range = data.range;
367
+ const base = displayRef(range.baseName ?? range.base);
368
+ const head = displayRef(range.headName ?? range.head);
369
+ const files = data.files.length;
370
+
371
+ label.append(strong(base), ' → ', strong(head));
372
+ meta.textContent = files > 0
373
+ ? `${plural(range.commits, 'commit')} · ${plural(files, 'file')} changed`
374
+ : `${plural(range.commits, 'commit')} · no files differ`;
375
+
376
+ // The empty cases first: they are the ones that need explaining, and an
377
+ // empty file list with no explanation reads as the tool having failed.
378
+ if (files === 0 && range.commits === 0) {
379
+ setNote(range.behind > 0
380
+ ? `${head} has nothing that ${base} does not already have. Try swapping them to see what ${base} adds.`
381
+ : `${base} and ${head} are the same commit, so there is nothing between them.`);
382
+ } else if (files === 0) {
383
+ setNote(`Those ${plural(range.commits, 'commit')} undo each other: every change in them was reverted again before ${head}, so there is no difference left to review.`);
384
+ } else if (range.behind > 0) {
385
+ // The merge-base diff leaves these out on purpose, the way a pull
386
+ // request does. Say so, or someone goes looking for main's change and
387
+ // concludes the tool lost it.
388
+ setNote(`${base} has ${plural(range.behind, 'newer commit')} that ${range.behind === 1 ? 'is' : 'are'} not on ${head}. ${range.behind === 1 ? 'It is' : 'They are'} left out: this shows only what ${head} changes since the two diverged, as a pull request would.`);
389
+ } else {
390
+ setNote(null);
391
+ }
392
+
393
+ row.classList.remove('hidden');
394
+ selectRef('base', range.baseName, range.base);
395
+ selectRef('head', range.headName, range.head);
396
+ toggle.classList.add('hidden');
397
+ reset.classList.remove('hidden');
398
+ return;
399
+ }
400
+
401
+ if (data.mode === 'lastCommit') {
402
+ label.append('Last commit');
403
+ if (branch) label.append(' on ', strong(branch));
404
+ const tip = currentRefs?.commits?.[0];
405
+ meta.textContent = tip ? `${tip.subject} · ${tip.short}` : '';
406
+ } else {
407
+ label.append('Uncommitted changes');
408
+ if (branch) label.append(' on ', strong(branch));
409
+ meta.textContent = plural(data.files.length, 'file') + ' changed';
410
+ }
411
+
412
+ setNote(null);
413
+ row.classList.add('hidden');
414
+ toggle.classList.remove('hidden');
415
+ reset.classList.add('hidden');
416
+ }
417
+
418
+ /** Empty the diff pane, so a finished comparison cannot leave the last one's file on screen. */
419
+ function clearCodePane() {
420
+ currentFile = null;
421
+ currentDiffLines = [];
422
+ document.getElementById('codeSection').classList.add('hidden');
423
+ document.getElementById('currentFile').textContent = '';
424
+ }
425
+
426
+ /** What the file list says when a comparison has no files in it. */
427
+ function showEmptyFileList(message) {
428
+ const filesList = document.getElementById('filesList');
429
+ const empty = document.createElement('div');
430
+ empty.className = 'files-empty';
431
+ empty.textContent = message;
432
+ filesList.replaceChildren(empty);
433
+ }
434
+
192
435
  /**
193
436
  * Load a repository, optionally as a range between two commits.
194
437
  *
195
- * @param {{base?: string}} [scope] omit for the default: uncommitted changes,
196
- * falling back to the last commit on a clean tree.
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.
197
441
  */
198
442
  async function loadRepo(scope = {}) {
199
443
  const repoPath = document.getElementById('repoPath').value.trim();
@@ -210,7 +454,9 @@ async function loadRepo(scope = {}) {
210
454
  const response = await fetch(`${API_BASE}/load-repo`, {
211
455
  method: 'POST',
212
456
  headers: { 'Content-Type': 'application/json' },
213
- body: JSON.stringify(scope.base ? { repoPath, base: scope.base } : { repoPath })
457
+ body: JSON.stringify(scope.base
458
+ ? { repoPath, base: scope.base, ...(scope.head ? { head: scope.head } : {}) }
459
+ : { repoPath })
214
460
  });
215
461
 
216
462
  const data = await response.json();
@@ -230,6 +476,9 @@ async function loadRepo(scope = {}) {
230
476
  setRepoButton(data.repoPath);
231
477
  }
232
478
 
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);
233
482
  renderScope(data);
234
483
 
235
484
  if (data.files.length === 0) {
@@ -239,9 +488,16 @@ async function loadRepo(scope = {}) {
239
488
  // that covers -- so this is information, not an error, and it must not
240
489
  // be dressed as one.
241
490
  if (data.mode === 'range') {
242
- showStatus(data.message, 'success');
491
+ // The scope bar is already explaining this; a status line repeating
492
+ // it underneath is two bars saying one thing.
493
+ clearStatus();
243
494
  hidePicker();
244
495
  displayFiles([]);
496
+ showEmptyFileList('No files differ between these two.');
497
+ updateCommentsSidebar();
498
+ // Or the previous comparison's file stays on screen under a bar that
499
+ // says there are no files, and reads as part of this one.
500
+ clearCodePane();
245
501
  document.getElementById('loadBtn').disabled = false;
246
502
  return;
247
503
  }
@@ -251,9 +507,15 @@ async function loadRepo(scope = {}) {
251
507
  return;
252
508
  }
253
509
 
254
- showStatus(data.message, 'success');
510
+ // The scope bar above says what was loaded, in more useful terms than
511
+ // this line did ("Found 3 changed file(s)"), so there is nothing left
512
+ // for the status line to add on success.
513
+ clearStatus();
255
514
  hidePicker();
256
515
  displayFiles(data.files);
516
+ // Which comments are in view depends on which files are, so the sidebar
517
+ // is redrawn for every new scope, not only when saved comments arrive.
518
+ updateCommentsSidebar();
257
519
 
258
520
  // Load saved comments
259
521
  const savedComments = await loadCommentsFromBackend();
@@ -273,7 +535,7 @@ async function loadRepo(scope = {}) {
273
535
  // Show comments sidebar immediately
274
536
  updateCommentsSidebar();
275
537
 
276
- showStatus(`${data.message} - Loaded ${savedComments.length} saved comment(s)`, 'success');
538
+ showStatus(`Picked up ${plural(savedComments.length, 'saved comment')} from last time.`, 'success');
277
539
  }
278
540
 
279
541
  // Open something. A loaded repository used to sit behind an empty pane
@@ -300,6 +562,15 @@ async function loadRepo(scope = {}) {
300
562
  }
301
563
 
302
564
  // Display list of changed files
565
+ /** What each status letter means, for the badge's tooltip and screen readers. */
566
+ const FILE_STATUS_NAMES = {
567
+ A: 'Added',
568
+ M: 'Modified',
569
+ D: 'Deleted',
570
+ R: 'Renamed',
571
+ B: 'Binary'
572
+ };
573
+
303
574
  function fileStatusClass(status) {
304
575
  switch (status) {
305
576
  case 'A': return 'file-status-added';
@@ -334,7 +605,12 @@ function displayFiles(files) {
334
605
  ? `${escapeHtml(path)}/<span class="filename">${escapeHtml(filename)}</span>`
335
606
  : `<span class="filename">${escapeHtml(filename)}</span>`;
336
607
  const statusClass = fileStatusClass(status);
337
- const statusBadge = `<span class="file-status ${statusClass}">${status}</span>`;
608
+ // One letter is git's vocabulary, not everyone's. The name rides along as
609
+ // a tooltip and as the accessible label, so "D" is never the only thing
610
+ // saying that a file is gone. `status` is one of five letters the server
611
+ // produces, and escaped anyway: it is data that ends up in markup.
612
+ const statusName = FILE_STATUS_NAMES[status] ?? 'Changed';
613
+ const statusBadge = `<span class="file-status ${statusClass}" title="${statusName}" aria-label="${statusName}">${escapeHtml(status)}</span>`;
338
614
 
339
615
  // The row used to carry the file name itself, inside a string literal
340
616
  // inside the attribute the browser evaluates as JavaScript. A name with a
@@ -1325,10 +1601,19 @@ function resetApp() {
1325
1601
  // Show status message
1326
1602
  function showStatus(message, type) {
1327
1603
  const statusEl = document.getElementById('status');
1328
- statusEl.textContent = 'Status: ' + message;
1604
+ // No "Status:" prefix. The element is role="status", which is what tells a
1605
+ // screen reader what it is; sighted readers could already see that.
1606
+ statusEl.textContent = message;
1329
1607
  statusEl.className = `status ${type}`;
1330
1608
  }
1331
1609
 
1610
+ /** Hide the status line, when something else on screen is already saying it. */
1611
+ function clearStatus() {
1612
+ const statusEl = document.getElementById('status');
1613
+ statusEl.textContent = '';
1614
+ statusEl.className = 'status hidden';
1615
+ }
1616
+
1332
1617
  // Track if we're in full context mode
1333
1618
  let isFullContextMode = false;
1334
1619
  let storedFullFileLines = null;
@@ -1639,14 +1924,24 @@ function updateCommentsSidebar() {
1639
1924
  // anyway. A comment's identity here is its position in `comments`,
1640
1925
  // and that is a number. See #46.
1641
1926
  const commentIndex = comments.indexOf(comment);
1927
+ // A comment is kept across comparisons -- switch from main -> feature
1928
+ // back to the last commit and a note on a file only the branch
1929
+ // touched is still yours. It just is not on screen, and a sidebar
1930
+ // entry that looks attached but does nothing when clicked reads as
1931
+ // lost. So it says where it stands.
1932
+ const elsewhere = !currentFiles.some(f => f.path === file);
1933
+ const elsewhereHtml = elsewhere
1934
+ ? '<div class="comment-item-elsewhere-note">Not in this view</div>'
1935
+ : '';
1642
1936
  html += `
1643
- <div class="comment-item">
1937
+ <div class="comment-item${elsewhere ? ' comment-item-elsewhere' : ''}">
1644
1938
  <button class="comment-item-delete" onclick="event.stopPropagation(); deleteCommentFromSidebar(${Number(commentIndex)})" title="Delete comment">×</button>
1645
1939
  <div class="comment-item-content" onclick="jumpToComment(${Number(commentIndex)})">
1646
1940
  <div class="comment-item-file">${escapeHtml(file)}</div>
1647
1941
  <div class="comment-item-line">Line ${comment.line}</div>
1648
1942
  <div class="comment-item-text">${escapeHtml(comment.text)}</div>
1649
1943
  ${selectedHtml}
1944
+ ${elsewhereHtml}
1650
1945
  </div>
1651
1946
  </div>
1652
1947
  `;
@@ -1686,7 +1981,17 @@ function jumpToComment(commentIndex) {
1686
1981
 
1687
1982
  // Find the file index
1688
1983
  const fileIndex = currentFiles.findIndex(f => f.path === file);
1689
- if (fileIndex === -1) return;
1984
+ if (fileIndex === -1) {
1985
+ // This used to return without a word, so clicking a comment did nothing
1986
+ // and looked broken. The comment is safe; the file is just not part of
1987
+ // what is being compared right now.
1988
+ showStatus(
1989
+ `${file} is not in the files you are looking at now, so this comment cannot be shown in place. ` +
1990
+ 'It is kept, and will be back in place in any view that includes that file.',
1991
+ 'loading'
1992
+ );
1993
+ return;
1994
+ }
1690
1995
 
1691
1996
  // Load the file
1692
1997
  loadFile(file, fileIndex).then(() => {
@@ -1827,11 +2132,13 @@ document.addEventListener('DOMContentLoaded', () => {
1827
2132
  }
1828
2133
  });
1829
2134
 
1830
- document.getElementById('compareBase').addEventListener('keypress', (e) => {
1831
- if (e.key === 'Enter') {
1832
- compareFrom();
1833
- }
1834
- });
2135
+ for (const end of ['base', 'head']) {
2136
+ document.getElementById(PICKERS[end].other).addEventListener('keypress', (e) => {
2137
+ if (e.key === 'Enter') {
2138
+ compareNow();
2139
+ }
2140
+ });
2141
+ }
1835
2142
 
1836
2143
  // Initialize resize handle
1837
2144
  initResizeHandle();
package/public/index.html CHANGED
@@ -53,22 +53,42 @@
53
53
  </button>
54
54
  </div>
55
55
  </div>
56
- <div id="status" class="status"></div>
57
-
58
- <div id="scopeBar" class="scope-bar hidden">
59
- <span id="scopeLabel" class="scope-label"></span>
60
- <div class="scope-compare">
61
- <label for="compareBase">Compare from</label>
62
- <input
63
- type="text"
64
- id="compareBase"
65
- placeholder="commit, tag or branch"
66
- spellcheck="false"
67
- autocomplete="off"
68
- value="">
69
- <button id="compareBtn" onclick="compareFrom()">Compare</button>
70
- <button id="scopeResetBtn" class="scope-reset hidden" onclick="resetScope()">Back to latest</button>
56
+ <div id="status" class="status hidden" role="status" aria-live="polite"></div>
57
+
58
+ <div id="scopeBar" class="scope-bar hidden" role="region" aria-label="What is being reviewed">
59
+ <div class="scope-summary">
60
+ <div class="scope-text">
61
+ <span id="scopeLabel" class="scope-label"></span>
62
+ <span id="scopeMeta" class="scope-meta"></span>
63
+ </div>
64
+ <div class="scope-actions">
65
+ <button id="compareToggle" class="scope-action" onclick="openCompare()">Compare branches</button>
66
+ <button id="scopeResetBtn" class="scope-action hidden" onclick="resetScope()">Stop comparing</button>
67
+ </div>
68
+ </div>
69
+
70
+ <!-- GitHub's vocabulary, deliberately: "base" and "compare" are what
71
+ the people using this already call the two ends of a pull request. -->
72
+ <div id="compareRow" class="compare-row hidden">
73
+ <div class="compare-end">
74
+ <label for="compareBaseSelect">Base</label>
75
+ <select id="compareBaseSelect" onchange="onPickerChange('base')"></select>
76
+ <input id="compareBaseOther" class="compare-other hidden" type="text"
77
+ placeholder="tag, HEAD~5 or sha" spellcheck="false" autocomplete="off"
78
+ aria-label="Base ref">
79
+ </div>
80
+ <button id="compareSwap" class="compare-swap" onclick="swapCompare()"
81
+ title="Swap base and compare" aria-label="Swap base and compare">&#8644;</button>
82
+ <div class="compare-end">
83
+ <label for="compareHeadSelect">Compare</label>
84
+ <select id="compareHeadSelect" onchange="onPickerChange('head')"></select>
85
+ <input id="compareHeadOther" class="compare-other hidden" type="text"
86
+ placeholder="tag, HEAD~5 or sha" spellcheck="false" autocomplete="off"
87
+ aria-label="Compare ref">
88
+ </div>
71
89
  </div>
90
+
91
+ <p id="scopeNote" class="scope-note hidden"></p>
72
92
  </div>
73
93
  </header>
74
94
 
package/public/style.css CHANGED
@@ -1878,60 +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;
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;
1968
+ }
1969
+
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;
1978
+ }
1979
+
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;
1929
2002
  border: 1px solid #d1d5da;
2003
+ border-radius: 6px;
2004
+ background: #fff;
1930
2005
  color: #24292e;
1931
2006
  }
1932
2007
 
2008
+ .compare-other {
2009
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
2010
+ font-size: 12px;
2011
+ }
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. */
1933
2039
  @media (max-width: 720px) {
1934
- .scope-compare input {
1935
- width: 120px;
2040
+ .compare-row {
2041
+ flex-direction: column;
2042
+ align-items: stretch;
1936
2043
  }
2044
+
2045
+ .compare-end {
2046
+ flex: 1 1 auto;
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;
1937
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');
@@ -62,6 +62,17 @@ class FileNotFoundError extends Error {
62
62
  * leaves before deciding it is not coming back.
63
63
  * @returns {import('express').Express}
64
64
  */
65
+ // How far back the range picker offers to go. Long enough to cover the work
66
+ // someone is plausibly still reviewing, short enough that the list is a list
67
+ // rather than the whole history -- anything older is still reachable by
68
+ // typing a ref, which is what the text box is for.
69
+ const COMMIT_LIST_LIMIT = 30;
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
+
65
76
  function createApp(options = {}) {
66
77
  const {
67
78
  reviewsDir = resolveReviewsDir(),
@@ -122,10 +133,18 @@ function createApp(options = {}) {
122
133
  const absolutePath = resolveRepoFile(session.repoPath, filePath);
123
134
 
124
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.
125
144
  try {
126
145
  return await git.show([`${session.range.head}:${filePath}`]);
127
146
  } catch {
128
- return await fs.readFile(absolutePath, 'utf-8').catch(() => '');
147
+ return git.show([`${session.range.from}:${filePath}`]).catch(() => '');
129
148
  }
130
149
  }
131
150
 
@@ -199,7 +218,7 @@ function createApp(options = {}) {
199
218
 
200
219
  async function diffForFile(git, session, filePath) {
201
220
  if (session.range) {
202
- return git.diff([session.range.base, session.range.head, '--', filePath]);
221
+ return git.diff([session.range.from, session.range.head, '--', filePath]);
203
222
  }
204
223
  if (!(await hasCommits(git))) {
205
224
  return '';
@@ -245,7 +264,7 @@ function createApp(options = {}) {
245
264
  }
246
265
  }
247
266
 
248
- const resolve = async (label, ref) => {
267
+ const resolve = async ref => {
249
268
  try {
250
269
  return (await git.revparse([`${ref}^{commit}`])).trim();
251
270
  } catch {
@@ -253,19 +272,67 @@ function createApp(options = {}) {
253
272
  }
254
273
  };
255
274
 
256
- const baseSha = await resolve('base', base);
257
- const headSha = await resolve('head', head);
258
-
259
- // `base..head` counts what head has and base does not, which is the
260
- // number a reviewer means by "how many commits am I looking at". It is
261
- // not symmetric, and it is not the same as the file count -- a range
262
- // whose changes cancel out has commits and no files, which is the whole
263
- // reason this number is reported.
264
- const commits = Number(
265
- (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()
266
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
+ };
312
+
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
+ }
267
323
 
268
- return { base: baseSha, head: headSha, commits: Number.isFinite(commits) ? commits : 0 };
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));
269
336
  }
270
337
 
271
338
  /**
@@ -282,17 +349,134 @@ function createApp(options = {}) {
282
349
  * @returns {string}
283
350
  */
284
351
  function describeRange(range, fileCount) {
285
- 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)}`;
354
+
355
+ if (fileCount > 0) {
356
+ return `${ends}: ${plural(range.commits, 'commit')}, ${plural(fileCount, 'changed file')}`;
357
+ }
286
358
 
287
- if (fileCount === 0) {
288
- return range.commits === 0
289
- ? 'Those two commits are the same, so there is nothing between them.'
290
- : `${commits}, and no net change between the two ends — every change in them was undone again inside the range.`;
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.`;
291
363
  }
292
364
 
293
- return `${commits}, ${fileCount} changed file${fileCount === 1 ? '' : 's'} between the two ends`;
365
+ return `${ends}: ${plural(range.commits, 'commit')}, and no net change \u2014 every change in them was undone again inside the range.`;
366
+ }
367
+
368
+ /**
369
+ * Recent commits, so a range can be picked instead of typed.
370
+ *
371
+ * Nobody knows the SHA of the commit they want to review back to, and
372
+ * asking for one is asking the reviewer to go and run `git log` in another
373
+ * window to use a tool whose whole point is not having to.
374
+ *
375
+ * Each entry is what a person recognises a commit by -- the subject line
376
+ * and when it landed -- with the SHA carried alongside rather than being
377
+ * the thing they have to read.
378
+ *
379
+ * `%x00` separates the fields because a commit subject can contain
380
+ * anything a person can type, including whatever delimiter looked safe.
381
+ *
382
+ * @param {import('simple-git').SimpleGit} git
383
+ * @param {number} limit
384
+ */
385
+ async function recentCommits(git, limit) {
386
+ const raw = await git.raw([
387
+ 'log', `--max-count=${limit}`, '--format=%H%x00%h%x00%s%x00%cr'
388
+ ]);
389
+
390
+ return raw
391
+ .split('\n')
392
+ .filter(line => line.trim() !== '')
393
+ .map(line => {
394
+ const [sha, short, subject, when] = line.split('\u0000');
395
+ return { sha, short, subject, when };
396
+ });
294
397
  }
295
398
 
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
+ /**
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
+
466
+ app.get('/api/commits/:repoId', async (req, res) => {
467
+ try {
468
+ const session = requireSession(res, req.params.repoId);
469
+ if (!session) return;
470
+
471
+ const commits = await recentCommits(gitFactory(session.repoPath), COMMIT_LIST_LIMIT);
472
+ res.json({ commits });
473
+ } catch (error) {
474
+ // A repository with no commits is not an error, it is an empty list.
475
+ console.error('Commit list error:', error.message);
476
+ res.json({ commits: [] });
477
+ }
478
+ });
479
+
296
480
  /** Liveness probe, and a count of how many repositories are open. */
297
481
  app.get('/api/health', (req, res) => {
298
482
  res.json({ status: 'ok', sessions: sessions.size });
@@ -447,7 +631,7 @@ function createApp(options = {}) {
447
631
  return res.status(400).json({ error: error.message });
448
632
  }
449
633
 
450
- files = collectCommitChanges(await git.diffSummary([range.base, range.head]));
634
+ files = await collectRangeChanges(git, range);
451
635
  mode = 'range';
452
636
  message = describeRange(range, files.length);
453
637
  } else {
@@ -459,9 +643,7 @@ function createApp(options = {}) {
459
643
  if (files.length === 0) {
460
644
  try {
461
645
  const lastCommit = await resolveRange(git, 'HEAD~1', 'HEAD');
462
- const commitFiles = collectCommitChanges(
463
- await git.diffSummary([lastCommit.base, lastCommit.head])
464
- );
646
+ const commitFiles = await collectRangeChanges(git, lastCommit);
465
647
  if (commitFiles.length > 0) {
466
648
  files = commitFiles;
467
649
  mode = 'lastCommit';