git-reviewer 2.9.1 → 2.11.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-299-brightgreen.svg)](test/)
6
+ [![Tests](https://img.shields.io/badge/tests-314-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,35 @@ 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
+ ### Reviewing further back than the last commit
145
+
146
+ Those defaults cover the common case and nothing else needs setting up, but a
147
+ branch you have been working on for a day is several commits deep, and the
148
+ default scope only ever shows you the most recent one.
149
+
150
+ **Changes since** in the header takes any earlier commit and reloads the review
151
+ as a single combined diff up to `HEAD`. Pick one from the dropdown — recent
152
+ commits by subject line and when they landed, so you do not have to go and find
153
+ a SHA — or type a branch, a tag, or something like `HEAD~5` in the box beside
154
+ it for anything the list does not reach.
155
+
156
+ The commit the range starts from is not itself included: "changes since x1"
157
+ means everything after x1, the same as `x1..HEAD`.
158
+
159
+ The bar keeps saying how many commits the range covers, and it keeps saying it
160
+ when there are no files:
161
+
162
+ ```
163
+ Comparing 86b28f11 → 27668114 — 2 commits, shown as one combined diff
164
+ 2 commits, and no net change between the two ends — every change in them was
165
+ undone again inside the range.
166
+ ```
167
+
168
+ That is a real answer, not an empty screen. A file added in one commit and
169
+ deleted in a later one is correctly absent from a diff of the two ends, and
170
+ "no net change across 2 commits" is a different statement from "nothing to
171
+ review".
172
+
144
173
  Modified, added, deleted, renamed, and binary files are all listed, each marked
145
174
  with its git status letter. Deleted lines are commentable too — the most useful
146
175
  review note is often about the code someone removed. A file deleted outright is
@@ -294,7 +323,7 @@ rather than a public issue.
294
323
 
295
324
  ```bash
296
325
  npm install # 3 dependencies, no build step, ~6MB
297
- npm test # 299 tests
326
+ npm test # 314 tests
298
327
  npm run test:watch
299
328
  npm run test:coverage
300
329
  ```
package/lib/agent.js CHANGED
@@ -50,7 +50,7 @@ const SCHEMA = 'code-review/v1';
50
50
  * @param {string|null} [args.mode]
51
51
  * @returns {ReviewDocument}
52
52
  */
53
- function buildReviewDocument({ repoPath, comments, generatedAt, head = null, branch = null, mode = null }) {
53
+ function buildReviewDocument({ repoPath, comments, generatedAt, head = null, branch = null, mode = null, range = null }) {
54
54
  const grouped = groupByFile(comments);
55
55
 
56
56
  return {
@@ -63,6 +63,10 @@ function buildReviewDocument({ repoPath, comments, generatedAt, head = null, bra
63
63
  branch
64
64
  },
65
65
  mode,
66
+ // Omitted rather than null when there is no range, so `"range" in
67
+ // document` is a meaningful test -- the same rule the optional comment
68
+ // fields follow. A `working` review has no range and never will.
69
+ ...(range ? { range } : {}),
66
70
  summary: {
67
71
  comments: comments.length,
68
72
  files: grouped.length
@@ -194,13 +198,22 @@ function toAgentComment(comment, id) {
194
198
  * @param {ReviewDocument} document
195
199
  * @returns {string|null}
196
200
  */
197
- function describeScope({ repository, mode }) {
201
+ function describeScope({ repository, mode, range }) {
198
202
  const at = repository.head ? ` (${repository.head})` : '';
199
203
 
200
204
  if (mode === 'working') {
201
205
  return `Reviewed: uncommitted changes in the working tree, against ${repository.branch ?? 'HEAD'}${at}.`;
202
206
  }
203
207
 
208
+ if (mode === 'range' && range) {
209
+ // Both ends, and the count, because the count is the only thing that
210
+ // distinguishes "one commit of work" from "nine" once the diff has been
211
+ // flattened -- and an agent reading a flattened diff of nine commits
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.`;
215
+ }
216
+
204
217
  if (mode === 'lastCommit') {
205
218
  return repository.head
206
219
  ? `Reviewed: commit ${repository.head}, against its parent.`
package/lib/export.js CHANGED
@@ -100,7 +100,7 @@ async function readGitPosition(repoPath) {
100
100
  * records a different repository than the one asked for
101
101
  */
102
102
  async function loadReviewDocument(reviewsDir, repoPath, { file = null } = {}) {
103
- const { comments, mode } = await readSavedComments(reviewsDir, repoPath);
103
+ const { comments, mode, range } = await readSavedComments(reviewsDir, repoPath);
104
104
 
105
105
  if (comments.length === 0) throw new NoReviewError(repoPath);
106
106
 
@@ -121,7 +121,7 @@ async function loadReviewDocument(reviewsDir, repoPath, { file = null } = {}) {
121
121
  // same object reference when no filter is given. `test/agent.test.js`
122
122
  // asserts that identity with `assert.equal`.
123
123
  const document = filterReviewDocument(
124
- buildReviewDocument({ repoPath, comments, generatedAt: new Date(), head, branch, mode }),
124
+ buildReviewDocument({ repoPath, comments, generatedAt: new Date(), head, branch, mode, range }),
125
125
  { file }
126
126
  );
127
127
 
package/lib/sessions.js CHANGED
@@ -11,9 +11,21 @@ const crypto = require('crypto');
11
11
  */
12
12
 
13
13
  /**
14
- * @typedef {'working'|'lastCommit'} ReviewMode
14
+ * @typedef {'working'|'lastCommit'|'range'} ReviewMode
15
15
  * `working` diffs the working tree against HEAD; `lastCommit` diffs HEAD
16
- * against its parent, which is what a clean tree falls back to.
16
+ * against its parent, which is what a clean tree falls back to; `range`
17
+ * diffs two refs the reviewer chose.
18
+ */
19
+
20
+ /**
21
+ * @typedef {object} ReviewRange
22
+ * @property {string} base ref the review is measured from, already resolved
23
+ * @property {string} head ref the review is measured to, already resolved
24
+ * @property {number} commits how many commits separate them
25
+ *
26
+ * Present for every commit-based review, including `lastCommit`, which is
27
+ * just the range `HEAD~1..HEAD` under a name. `working` has no range: its
28
+ * head is the working tree, which is not a ref.
17
29
  */
18
30
 
19
31
  class SessionStore {
@@ -22,24 +34,25 @@ class SessionStore {
22
34
  */
23
35
  constructor(generateId = () => crypto.randomBytes(8).toString('hex')) {
24
36
  this.generateId = generateId;
25
- /** @type {Map<string, {repoPath: string, mode: ReviewMode}>} */
37
+ /** @type {Map<string, {repoPath: string, mode: ReviewMode, range: ReviewRange|null}>} */
26
38
  this.sessions = new Map();
27
39
  }
28
40
 
29
41
  /**
30
42
  * @param {string} repoPath absolute path of the opened repository
31
43
  * @param {ReviewMode} mode
44
+ * @param {ReviewRange|null} [range] the two refs, for a commit-based review
32
45
  * @returns {string} the new session id
33
46
  */
34
- create(repoPath, mode) {
47
+ create(repoPath, mode, range = null) {
35
48
  const repoId = this.generateId();
36
- this.sessions.set(repoId, { repoPath, mode });
49
+ this.sessions.set(repoId, { repoPath, mode, range });
37
50
  return repoId;
38
51
  }
39
52
 
40
53
  /**
41
54
  * @param {string} repoId
42
- * @returns {{repoPath: string, mode: ReviewMode}|undefined}
55
+ * @returns {{repoPath: string, mode: ReviewMode, range: ReviewRange|null}|undefined}
43
56
  */
44
57
  get(repoId) {
45
58
  return this.sessions.get(repoId);
package/lib/store.js CHANGED
@@ -74,7 +74,7 @@ async function readJson(file) {
74
74
  *
75
75
  * @param {string} reviewsDir
76
76
  * @param {string} repoPath
77
- * @returns {Promise<{comments: object[], mode: string|null, adopted: boolean}>}
77
+ * @returns {Promise<{comments: object[], mode: string|null, range: object|null, adopted: boolean}>}
78
78
  * `mode` is null for a file written before it was recorded -- there is
79
79
  * nothing on disk that says what was compared, and guessing would be
80
80
  * worse than admitting it.
@@ -90,15 +90,15 @@ async function readSavedComments(reviewsDir, repoPath) {
90
90
  if (!belongsTo(saved.repoPath, repoPath)) {
91
91
  throw new ReviewOwnershipError(repoPath, saved.repoPath);
92
92
  }
93
- return { comments: saved.comments ?? [], mode: saved.mode ?? null, adopted: false };
93
+ return { comments: saved.comments ?? [], mode: saved.mode ?? null, range: saved.range ?? null, adopted: false };
94
94
  }
95
95
 
96
96
  const legacyName = legacyCommentsFilename(repoPath);
97
- if (legacyName === commentsFilename(repoPath)) return { comments: [], mode: null, adopted: false };
97
+ if (legacyName === commentsFilename(repoPath)) return { comments: [], mode: null, range: null, adopted: false };
98
98
 
99
99
  const legacy = await readJson(path.join(reviewsDir, legacyName));
100
100
  if (legacy === null || !belongsTo(legacy.repoPath, repoPath)) {
101
- return { comments: [], mode: null, adopted: false };
101
+ return { comments: [], mode: null, range: null, adopted: false };
102
102
  }
103
103
 
104
104
  // Move it under the new name so the next read is a plain hit and the old
@@ -110,7 +110,7 @@ async function readSavedComments(reviewsDir, repoPath) {
110
110
  /* the comments are already in hand; renaming is housekeeping */
111
111
  }
112
112
 
113
- return { comments: legacy.comments ?? [], mode: legacy.mode ?? null, adopted: true };
113
+ return { comments: legacy.comments ?? [], mode: legacy.mode ?? null, range: legacy.range ?? null, adopted: true };
114
114
  }
115
115
 
116
116
  module.exports = { readSavedComments, ReviewOwnershipError, belongsTo };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-reviewer",
3
- "version": "2.9.1",
3
+ "version": "2.11.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
@@ -132,7 +132,129 @@ function calculateSimilarity(str1, str2) {
132
132
  }
133
133
 
134
134
  // Load local repository
135
- async function loadRepo() {
135
+ /** Short form of a commit sha, for reading rather than for copying. */
136
+ function shortSha(sha) {
137
+ return typeof sha === 'string' ? sha.slice(0, 8) : '';
138
+ }
139
+
140
+ /**
141
+ * Say what the review is of, above the files.
142
+ *
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".
148
+ *
149
+ * @param {{mode: string, range: ?{base: string, head: string, commits: number}}} data
150
+ */
151
+ function renderScope(data) {
152
+ const bar = document.getElementById('scopeBar');
153
+ const label = document.getElementById('scopeLabel');
154
+ const reset = document.getElementById('scopeResetBtn');
155
+
156
+ bar.classList.remove('hidden');
157
+ reset.classList.toggle('hidden', data.mode !== 'range');
158
+
159
+ const select = document.getElementById('compareSelect');
160
+
161
+ if (data.mode === 'range' && data.range) {
162
+ const { base, head, commits } = data.range;
163
+ label.textContent =
164
+ `Comparing ${shortSha(base)} \u2192 ${shortSha(head)} \u2014 ` +
165
+ `${commits} commit${commits === 1 ? '' : 's'}, shown as one combined diff`;
166
+ // Only if the base is one of the offered commits. A range opened by
167
+ // typing `HEAD~5` or a branch name has no matching option, and leaving
168
+ // the picker on its placeholder is honest -- the scope bar above already
169
+ // says exactly what is being compared.
170
+ select.value = [...select.options].some(option => option.value === base) ? base : '';
171
+ return;
172
+ }
173
+
174
+ label.textContent = data.mode === 'lastCommit'
175
+ ? 'Reviewing the last commit'
176
+ : 'Reviewing uncommitted changes';
177
+ select.value = '';
178
+ document.getElementById('compareBase').value = '';
179
+ }
180
+
181
+ /**
182
+ * Fill the commit picker for the open repository.
183
+ *
184
+ * The newest commit is deliberately not offered. "Changes since HEAD" is
185
+ * either nothing or the working tree depending on how you squint, and both
186
+ * of those are already what "Back to latest" gives you -- so listing it
187
+ * would be offering a choice that cannot do anything useful.
188
+ *
189
+ * A failure here is silent on purpose: the text box next to it still works,
190
+ * and a repository whose history will not list is not a reason to refuse to
191
+ * review it.
192
+ */
193
+ async function loadCommitOptions(repoId) {
194
+ const select = document.getElementById('compareSelect');
195
+
196
+ try {
197
+ const response = await fetch(`${API_BASE}/commits/${repoId}`);
198
+ if (!response.ok) return;
199
+
200
+ const { commits } = await response.json();
201
+ const options = commits.slice(1);
202
+
203
+ select.innerHTML = '';
204
+ if (options.length === 0) {
205
+ select.appendChild(new Option('No earlier commits', ''));
206
+ select.disabled = true;
207
+ return;
208
+ }
209
+
210
+ select.disabled = false;
211
+ select.appendChild(new Option('Pick a commit…', ''));
212
+ for (const commit of options) {
213
+ // The subject is what a person recognises; the sha is there to
214
+ // disambiguate two commits with the same message, which is common
215
+ // enough in a branch that has been rebased.
216
+ select.appendChild(new Option(`${commit.short} · ${commit.subject} · ${commit.when}`, commit.sha));
217
+ }
218
+ } catch {
219
+ /* the text box beside it is the fallback */
220
+ }
221
+ }
222
+
223
+ /** Compare from whatever was chosen in the picker. */
224
+ function compareFromSelection() {
225
+ const base = document.getElementById('compareSelect').value;
226
+ if (!base) return;
227
+
228
+ document.getElementById('compareBase').value = '';
229
+ loadRepo({ base });
230
+ }
231
+
232
+ /** Reload the repository as a range starting at whatever was typed. */
233
+ function compareFrom() {
234
+ const base = document.getElementById('compareBase').value.trim();
235
+
236
+ if (!base) {
237
+ showStatus('Enter a commit, tag or branch to compare from', 'error');
238
+ return;
239
+ }
240
+
241
+ loadRepo({ base });
242
+ }
243
+
244
+ /** Back to the default scope: uncommitted changes, or the last commit. */
245
+ function resetScope() {
246
+ document.getElementById('compareBase').value = '';
247
+ document.getElementById('compareSelect').value = '';
248
+ loadRepo();
249
+ }
250
+
251
+ /**
252
+ * Load a repository, optionally as a range between two commits.
253
+ *
254
+ * @param {{base?: string}} [scope] omit for the default: uncommitted changes,
255
+ * falling back to the last commit on a clean tree.
256
+ */
257
+ async function loadRepo(scope = {}) {
136
258
  const repoPath = document.getElementById('repoPath').value.trim();
137
259
 
138
260
  if (!repoPath) {
@@ -147,7 +269,7 @@ async function loadRepo() {
147
269
  const response = await fetch(`${API_BASE}/load-repo`, {
148
270
  method: 'POST',
149
271
  headers: { 'Content-Type': 'application/json' },
150
- body: JSON.stringify({ repoPath })
272
+ body: JSON.stringify(scope.base ? { repoPath, base: scope.base } : { repoPath })
151
273
  });
152
274
 
153
275
  const data = await response.json();
@@ -167,7 +289,25 @@ async function loadRepo() {
167
289
  setRepoButton(data.repoPath);
168
290
  }
169
291
 
292
+ // Before renderScope, which needs the options present to be able to
293
+ // select the current base among them.
294
+ await loadCommitOptions(data.repoId);
295
+ renderScope(data);
296
+
170
297
  if (data.files.length === 0) {
298
+ // A range with no files is a real answer, not a failure: every change
299
+ // in it was undone again before the far end. The server says so in
300
+ // `message`, and the scope bar above is still showing how many commits
301
+ // that covers -- so this is information, not an error, and it must not
302
+ // be dressed as one.
303
+ if (data.mode === 'range') {
304
+ showStatus(data.message, 'success');
305
+ hidePicker();
306
+ displayFiles([]);
307
+ document.getElementById('loadBtn').disabled = false;
308
+ return;
309
+ }
310
+
171
311
  showStatus('No uncommitted changes found in the repository', 'error');
172
312
  document.getElementById('loadBtn').disabled = false;
173
313
  return;
@@ -1749,6 +1889,12 @@ document.addEventListener('DOMContentLoaded', () => {
1749
1889
  }
1750
1890
  });
1751
1891
 
1892
+ document.getElementById('compareBase').addEventListener('keypress', (e) => {
1893
+ if (e.key === 'Enter') {
1894
+ compareFrom();
1895
+ }
1896
+ });
1897
+
1752
1898
  // Initialize resize handle
1753
1899
  initResizeHandle();
1754
1900
 
package/public/index.html CHANGED
@@ -54,6 +54,28 @@
54
54
  </div>
55
55
  </div>
56
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
+ <!-- "since", not "from": `base..HEAD` does not include base's own
62
+ changes, and a picker that says "compare from x1" invites the
63
+ reading that x1 is in the diff. -->
64
+ <label for="compareSelect">Changes since</label>
65
+ <select id="compareSelect" onchange="compareFromSelection()" aria-label="Review everything after this commit">
66
+ <option value="">Pick a commit…</option>
67
+ </select>
68
+ <input
69
+ type="text"
70
+ id="compareBase"
71
+ placeholder="or a branch, tag or ref"
72
+ spellcheck="false"
73
+ autocomplete="off"
74
+ value="">
75
+ <button id="compareBtn" onclick="compareFrom()">Compare</button>
76
+ <button id="scopeResetBtn" class="scope-reset hidden" onclick="resetScope()">Back to latest</button>
77
+ </div>
78
+ </div>
57
79
  </header>
58
80
 
59
81
  <div class="main-container">
package/public/style.css CHANGED
@@ -1877,3 +1877,90 @@ button:disabled {
1877
1877
  color: #7a5d00;
1878
1878
  text-align: center;
1879
1879
  }
1880
+
1881
+ /* What the review is of, and how to change it.
1882
+ *
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. */
1887
+ .scope-bar {
1888
+ display: flex;
1889
+ align-items: center;
1890
+ justify-content: space-between;
1891
+ flex-wrap: wrap;
1892
+ 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
+ }
1900
+
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;
1906
+ min-width: 0;
1907
+ }
1908
+
1909
+ .scope-compare {
1910
+ display: flex;
1911
+ align-items: center;
1912
+ gap: 8px;
1913
+ flex-shrink: 0;
1914
+ }
1915
+
1916
+ .scope-compare label {
1917
+ color: #6a737d;
1918
+ white-space: nowrap;
1919
+ }
1920
+
1921
+ .scope-compare input {
1922
+ width: 190px;
1923
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1924
+ font-size: 12px;
1925
+ }
1926
+
1927
+ .scope-reset {
1928
+ background: transparent;
1929
+ border: 1px solid #d1d5da;
1930
+ color: #24292e;
1931
+ }
1932
+
1933
+ @media (max-width: 720px) {
1934
+ .scope-compare input {
1935
+ width: 120px;
1936
+ }
1937
+ }
1938
+
1939
+ /* The picker carries the common case; the text box beside it carries
1940
+ * everything else — a branch, a tag, `HEAD~5`, a sha from somewhere else.
1941
+ * Both stay visible rather than one hiding behind a mode, because a control
1942
+ * you cannot see is a feature people believe is missing. */
1943
+ .scope-compare select {
1944
+ max-width: 320px;
1945
+ font-size: 12px;
1946
+ padding: 4px 6px;
1947
+ border: 1px solid #d1d5da;
1948
+ border-radius: 6px;
1949
+ background: #fff;
1950
+ color: #24292e;
1951
+ }
1952
+
1953
+ .scope-compare select:disabled {
1954
+ color: #6a737d;
1955
+ background: #f3f4f6;
1956
+ }
1957
+
1958
+ @media (max-width: 720px) {
1959
+ .scope-compare {
1960
+ flex-wrap: wrap;
1961
+ }
1962
+
1963
+ .scope-compare select {
1964
+ max-width: 100%;
1965
+ }
1966
+ }
package/server.js CHANGED
@@ -62,6 +62,12 @@ 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
+
65
71
  function createApp(options = {}) {
66
72
  const {
67
73
  reviewsDir = resolveReviewsDir(),
@@ -121,9 +127,9 @@ function createApp(options = {}) {
121
127
  const git = gitFactory(session.repoPath);
122
128
  const absolutePath = resolveRepoFile(session.repoPath, filePath);
123
129
 
124
- if (session.mode === 'lastCommit') {
130
+ if (session.range) {
125
131
  try {
126
- return await git.show([`HEAD:${filePath}`]);
132
+ return await git.show([`${session.range.head}:${filePath}`]);
127
133
  } catch {
128
134
  return await fs.readFile(absolutePath, 'utf-8').catch(() => '');
129
135
  }
@@ -198,8 +204,8 @@ function createApp(options = {}) {
198
204
  }
199
205
 
200
206
  async function diffForFile(git, session, filePath) {
201
- if (session.mode === 'lastCommit') {
202
- return git.diff(['HEAD~1', 'HEAD', '--', filePath]);
207
+ if (session.range) {
208
+ return git.diff([session.range.base, session.range.head, '--', filePath]);
203
209
  }
204
210
  if (!(await hasCommits(git))) {
205
211
  return '';
@@ -207,6 +213,144 @@ function createApp(options = {}) {
207
213
  return git.diff(['HEAD', '--', filePath]);
208
214
  }
209
215
 
216
+ /**
217
+ * A ref safe to hand to git as an argument.
218
+ *
219
+ * simple-git passes arguments as an array rather than through a shell, so
220
+ * there is no quoting to get wrong -- but a value beginning with `-` is
221
+ * still read by git as an option rather than as a ref, and
222
+ * `--output=/somewhere` is a real thing to hand `git diff`. Refs do not
223
+ * begin with a dash, so refusing one costs nothing.
224
+ *
225
+ * @param {string} ref
226
+ * @returns {boolean}
227
+ */
228
+ function looksLikeRef(ref) {
229
+ return ref.length > 0 && ref.length <= 255 && !ref.startsWith('-');
230
+ }
231
+
232
+ /**
233
+ * Turn two refs into the range a review is of.
234
+ *
235
+ * Both are resolved to commit SHAs here, once, and the session carries
236
+ * those rather than the names. A branch that moves under a review in
237
+ * progress would otherwise change what the review is of halfway through,
238
+ * and the comments already written would silently be about a diff that no
239
+ * longer exists.
240
+ *
241
+ * @param {import('simple-git').SimpleGit} git
242
+ * @param {string} base
243
+ * @param {string} head
244
+ * @returns {Promise<import('./lib/sessions').ReviewRange>}
245
+ * @throws {Error} with a message meant for the page, when a ref will not resolve
246
+ */
247
+ async function resolveRange(git, base, head) {
248
+ for (const [label, ref] of [['base', base], ['head', head]]) {
249
+ if (!looksLikeRef(ref)) {
250
+ throw new Error(`That ${label} does not look like a commit or branch.`);
251
+ }
252
+ }
253
+
254
+ const resolve = async (label, ref) => {
255
+ try {
256
+ return (await git.revparse([`${ref}^{commit}`])).trim();
257
+ } catch {
258
+ throw new Error(`No commit or branch called "${ref}" in this repository.`);
259
+ }
260
+ };
261
+
262
+ const baseSha = await resolve('base', base);
263
+ const headSha = await resolve('head', head);
264
+
265
+ // `base..head` counts what head has and base does not, which is the
266
+ // number a reviewer means by "how many commits am I looking at". It is
267
+ // not symmetric, and it is not the same as the file count -- a range
268
+ // whose changes cancel out has commits and no files, which is the whole
269
+ // reason this number is reported.
270
+ const commits = Number(
271
+ (await git.raw(['rev-list', '--count', `${baseSha}..${headSha}`])).trim()
272
+ );
273
+
274
+ return { base: baseSha, head: headSha, commits: Number.isFinite(commits) ? commits : 0 };
275
+ }
276
+
277
+ /**
278
+ * What to tell the reviewer about a range they just opened.
279
+ *
280
+ * The empty case is the one that matters. `git diff x3 x5` over a file
281
+ * added in x4 and deleted in x5 is correctly empty, and an interface that
282
+ * renders that as "no changes" is saying something false about two commits
283
+ * of real work. So the commit count leads, and the absence of files is
284
+ * described rather than left as a blank screen.
285
+ *
286
+ * @param {import('./lib/sessions').ReviewRange} range
287
+ * @param {number} fileCount
288
+ * @returns {string}
289
+ */
290
+ function describeRange(range, fileCount) {
291
+ const commits = `${range.commits} commit${range.commits === 1 ? '' : 's'}`;
292
+
293
+ if (fileCount === 0) {
294
+ return range.commits === 0
295
+ ? 'Those two commits are the same, so there is nothing between them.'
296
+ : `${commits}, and no net change between the two ends — every change in them was undone again inside the range.`;
297
+ }
298
+
299
+ return `${commits}, ${fileCount} changed file${fileCount === 1 ? '' : 's'} between the two ends`;
300
+ }
301
+
302
+ /**
303
+ * Recent commits, so a range can be picked instead of typed.
304
+ *
305
+ * Nobody knows the SHA of the commit they want to review back to, and
306
+ * asking for one is asking the reviewer to go and run `git log` in another
307
+ * window to use a tool whose whole point is not having to.
308
+ *
309
+ * Each entry is what a person recognises a commit by -- the subject line
310
+ * and when it landed -- with the SHA carried alongside rather than being
311
+ * the thing they have to read.
312
+ *
313
+ * `%x00` separates the fields because a commit subject can contain
314
+ * anything a person can type, including whatever delimiter looked safe.
315
+ *
316
+ * @param {import('simple-git').SimpleGit} git
317
+ * @param {number} limit
318
+ */
319
+ async function recentCommits(git, limit) {
320
+ const raw = await git.raw([
321
+ 'log', `--max-count=${limit}`, '--format=%H%x00%h%x00%s%x00%cr'
322
+ ]);
323
+
324
+ return raw
325
+ .split('\n')
326
+ .filter(line => line.trim() !== '')
327
+ .map(line => {
328
+ const [sha, short, subject, when] = line.split('\u0000');
329
+ return { sha, short, subject, when };
330
+ });
331
+ }
332
+
333
+ /**
334
+ * The commits of an open repository, newest first.
335
+ *
336
+ * Behind a session id like the file endpoints rather than taking a path:
337
+ * this reads history out of a repository, and the session is what says
338
+ * which repository the caller has already been granted.
339
+ */
340
+ app.get('/api/commits/:repoId', async (req, res) => {
341
+ try {
342
+ const session = requireSession(res, req.params.repoId);
343
+ if (!session) return;
344
+
345
+ const commits = await recentCommits(gitFactory(session.repoPath), COMMIT_LIST_LIMIT);
346
+ res.json({ commits });
347
+ } catch (error) {
348
+ // A repository with no commits is not an error, it is an empty list.
349
+ console.error('Commit list error:', error.message);
350
+ res.json({ commits: [] });
351
+ }
352
+ });
353
+
210
354
  /** Liveness probe, and a count of how many repositories are open. */
211
355
  app.get('/api/health', (req, res) => {
212
356
  res.json({ status: 'ok', sessions: sessions.size });
@@ -338,6 +482,7 @@ function createApp(options = {}) {
338
482
  // Resolve to the root of the working tree before anything is keyed on
339
483
  // this path. A subdirectory passes `checkIsRepo()` quite happily and
340
484
  // then produces a file list whose diffs are all empty; see lib/repo.js.
485
+ const { base, head } = req.body ?? {};
341
486
  const root = await repoRoot(repoPath, gitFactory);
342
487
  if (!root) {
343
488
  return res.status(400).json({ error: 'Not a valid git repository' });
@@ -345,27 +490,51 @@ function createApp(options = {}) {
345
490
 
346
491
  const git = gitFactory(root);
347
492
 
348
- let files = collectWorkingChanges(await git.status());
349
- let mode = 'working';
350
- let message = `Found ${files.length} changed file(s)`;
493
+ let files;
494
+ let mode;
495
+ let range = null;
496
+ let message;
351
497
 
352
- // A clean tree has nothing to review, so fall back to the last commit.
353
- if (files.length === 0) {
498
+ if (base !== undefined && base !== null && `${base}`.trim() !== '') {
499
+ // An explicit range. Nothing falls back to anything here: someone who
500
+ // asked for x1..x3 and is silently shown their working tree instead
501
+ // has been told a lie about what they are reviewing.
354
502
  try {
355
- const commitFiles = collectCommitChanges(await git.diffSummary(['HEAD~1', 'HEAD']));
356
- if (commitFiles.length > 0) {
357
- files = commitFiles;
358
- mode = 'lastCommit';
359
- message = `No working directory changes. Loaded last commit with ${files.length} changed file(s)`;
360
- }
503
+ range = await resolveRange(git, `${base}`.trim(), `${head ?? 'HEAD'}`.trim());
361
504
  } catch (error) {
362
- // A repository with no commits, or only one, has no parent to diff
363
- // against. An empty file list is the correct answer there.
364
- console.error('Error loading last commit:', error.message);
505
+ return res.status(400).json({ error: error.message });
506
+ }
507
+
508
+ files = collectCommitChanges(await git.diffSummary([range.base, range.head]));
509
+ mode = 'range';
510
+ message = describeRange(range, files.length);
511
+ } else {
512
+ files = collectWorkingChanges(await git.status());
513
+ mode = 'working';
514
+ message = `Found ${files.length} changed file(s)`;
515
+
516
+ // A clean tree has nothing to review, so fall back to the last commit.
517
+ if (files.length === 0) {
518
+ try {
519
+ const lastCommit = await resolveRange(git, 'HEAD~1', 'HEAD');
520
+ const commitFiles = collectCommitChanges(
521
+ await git.diffSummary([lastCommit.base, lastCommit.head])
522
+ );
523
+ if (commitFiles.length > 0) {
524
+ files = commitFiles;
525
+ mode = 'lastCommit';
526
+ range = lastCommit;
527
+ message = `No working directory changes. Loaded last commit with ${files.length} changed file(s)`;
528
+ }
529
+ } catch (error) {
530
+ // A repository with no commits, or only one, has no parent to diff
531
+ // against. An empty file list is the correct answer there.
532
+ console.error('Error loading last commit:', error.message);
533
+ }
365
534
  }
366
535
  }
367
536
 
368
- const repoId = sessions.create(root, mode);
537
+ const repoId = sessions.create(root, mode, range);
369
538
  // stderr, not stdout. `reviewer | claude -p ...` puts the finished
370
539
  // review on stdout, and anything else written there lands in the middle
371
540
  // of it. Progress is for the person watching; stdout is for the pipe.
@@ -380,7 +549,7 @@ function createApp(options = {}) {
380
549
 
381
550
  // `repoPath` in the response is the resolved root, not what was asked
382
551
  // for, so the page can show which repository it actually opened.
383
- res.json({ repoId, files, repoPath: root, mode, message });
552
+ res.json({ repoId, files, repoPath: root, mode, range, message });
384
553
  } catch (error) {
385
554
  console.error('Load repo error:', error);
386
555
  res.status(500).json({ error: error.message });
@@ -472,6 +641,7 @@ function createApp(options = {}) {
472
641
  repoPath: session.repoPath,
473
642
  lastUpdated: new Date().toISOString(),
474
643
  mode: session.mode,
644
+ range: session.range,
475
645
  comments: normalized
476
646
  };
477
647
 
@@ -535,7 +705,8 @@ function createApp(options = {}) {
535
705
  generatedAt,
536
706
  head,
537
707
  branch,
538
- mode: session.mode
708
+ mode: session.mode,
709
+ range: session.range
539
710
  });
540
711
  const documentFilename = filename.replace(/\.txt$/, '.json');
541
712