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 +29 -5
- package/lib/agent.js +11 -0
- package/package.json +1 -1
- package/public/app.js +384 -19
- package/public/index.html +25 -2
- package/public/style.css +144 -0
- package/server.js +183 -12
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](package.json)
|
|
6
|
-
[](test/)
|
|
7
7
|
[](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 #
|
|
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.
|
|
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
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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]
|
|
439
|
-
* uncommitted changes, falling back to the last
|
|
440
|
-
* `head`
|
|
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(
|
|
458
|
-
? { repoPath,
|
|
459
|
-
|
|
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
|
-
//
|
|
480
|
-
//
|
|
481
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
<
|
|
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">‹</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">›</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 (
|
|
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.
|