git-reviewer 2.9.1 → 2.10.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-310-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
@@ -294,7 +294,7 @@ rather than a public issue.
294
294
 
295
295
  ```bash
296
296
  npm install # 3 dependencies, no build step, ~6MB
297
- npm test # 299 tests
297
+ npm test # 310 tests
298
298
  npm run test:watch
299
299
  npm run test:coverage
300
300
  ```
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.10.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,70 @@ 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
+ if (data.mode === 'range' && data.range) {
160
+ const { base, head, commits } = data.range;
161
+ label.textContent =
162
+ `Comparing ${shortSha(base)} \u2192 ${shortSha(head)} \u2014 ` +
163
+ `${commits} commit${commits === 1 ? '' : 's'}, shown as one combined diff`;
164
+ document.getElementById('compareBase').value = shortSha(base);
165
+ return;
166
+ }
167
+
168
+ label.textContent = data.mode === 'lastCommit'
169
+ ? 'Reviewing the last commit'
170
+ : 'Reviewing uncommitted changes';
171
+ document.getElementById('compareBase').value = '';
172
+ }
173
+
174
+ /** Reload the repository as a range starting at whatever was typed. */
175
+ function compareFrom() {
176
+ const base = document.getElementById('compareBase').value.trim();
177
+
178
+ if (!base) {
179
+ showStatus('Enter a commit, tag or branch to compare from', 'error');
180
+ return;
181
+ }
182
+
183
+ loadRepo({ base });
184
+ }
185
+
186
+ /** Back to the default scope: uncommitted changes, or the last commit. */
187
+ function resetScope() {
188
+ document.getElementById('compareBase').value = '';
189
+ loadRepo();
190
+ }
191
+
192
+ /**
193
+ * Load a repository, optionally as a range between two commits.
194
+ *
195
+ * @param {{base?: string}} [scope] omit for the default: uncommitted changes,
196
+ * falling back to the last commit on a clean tree.
197
+ */
198
+ async function loadRepo(scope = {}) {
136
199
  const repoPath = document.getElementById('repoPath').value.trim();
137
200
 
138
201
  if (!repoPath) {
@@ -147,7 +210,7 @@ async function loadRepo() {
147
210
  const response = await fetch(`${API_BASE}/load-repo`, {
148
211
  method: 'POST',
149
212
  headers: { 'Content-Type': 'application/json' },
150
- body: JSON.stringify({ repoPath })
213
+ body: JSON.stringify(scope.base ? { repoPath, base: scope.base } : { repoPath })
151
214
  });
152
215
 
153
216
  const data = await response.json();
@@ -167,7 +230,22 @@ async function loadRepo() {
167
230
  setRepoButton(data.repoPath);
168
231
  }
169
232
 
233
+ renderScope(data);
234
+
170
235
  if (data.files.length === 0) {
236
+ // A range with no files is a real answer, not a failure: every change
237
+ // in it was undone again before the far end. The server says so in
238
+ // `message`, and the scope bar above is still showing how many commits
239
+ // that covers -- so this is information, not an error, and it must not
240
+ // be dressed as one.
241
+ if (data.mode === 'range') {
242
+ showStatus(data.message, 'success');
243
+ hidePicker();
244
+ displayFiles([]);
245
+ document.getElementById('loadBtn').disabled = false;
246
+ return;
247
+ }
248
+
171
249
  showStatus('No uncommitted changes found in the repository', 'error');
172
250
  document.getElementById('loadBtn').disabled = false;
173
251
  return;
@@ -1749,6 +1827,12 @@ document.addEventListener('DOMContentLoaded', () => {
1749
1827
  }
1750
1828
  });
1751
1829
 
1830
+ document.getElementById('compareBase').addEventListener('keypress', (e) => {
1831
+ if (e.key === 'Enter') {
1832
+ compareFrom();
1833
+ }
1834
+ });
1835
+
1752
1836
  // Initialize resize handle
1753
1837
  initResizeHandle();
1754
1838
 
package/public/index.html CHANGED
@@ -54,6 +54,22 @@
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
+ <label for="compareBase">Compare from</label>
62
+ <input
63
+ type="text"
64
+ id="compareBase"
65
+ placeholder="commit, tag or branch"
66
+ spellcheck="false"
67
+ autocomplete="off"
68
+ value="">
69
+ <button id="compareBtn" onclick="compareFrom()">Compare</button>
70
+ <button id="scopeResetBtn" class="scope-reset hidden" onclick="resetScope()">Back to latest</button>
71
+ </div>
72
+ </div>
57
73
  </header>
58
74
 
59
75
  <div class="main-container">
package/public/style.css CHANGED
@@ -1877,3 +1877,61 @@ 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
+ }
package/server.js CHANGED
@@ -121,9 +121,9 @@ function createApp(options = {}) {
121
121
  const git = gitFactory(session.repoPath);
122
122
  const absolutePath = resolveRepoFile(session.repoPath, filePath);
123
123
 
124
- if (session.mode === 'lastCommit') {
124
+ if (session.range) {
125
125
  try {
126
- return await git.show([`HEAD:${filePath}`]);
126
+ return await git.show([`${session.range.head}:${filePath}`]);
127
127
  } catch {
128
128
  return await fs.readFile(absolutePath, 'utf-8').catch(() => '');
129
129
  }
@@ -198,8 +198,8 @@ function createApp(options = {}) {
198
198
  }
199
199
 
200
200
  async function diffForFile(git, session, filePath) {
201
- if (session.mode === 'lastCommit') {
202
- return git.diff(['HEAD~1', 'HEAD', '--', filePath]);
201
+ if (session.range) {
202
+ return git.diff([session.range.base, session.range.head, '--', filePath]);
203
203
  }
204
204
  if (!(await hasCommits(git))) {
205
205
  return '';
@@ -207,6 +207,92 @@ function createApp(options = {}) {
207
207
  return git.diff(['HEAD', '--', filePath]);
208
208
  }
209
209
 
210
+ /**
211
+ * A ref safe to hand to git as an argument.
212
+ *
213
+ * simple-git passes arguments as an array rather than through a shell, so
214
+ * there is no quoting to get wrong -- but a value beginning with `-` is
215
+ * still read by git as an option rather than as a ref, and
216
+ * `--output=/somewhere` is a real thing to hand `git diff`. Refs do not
217
+ * begin with a dash, so refusing one costs nothing.
218
+ *
219
+ * @param {string} ref
220
+ * @returns {boolean}
221
+ */
222
+ function looksLikeRef(ref) {
223
+ return ref.length > 0 && ref.length <= 255 && !ref.startsWith('-');
224
+ }
225
+
226
+ /**
227
+ * Turn two refs into the range a review is of.
228
+ *
229
+ * Both are resolved to commit SHAs here, once, and the session carries
230
+ * those rather than the names. A branch that moves under a review in
231
+ * progress would otherwise change what the review is of halfway through,
232
+ * and the comments already written would silently be about a diff that no
233
+ * longer exists.
234
+ *
235
+ * @param {import('simple-git').SimpleGit} git
236
+ * @param {string} base
237
+ * @param {string} head
238
+ * @returns {Promise<import('./lib/sessions').ReviewRange>}
239
+ * @throws {Error} with a message meant for the page, when a ref will not resolve
240
+ */
241
+ async function resolveRange(git, base, head) {
242
+ for (const [label, ref] of [['base', base], ['head', head]]) {
243
+ if (!looksLikeRef(ref)) {
244
+ throw new Error(`That ${label} does not look like a commit or branch.`);
245
+ }
246
+ }
247
+
248
+ const resolve = async (label, ref) => {
249
+ try {
250
+ return (await git.revparse([`${ref}^{commit}`])).trim();
251
+ } catch {
252
+ throw new Error(`No commit or branch called "${ref}" in this repository.`);
253
+ }
254
+ };
255
+
256
+ const baseSha = await resolve('base', base);
257
+ const headSha = await resolve('head', head);
258
+
259
+ // `base..head` counts what head has and base does not, which is the
260
+ // number a reviewer means by "how many commits am I looking at". It is
261
+ // not symmetric, and it is not the same as the file count -- a range
262
+ // whose changes cancel out has commits and no files, which is the whole
263
+ // reason this number is reported.
264
+ const commits = Number(
265
+ (await git.raw(['rev-list', '--count', `${baseSha}..${headSha}`])).trim()
266
+ );
267
+
268
+ return { base: baseSha, head: headSha, commits: Number.isFinite(commits) ? commits : 0 };
269
+ }
270
+
271
+ /**
272
+ * What to tell the reviewer about a range they just opened.
273
+ *
274
+ * The empty case is the one that matters. `git diff x3 x5` over a file
275
+ * added in x4 and deleted in x5 is correctly empty, and an interface that
276
+ * renders that as "no changes" is saying something false about two commits
277
+ * of real work. So the commit count leads, and the absence of files is
278
+ * described rather than left as a blank screen.
279
+ *
280
+ * @param {import('./lib/sessions').ReviewRange} range
281
+ * @param {number} fileCount
282
+ * @returns {string}
283
+ */
284
+ function describeRange(range, fileCount) {
285
+ const commits = `${range.commits} commit${range.commits === 1 ? '' : 's'}`;
286
+
287
+ if (fileCount === 0) {
288
+ return range.commits === 0
289
+ ? 'Those two commits are the same, so there is nothing between them.'
290
+ : `${commits}, and no net change between the two ends — every change in them was undone again inside the range.`;
291
+ }
292
+
293
+ return `${commits}, ${fileCount} changed file${fileCount === 1 ? '' : 's'} between the two ends`;
294
+ }
295
+
210
296
  /** Liveness probe, and a count of how many repositories are open. */
211
297
  app.get('/api/health', (req, res) => {
212
298
  res.json({ status: 'ok', sessions: sessions.size });
@@ -338,6 +424,7 @@ function createApp(options = {}) {
338
424
  // Resolve to the root of the working tree before anything is keyed on
339
425
  // this path. A subdirectory passes `checkIsRepo()` quite happily and
340
426
  // then produces a file list whose diffs are all empty; see lib/repo.js.
427
+ const { base, head } = req.body ?? {};
341
428
  const root = await repoRoot(repoPath, gitFactory);
342
429
  if (!root) {
343
430
  return res.status(400).json({ error: 'Not a valid git repository' });
@@ -345,27 +432,51 @@ function createApp(options = {}) {
345
432
 
346
433
  const git = gitFactory(root);
347
434
 
348
- let files = collectWorkingChanges(await git.status());
349
- let mode = 'working';
350
- let message = `Found ${files.length} changed file(s)`;
435
+ let files;
436
+ let mode;
437
+ let range = null;
438
+ let message;
351
439
 
352
- // A clean tree has nothing to review, so fall back to the last commit.
353
- if (files.length === 0) {
440
+ if (base !== undefined && base !== null && `${base}`.trim() !== '') {
441
+ // An explicit range. Nothing falls back to anything here: someone who
442
+ // asked for x1..x3 and is silently shown their working tree instead
443
+ // has been told a lie about what they are reviewing.
354
444
  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
- }
445
+ range = await resolveRange(git, `${base}`.trim(), `${head ?? 'HEAD'}`.trim());
361
446
  } 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);
447
+ return res.status(400).json({ error: error.message });
448
+ }
449
+
450
+ files = collectCommitChanges(await git.diffSummary([range.base, range.head]));
451
+ mode = 'range';
452
+ message = describeRange(range, files.length);
453
+ } else {
454
+ files = collectWorkingChanges(await git.status());
455
+ mode = 'working';
456
+ message = `Found ${files.length} changed file(s)`;
457
+
458
+ // A clean tree has nothing to review, so fall back to the last commit.
459
+ if (files.length === 0) {
460
+ try {
461
+ const lastCommit = await resolveRange(git, 'HEAD~1', 'HEAD');
462
+ const commitFiles = collectCommitChanges(
463
+ await git.diffSummary([lastCommit.base, lastCommit.head])
464
+ );
465
+ if (commitFiles.length > 0) {
466
+ files = commitFiles;
467
+ mode = 'lastCommit';
468
+ range = lastCommit;
469
+ message = `No working directory changes. Loaded last commit with ${files.length} changed file(s)`;
470
+ }
471
+ } catch (error) {
472
+ // A repository with no commits, or only one, has no parent to diff
473
+ // against. An empty file list is the correct answer there.
474
+ console.error('Error loading last commit:', error.message);
475
+ }
365
476
  }
366
477
  }
367
478
 
368
- const repoId = sessions.create(root, mode);
479
+ const repoId = sessions.create(root, mode, range);
369
480
  // stderr, not stdout. `reviewer | claude -p ...` puts the finished
370
481
  // review on stdout, and anything else written there lands in the middle
371
482
  // of it. Progress is for the person watching; stdout is for the pipe.
@@ -380,7 +491,7 @@ function createApp(options = {}) {
380
491
 
381
492
  // `repoPath` in the response is the resolved root, not what was asked
382
493
  // for, so the page can show which repository it actually opened.
383
- res.json({ repoId, files, repoPath: root, mode, message });
494
+ res.json({ repoId, files, repoPath: root, mode, range, message });
384
495
  } catch (error) {
385
496
  console.error('Load repo error:', error);
386
497
  res.status(500).json({ error: error.message });
@@ -472,6 +583,7 @@ function createApp(options = {}) {
472
583
  repoPath: session.repoPath,
473
584
  lastUpdated: new Date().toISOString(),
474
585
  mode: session.mode,
586
+ range: session.range,
475
587
  comments: normalized
476
588
  };
477
589
 
@@ -535,7 +647,8 @@ function createApp(options = {}) {
535
647
  generatedAt,
536
648
  head,
537
649
  branch,
538
- mode: session.mode
650
+ mode: session.mode,
651
+ range: session.range
539
652
  });
540
653
  const documentFilename = filename.replace(/\.txt$/, '.json');
541
654