git-reviewer 2.13.1 → 2.14.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-359-brightgreen.svg)](test/)
6
+ [![Tests](https://img.shields.io/badge/tests-372-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
@@ -77,6 +77,7 @@ git reviewer export [repository] [--format json|prompt]
77
77
  -p, --port <n> Port to listen on (default 4500; falls back to a free
78
78
  port if that one is taken)
79
79
  --no-open Print the URL instead of opening a browser
80
+ --staged Review only staged changes (HEAD vs index)
80
81
  -f, --format <f> Export format: json (default) or prompt
81
82
  -h, --help Show help
82
83
  -v, --version Show the version
@@ -91,6 +92,15 @@ git reviewer . --port 8080 # somewhere other than 4500
91
92
 
92
93
  Two at once is fine — the second one finds its own port.
93
94
 
95
+ Use `git reviewer --staged` to review what the next commit would contain.
96
+ The file list, diff and full context come from the index, so edits made after
97
+ `git add` stay out of the view. An empty index comparison stays empty; it does
98
+ not fall back to the last commit. The scope bar and exported review identify
99
+ this as a staged review. The default command still shows all working-tree
100
+ changes. As with the default view, reload after changing what is staged. Saved comments
101
+ are shared between views and may refer to code outside the current comparison;
102
+ check their anchors before applying them.
103
+
94
104
  ### When you are not standing in a repository
95
105
 
96
106
  The page opens on a picker rather than on an empty box:
@@ -372,7 +382,7 @@ rather than a public issue.
372
382
 
373
383
  ```bash
374
384
  npm install # 3 dependencies, no build step, ~6MB
375
- npm test # 359 tests
385
+ npm test # 372 tests
376
386
  npm run test:watch
377
387
  npm run test:coverage
378
388
  ```
@@ -399,7 +409,7 @@ temporary directory. The HTTP tests do not stub git: each one builds a real
399
409
  repository in a temp directory and runs real `git` against it, because diff
400
410
  parsing is exactly where a stub would be wrong in the same way the code is.
401
411
 
402
- Tests run on Linux and macOS across Node 18, 20, and 22. See
412
+ Tests run on Linux, macOS and Windows across Node 18, 20, and 22. See
403
413
  [CONTRIBUTING.md](CONTRIBUTING.md).
404
414
 
405
415
  ## Tech
@@ -423,11 +433,18 @@ The ones most likely to change how the tool feels:
423
433
  - [#2](https://github.com/dheerajjha/reviewer/issues/2) — give comments a state, so a review can be worked through and marked off
424
434
  - [#3](https://github.com/dheerajjha/reviewer/issues/3) — report whether each anchor still matches the file
425
435
  - [#4](https://github.com/dheerajjha/reviewer/issues/4) — expose the review over MCP, so an agent works through it interactively
426
- - [#6](https://github.com/dheerajjha/reviewer/issues/6) — review a commit range, not just the working tree
436
+ - [#6](https://github.com/dheerajjha/reviewer/issues/6) — open straight into a comparison from the command line, `git reviewer --range main...HEAD`
427
437
 
428
438
  [CONTRIBUTING.md](CONTRIBUTING.md) covers the setup and what a good change looks
429
439
  like here.
430
440
 
441
+ ## Related
442
+
443
+ [margins](https://github.com/dheerajjha/margins) is its sibling for notes rather
444
+ than diffs: `margins` in a folder of markdown opens it in the browser, with a
445
+ file tree, `[[wikilinks]]` and backlinks, search, and editing with a live
446
+ preview. `npm install -g margins`.
447
+
431
448
  ## License
432
449
 
433
450
  [MIT](LICENSE) © Dheeraj Jha
package/bin/reviewer.js CHANGED
@@ -250,7 +250,7 @@ async function main() {
250
250
  options.port ?? (Number(process.env.PORT) || DEFAULT_PORT),
251
251
  { onReviewSubmitted, handoff: handingOff, onIdle }
252
252
  );
253
- const url = buildUrl(`http://${DEFAULT_HOST}:${server.address().port}`, repoPath);
253
+ const url = buildUrl(`http://${DEFAULT_HOST}:${server.address().port}`, repoPath, options.staged);
254
254
 
255
255
  note(`\n Code Reviewer ${url}`);
256
256
  if (repoPath) note(` reviewing ${repoPath}`);
package/lib/agent.js CHANGED
@@ -22,7 +22,7 @@ const SCHEMA = 'code-review/v1';
22
22
  * @property {string} schema
23
23
  * @property {string} generatedAt ISO 8601
24
24
  * @property {{path: string, name: string, head: string|null, branch: string|null}} repository
25
- * @property {'working'|'lastCommit'|null} mode
25
+ * @property {'working'|'staged'|'lastCommit'|'range'|null} mode
26
26
  * @property {{comments: number, files: number}} summary
27
27
  * @property {AgentComment[]} comments
28
28
  */
@@ -205,6 +205,10 @@ 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 === 'staged') {
209
+ return 'Comparison: staged changes in the index, against HEAD. Saved comments are shared between review views and may refer to changes outside this comparison; check their anchors before applying them.';
210
+ }
211
+
208
212
  if (mode === 'range' && range?.kind === 'commits') {
209
213
  // Commits themselves, picked from a list. Both ends are *in* the review,
210
214
  // which is the difference from a comparison and worth saying: an agent
package/lib/changes.js CHANGED
@@ -268,7 +268,24 @@ function collectCommitChanges(diffSummary, statuses) {
268
268
  });
269
269
  }
270
270
 
271
+ /**
272
+ * Files in the next commit, including their index status rather than any
273
+ * later working-tree edits. Omitting HEAD also works before the first commit.
274
+ * @param {import('simple-git').SimpleGit} git
275
+ */
276
+ async function collectStagedChanges(git) {
277
+ if (await git.raw(['ls-files', '--unmerged', '-z'])) {
278
+ throw new Error('Resolve merge conflicts before reviewing staged changes.');
279
+ }
280
+ const [summary, raw] = await Promise.all([
281
+ git.diffSummary(['--cached']),
282
+ git.raw(['diff', '--cached', '--name-status', '-z', '-M'])
283
+ ]);
284
+ return collectCommitChanges(summary, parseNameStatus(raw));
285
+ }
286
+
271
287
  module.exports = {
288
+ collectStagedChanges,
272
289
  collectWorkingChanges,
273
290
  collectCommitChanges,
274
291
  classifyCommitFile,
package/lib/cli.js CHANGED
@@ -43,6 +43,7 @@ Options:
43
43
  -p, --port <n> Port to listen on (default 4500; falls back to a free
44
44
  port if that one is taken)
45
45
  --no-open Print the URL instead of opening a browser
46
+ --staged Review only staged changes (HEAD vs index)
46
47
  -f, --format <f> Export format: json (default) or prompt
47
48
  --file <path> Export comments for one repo-relative file
48
49
  -h, --help Show this help
@@ -83,6 +84,7 @@ class UsageError extends Error {
83
84
  * @property {string|null} repoPath absolute path, or null when not given
84
85
  * @property {number|null} port explicit port, or null to use the default
85
86
  * @property {boolean} open whether to launch a browser
87
+ * @property {boolean} staged whether to review only the index
86
88
  * @property {'json'|'prompt'} format for `export`
87
89
  * @property {string|null} file repo-relative file to export, or all files
88
90
  * @property {boolean} help
@@ -104,6 +106,7 @@ function parseArgs(argv, cwd = process.cwd()) {
104
106
  repoPath: null,
105
107
  port: null,
106
108
  open: true,
109
+ staged: false,
107
110
  format: 'json',
108
111
  file: null,
109
112
  help: false,
@@ -138,6 +141,10 @@ function parseArgs(argv, cwd = process.cwd()) {
138
141
  options.open = false;
139
142
  break;
140
143
 
144
+ case '--staged':
145
+ options.staged = true;
146
+ break;
147
+
141
148
  case '-p':
142
149
  case '--port': {
143
150
  const value = rest[++i];
@@ -201,6 +208,10 @@ function parseArgs(argv, cwd = process.cwd()) {
201
208
  throw new UsageError('--file is only valid with reviewer export');
202
209
  }
203
210
 
211
+ if (options.staged && options.command !== 'serve') {
212
+ throw new UsageError('--staged is only valid when serving a review');
213
+ }
214
+
204
215
  return options;
205
216
  }
206
217
 
@@ -249,10 +260,11 @@ function parseFile(value, flag) {
249
260
  *
250
261
  * @param {string} origin e.g. http://127.0.0.1:4500
251
262
  * @param {string|null} repoPath
263
+ * @param {boolean} [staged]
252
264
  * @returns {string}
253
265
  */
254
- function buildUrl(origin, repoPath) {
255
- if (!repoPath) return origin;
266
+ function buildUrl(origin, repoPath, staged = false) {
267
+ if (!repoPath) return staged ? `${origin}/?staged=1` : origin;
256
268
 
257
269
  // Slashes are left as slashes. They are legal in a query value -- RFC 3986
258
270
  // has `query = *( pchar / "/" / "?" )` -- and encoding them turns the most
@@ -263,7 +275,7 @@ function buildUrl(origin, repoPath) {
263
275
  //
264
276
  // Everything else stays encoded, including the characters that would
265
277
  // otherwise end the query and take the rest of the path with them.
266
- return `${origin}/?repo=${encodeURIComponent(repoPath).replace(/%2F/g, '/')}`;
278
+ return `${origin}/?repo=${encodeURIComponent(repoPath).replace(/%2F/g, '/')}${staged ? '&staged=1' : ''}`;
267
279
  }
268
280
 
269
281
  /**
package/lib/sessions.js CHANGED
@@ -11,10 +11,11 @@ const crypto = require('crypto');
11
11
  */
12
12
 
13
13
  /**
14
- * @typedef {'working'|'lastCommit'|'range'} ReviewMode
14
+ * @typedef {'working'|'staged'|'lastCommit'|'range'} ReviewMode
15
15
  * `working` diffs the working tree against HEAD; `lastCommit` diffs HEAD
16
16
  * against its parent, which is what a clean tree falls back to; `range`
17
17
  * diffs two refs the reviewer chose.
18
+ * `staged` diffs the index against HEAD without including working-tree edits.
18
19
  */
19
20
 
20
21
  /**
@@ -51,6 +51,10 @@ Port to listen on. The default is 4500; if it is taken, a free one is used.
51
51
  .B \-\-no\-open
52
52
  Print the address instead of opening a browser.
53
53
  .TP
54
+ .B \-\-staged
55
+ Review only staged changes, comparing HEAD with the index. Unstaged edits are
56
+ excluded, and an empty index comparison does not fall back to the last commit.
57
+ .TP
54
58
  .BR \-f ", " \-\-format " \fIjson\fR|\fIprompt\fR"
55
59
  Export format. The default is json.
56
60
  .TP
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-reviewer",
3
- "version": "2.13.1",
3
+ "version": "2.14.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
@@ -2,6 +2,7 @@
2
2
  // In web mode, this will be http://localhost:4500
3
3
  // In Electron, this will be the random port Electron assigned
4
4
  const API_BASE = `${window.location.origin}/api`;
5
+ const stagedByDefault = new URLSearchParams(window.location.search).get('staged') === '1';
5
6
 
6
7
  let currentRepoId = null;
7
8
  let currentFiles = [];
@@ -492,10 +493,10 @@ function renderScope(data) {
492
493
  document.getElementById('nextCommitBtn').disabled = true;
493
494
  }
494
495
  } else {
495
- label.append('Uncommitted changes');
496
+ label.append(data.mode === 'staged' ? 'Staged changes' : 'Uncommitted changes');
496
497
  if (branch) label.append(' on ', strong(branch));
497
498
  meta.textContent = plural(data.files.length, 'file') + ' changed';
498
- setNote(null);
499
+ setNote(data.mode === 'staged' ? 'The diff excludes unstaged changes. Saved comments are shared between views and may refer to other code; check their anchors.' : null);
499
500
  }
500
501
 
501
502
  row.classList.add('hidden');
@@ -781,7 +782,7 @@ async function loadRepo(scope = {}) {
781
782
  body: JSON.stringify(
782
783
  scope.commits ? { repoPath, commits: scope.commits }
783
784
  : scope.base ? { repoPath, base: scope.base, ...(scope.head ? { head: scope.head } : {}) }
784
- : { repoPath }
785
+ : { repoPath, staged: stagedByDefault }
785
786
  )
786
787
  });
787
788
 
@@ -817,13 +818,13 @@ async function loadRepo(scope = {}) {
817
818
  // `message`, and the scope bar above is still showing how many commits
818
819
  // that covers -- so this is information, not an error, and it must not
819
820
  // be dressed as one.
820
- if (data.mode === 'range') {
821
+ if (data.mode === 'range' || data.mode === 'staged') {
821
822
  // The scope bar is already explaining this; a status line repeating
822
823
  // it underneath is two bars saying one thing.
823
824
  clearStatus();
824
825
  hidePicker();
825
826
  displayFiles([]);
826
- showEmptyFileList(data.range?.kind !== 'commits'
827
+ showEmptyFileList(data.mode === 'staged' ? 'No staged changes.' : data.range?.kind !== 'commits'
827
828
  ? 'No files differ between these two.'
828
829
  : data.range.first === data.range.last
829
830
  ? 'This commit changes no files.'
package/server.js CHANGED
@@ -12,7 +12,7 @@ const {
12
12
  resolveRepoFile,
13
13
  PathEscapeError
14
14
  } = require('./lib/paths');
15
- const { collectWorkingChanges, collectCommitChanges, parseNameStatus } = require('./lib/changes');
15
+ const { collectWorkingChanges, collectStagedChanges, collectCommitChanges, parseNameStatus } = require('./lib/changes');
16
16
  const { SessionStore } = require('./lib/sessions');
17
17
  const { normalizeComments } = require('./lib/comments');
18
18
  const { formatReview, reviewFilename, commentsFilename } = require('./lib/review');
@@ -137,6 +137,18 @@ function createApp(options = {}) {
137
137
  const git = gitFactory(session.repoPath);
138
138
  const absolutePath = resolveRepoFile(session.repoPath, filePath);
139
139
 
140
+ if (session.mode === 'staged') {
141
+ try {
142
+ // `:0:` names the stage outright. A bare `:` reads a file called
143
+ // `0:foo` as stage 0 of `foo`, and shows the wrong file's text.
144
+ return await git.show([`:0:${filePath}`]);
145
+ } catch {
146
+ // A staged deletion has no index entry. Its old content comes from
147
+ // HEAD, even if a replacement now exists in the working tree.
148
+ return git.show([`HEAD:${filePath}`]).catch(() => { throw new FileNotFoundError(filePath); });
149
+ }
150
+ }
151
+
140
152
  if (session.range) {
141
153
  // A range is two commits, and the working tree is neither of them. It
142
154
  // used to be the fallback here, which was merely odd while the head of
@@ -222,6 +234,9 @@ function createApp(options = {}) {
222
234
  }
223
235
 
224
236
  async function diffForFile(git, session, filePath) {
237
+ if (session.mode === 'staged') {
238
+ return git.diff(['--cached', '--', filePath]);
239
+ }
225
240
  if (session.range) {
226
241
  return git.diff([session.range.from, session.range.head, '--', filePath]);
227
242
  }
@@ -768,7 +783,10 @@ function createApp(options = {}) {
768
783
  // Resolve to the root of the working tree before anything is keyed on
769
784
  // this path. A subdirectory passes `checkIsRepo()` quite happily and
770
785
  // then produces a file list whose diffs are all empty; see lib/repo.js.
771
- const { base, head, commits: run } = req.body ?? {};
786
+ const { base, head, commits: run, staged = false } = req.body ?? {};
787
+ if (typeof staged !== 'boolean' || (staged && (base != null || head != null || run != null))) {
788
+ return res.status(400).json({ error: 'Choose staged changes or a commit comparison, not both.' });
789
+ }
772
790
  const root = await repoRoot(repoPath, gitFactory);
773
791
  if (!root) {
774
792
  return res.status(400).json({ error: 'Not a valid git repository' });
@@ -781,7 +799,15 @@ function createApp(options = {}) {
781
799
  let range = null;
782
800
  let message;
783
801
 
784
- if (run && typeof run === 'object') {
802
+ if (staged) {
803
+ try {
804
+ files = await collectStagedChanges(git);
805
+ } catch (error) {
806
+ return res.status(400).json({ error: error.message });
807
+ }
808
+ mode = 'staged';
809
+ message = files.length ? `Found ${files.length} staged file(s)` : 'No staged changes';
810
+ } else if (run && typeof run === 'object') {
785
811
  // Commits themselves, one or a run: see resolveCommitRun.
786
812
  try {
787
813
  range = await resolveCommitRun(git, `${run.from ?? ''}`.trim(), `${run.to ?? run.from ?? ''}`.trim());