git-reviewer 2.10.0 → 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-310-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 # 310 tests
326
+ npm test # 314 tests
298
327
  npm run test:watch
299
328
  npm run test:coverage
300
329
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-reviewer",
3
- "version": "2.10.0",
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
@@ -156,19 +156,77 @@ function renderScope(data) {
156
156
  bar.classList.remove('hidden');
157
157
  reset.classList.toggle('hidden', data.mode !== 'range');
158
158
 
159
+ const select = document.getElementById('compareSelect');
160
+
159
161
  if (data.mode === 'range' && data.range) {
160
162
  const { base, head, commits } = data.range;
161
163
  label.textContent =
162
164
  `Comparing ${shortSha(base)} \u2192 ${shortSha(head)} \u2014 ` +
163
165
  `${commits} commit${commits === 1 ? '' : 's'}, shown as one combined diff`;
164
- document.getElementById('compareBase').value = shortSha(base);
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 : '';
165
171
  return;
166
172
  }
167
173
 
168
174
  label.textContent = data.mode === 'lastCommit'
169
175
  ? 'Reviewing the last commit'
170
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
+
171
228
  document.getElementById('compareBase').value = '';
229
+ loadRepo({ base });
172
230
  }
173
231
 
174
232
  /** Reload the repository as a range starting at whatever was typed. */
@@ -186,6 +244,7 @@ function compareFrom() {
186
244
  /** Back to the default scope: uncommitted changes, or the last commit. */
187
245
  function resetScope() {
188
246
  document.getElementById('compareBase').value = '';
247
+ document.getElementById('compareSelect').value = '';
189
248
  loadRepo();
190
249
  }
191
250
 
@@ -230,6 +289,9 @@ async function loadRepo(scope = {}) {
230
289
  setRepoButton(data.repoPath);
231
290
  }
232
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);
233
295
  renderScope(data);
234
296
 
235
297
  if (data.files.length === 0) {
package/public/index.html CHANGED
@@ -58,11 +58,17 @@
58
58
  <div id="scopeBar" class="scope-bar hidden">
59
59
  <span id="scopeLabel" class="scope-label"></span>
60
60
  <div class="scope-compare">
61
- <label for="compareBase">Compare from</label>
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>
62
68
  <input
63
69
  type="text"
64
70
  id="compareBase"
65
- placeholder="commit, tag or branch"
71
+ placeholder="or a branch, tag or ref"
66
72
  spellcheck="false"
67
73
  autocomplete="off"
68
74
  value="">
package/public/style.css CHANGED
@@ -1935,3 +1935,32 @@ button:disabled {
1935
1935
  width: 120px;
1936
1936
  }
1937
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(),
@@ -293,6 +299,58 @@ function createApp(options = {}) {
293
299
  return `${commits}, ${fileCount} changed file${fileCount === 1 ? '' : 's'} between the two ends`;
294
300
  }
295
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
+
296
354
  /** Liveness probe, and a count of how many repositories are open. */
297
355
  app.get('/api/health', (req, res) => {
298
356
  res.json({ status: 'ok', sessions: sessions.size });