git-reviewer 2.13.1 → 2.14.1

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,9 +3,11 @@
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-379-brightgreen.svg)](test/)
7
7
  [![Dependencies](https://img.shields.io/badge/dependencies-3-brightgreen.svg)](package.json)
8
8
 
9
+ **English** · [简体中文](docs/README.zh-CN.md) · [日本語](docs/README.ja.md)
10
+
9
11
  When an agent writes the code, reading it becomes the bottleneck — and the tool
10
12
  built for reading a diff, the pull request, wants a branch, a remote and a push
11
13
  you are not ready to make yet.
@@ -77,6 +79,7 @@ git reviewer export [repository] [--format json|prompt]
77
79
  -p, --port <n> Port to listen on (default 4500; falls back to a free
78
80
  port if that one is taken)
79
81
  --no-open Print the URL instead of opening a browser
82
+ --staged Review only staged changes (HEAD vs index)
80
83
  -f, --format <f> Export format: json (default) or prompt
81
84
  -h, --help Show help
82
85
  -v, --version Show the version
@@ -91,6 +94,15 @@ git reviewer . --port 8080 # somewhere other than 4500
91
94
 
92
95
  Two at once is fine — the second one finds its own port.
93
96
 
97
+ Use `git reviewer --staged` to review what the next commit would contain.
98
+ The file list, diff and full context come from the index, so edits made after
99
+ `git add` stay out of the view. An empty index comparison stays empty; it does
100
+ not fall back to the last commit. The scope bar and exported review identify
101
+ this as a staged review. The default command still shows all working-tree
102
+ changes. As with the default view, reload after changing what is staged. Saved comments
103
+ are shared between views and may refer to code outside the current comparison;
104
+ check their anchors before applying them.
105
+
94
106
  ### When you are not standing in a repository
95
107
 
96
108
  The page opens on a picker rather than on an empty box:
@@ -331,6 +343,32 @@ rather than guess when an anchor has vanished.
331
343
  Submitting in the UI writes this alongside the `.txt`. The full schema is in
332
344
  [docs/agent-format.md](docs/agent-format.md).
333
345
 
346
+ ## Use it from inside your agent
347
+
348
+ Installed as a plugin, the agent opens the review itself, waits while you read
349
+ and comment, and works through your comments the moment you press **Submit
350
+ Review**. You never leave the conversation to start it.
351
+
352
+ ```
353
+ /plugin install git-reviewer --marketplace dheerajjha/reviewer
354
+ ```
355
+
356
+ That is Claude Code; then ask to review the changes, or run
357
+ `/git-reviewer:review`. The same skill installs elsewhere:
358
+
359
+ | Agent | Install |
360
+ |---|---|
361
+ | Codex CLI | `codex plugin marketplace add dheerajjha/reviewer`, then `codex plugin add git-reviewer@git-reviewer` |
362
+ | Cursor | **Customize** → **From GitHub Repository** → `dheerajjha/reviewer` |
363
+ | Copilot CLI | `copilot plugin marketplace add dheerajjha/reviewer`, then `copilot plugin install git-reviewer@git-reviewer` |
364
+ | Anything else | `npx skills add dheerajjha/reviewer` |
365
+
366
+ The skill is plain instructions, [skills/review/SKILL.md](skills/review/SKILL.md):
367
+ start `git reviewer` in the background, wait for the submit, then apply every
368
+ comment, finding the code a comment quotes rather than trusting its line
369
+ number. Where an agent cannot leave a command running, it asks you to start
370
+ `git reviewer` yourself and reads the result with `git reviewer export`.
371
+
334
372
  ## HTTP API
335
373
 
336
374
  The UI is a client of this; nothing is hidden from you.
@@ -372,7 +410,7 @@ rather than a public issue.
372
410
 
373
411
  ```bash
374
412
  npm install # 3 dependencies, no build step, ~6MB
375
- npm test # 359 tests
413
+ npm test # 379 tests
376
414
  npm run test:watch
377
415
  npm run test:coverage
378
416
  ```
@@ -399,7 +437,7 @@ temporary directory. The HTTP tests do not stub git: each one builds a real
399
437
  repository in a temp directory and runs real `git` against it, because diff
400
438
  parsing is exactly where a stub would be wrong in the same way the code is.
401
439
 
402
- Tests run on Linux and macOS across Node 18, 20, and 22. See
440
+ Tests run on Linux, macOS and Windows across Node 18, 20, and 22. See
403
441
  [CONTRIBUTING.md](CONTRIBUTING.md).
404
442
 
405
443
  ## Tech
@@ -423,11 +461,18 @@ The ones most likely to change how the tool feels:
423
461
  - [#2](https://github.com/dheerajjha/reviewer/issues/2) — give comments a state, so a review can be worked through and marked off
424
462
  - [#3](https://github.com/dheerajjha/reviewer/issues/3) — report whether each anchor still matches the file
425
463
  - [#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
464
+ - [#6](https://github.com/dheerajjha/reviewer/issues/6) — open straight into a comparison from the command line, `git reviewer --range main...HEAD`
427
465
 
428
466
  [CONTRIBUTING.md](CONTRIBUTING.md) covers the setup and what a good change looks
429
467
  like here.
430
468
 
469
+ ## Related
470
+
471
+ [margins](https://github.com/dheerajjha/margins) is its sibling for notes rather
472
+ than diffs: `margins` in a folder of markdown opens it in the browser, with a
473
+ file tree, `[[wikilinks]]` and backlinks, search, and editing with a live
474
+ preview. `npm install -g margins`.
475
+
431
476
  ## License
432
477
 
433
478
  [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/export.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const fs = require('fs').promises;
4
4
  const path = require('path');
5
5
 
6
- const simpleGit = require('simple-git');
6
+ const { simpleGit } = require('simple-git');
7
7
 
8
8
  const { buildReviewDocument, filterReviewDocument } = require('./agent');
9
9
  const { readSavedComments, belongsTo } = require('./store');
package/lib/repo.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  const path = require('path');
4
4
 
5
- const simpleGit = require('simple-git');
5
+ const { simpleGit } = require('simple-git');
6
6
 
7
7
  /**
8
8
  * Finding the repository a directory belongs to.
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.1",
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",
@@ -51,7 +51,7 @@
51
51
  "dependencies": {
52
52
  "cors": "^2.8.5",
53
53
  "express": "^4.18.2",
54
- "simple-git": "^3.20.0"
54
+ "simple-git": "^4.0.2"
55
55
  },
56
56
  "man": [
57
57
  "./man/git-reviewer.1"
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  const express = require('express');
4
4
  const cors = require('cors');
5
- const simpleGit = require('simple-git');
5
+ const { simpleGit } = require('simple-git');
6
6
  const fs = require('fs').promises;
7
7
  const path = require('path');
8
8
 
@@ -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');
@@ -26,6 +26,39 @@ const { listReviews } = require('./lib/export');
26
26
  const DEFAULT_PORT = 4500;
27
27
  const DEFAULT_HOST = '127.0.0.1';
28
28
 
29
+ // Addresses that only this machine can reach. Bound to one of these, the
30
+ // server installs `loopbackHostOnly`.
31
+ const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', 'localhost', '::1']);
32
+
33
+ // The names a browser puts in `Host` for a server it reached on loopback.
34
+ // An IPv6 address keeps its brackets there.
35
+ const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '[::1]']);
36
+
37
+ /** The host name in a `Host` header, without the port. */
38
+ function hostnameOf(header) {
39
+ const value = String(header || '').toLowerCase();
40
+ if (value.startsWith('[')) return value.slice(0, value.indexOf(']') + 1);
41
+ return value.split(':')[0];
42
+ }
43
+
44
+ /**
45
+ * Refuse a request addressed to this server by any name but a loopback one.
46
+ *
47
+ * That is DNS rebinding: a page on the attacker's own domain whose name is
48
+ * re-pointed at 127.0.0.1 after it loads. The browser then treats this server
49
+ * as that site, so `Origin` and `Host` agree and `sameOriginOnly` lets the
50
+ * request through. A server reachable only on loopback is only ever honestly
51
+ * addressed by a loopback name. Bound anywhere else (`HOST=0.0.0.0`), its
52
+ * owner has chosen to be reached by other names, and this is not installed.
53
+ */
54
+ function loopbackHostOnly(req, res, next) {
55
+ if (!LOOPBACK_HOSTNAMES.has(hostnameOf(req.headers.host))) {
56
+ return res.status(403).json({ error: 'Not available under this host name' });
57
+ }
58
+
59
+ next();
60
+ }
61
+
29
62
 
30
63
  /** A path inside the repository that names nothing, on disk or at HEAD. */
31
64
  class FileNotFoundError extends Error {
@@ -60,6 +93,8 @@ class FileNotFoundError extends Error {
60
93
  * stop. Omitted means never: a server with no one to tell keeps serving.
61
94
  * @param {number} [options.idleGraceMs] how long to wait after the last tab
62
95
  * leaves before deciding it is not coming back.
96
+ * @param {boolean} [options.loopbackOnly] refuse any request whose `Host` is
97
+ * not a loopback name. `startServer` turns it on when it binds to loopback.
63
98
  * @returns {import('express').Express}
64
99
  */
65
100
  // How far back the range picker offers to go. Long enough to cover the work
@@ -86,12 +121,15 @@ function createApp(options = {}) {
86
121
  onReviewSubmitted = null,
87
122
  handoff = false,
88
123
  onIdle = null,
89
- idleGraceMs = 5000
124
+ idleGraceMs = 5000,
125
+ loopbackOnly = false
90
126
  } = options;
91
127
 
92
128
  const app = express();
93
129
 
130
+ if (loopbackOnly) app.use(loopbackHostOnly);
94
131
  app.use(cors());
132
+ app.use('/api', sameOriginOnly);
95
133
  app.use(express.json({ limit: '10mb' }));
96
134
  app.use(express.static(path.join(__dirname, 'public')));
97
135
 
@@ -137,6 +175,18 @@ function createApp(options = {}) {
137
175
  const git = gitFactory(session.repoPath);
138
176
  const absolutePath = resolveRepoFile(session.repoPath, filePath);
139
177
 
178
+ if (session.mode === 'staged') {
179
+ try {
180
+ // `:0:` names the stage outright. A bare `:` reads a file called
181
+ // `0:foo` as stage 0 of `foo`, and shows the wrong file's text.
182
+ return await git.show([`:0:${filePath}`]);
183
+ } catch {
184
+ // A staged deletion has no index entry. Its old content comes from
185
+ // HEAD, even if a replacement now exists in the working tree.
186
+ return git.show([`HEAD:${filePath}`]).catch(() => { throw new FileNotFoundError(filePath); });
187
+ }
188
+ }
189
+
140
190
  if (session.range) {
141
191
  // A range is two commits, and the working tree is neither of them. It
142
192
  // used to be the fallback here, which was merely odd while the head of
@@ -222,6 +272,9 @@ function createApp(options = {}) {
222
272
  }
223
273
 
224
274
  async function diffForFile(git, session, filePath) {
275
+ if (session.mode === 'staged') {
276
+ return git.diff(['--cached', '--', filePath]);
277
+ }
225
278
  if (session.range) {
226
279
  return git.diff([session.range.from, session.range.head, '--', filePath]);
227
280
  }
@@ -645,20 +698,37 @@ function createApp(options = {}) {
645
698
  /**
646
699
  * Refuse a request a browser has marked as coming from somewhere else.
647
700
  *
648
- * `cors()` above is open, deliberately, and that is a settled decision this
649
- * does not reopen. It is a rule for the two endpoints below, which differ
650
- * from every other one in kind: they enumerate directories and name
651
- * projects, rather than answering about a repository whose path the caller
652
- * already had. A browser sends `Origin` on exactly the requests where that
653
- * distinction matters and omits it on same-origin GETs, so the page keeps
654
- * working and a page on another site does not. Anything that is not a
655
- * browser can read these directories without asking this server anyway.
701
+ * Every `/api` route goes through this (#66). Until 2.14.1 only the routes
702
+ * that enumerate directories did, on the reasoning that the rest answer
703
+ * about a repository whose path the caller already had. A path is easy to
704
+ * guess, though, and `cors()` is open, so a page on another site could read
705
+ * any file in a guessed repository. Worse, it could save a comment into the
706
+ * review, which `reviewer export` then hands to the coding agent as an
707
+ * instruction.
708
+ *
709
+ * A browser sends `Origin` on every cross-origin request that could do
710
+ * either, and leaves it out of the page's own same-origin GETs, so the page
711
+ * keeps working. A client that is not a browser sends no `Origin` and gets
712
+ * through: it could read these files without asking this server anyway.
713
+ * Only the host is compared, not the scheme, so the page still works behind
714
+ * a proxy that terminates TLS.
715
+ *
716
+ * This cannot catch DNS rebinding, where `Origin` and `Host` both name the
717
+ * attacker's domain. `loopbackHostOnly` does.
656
718
  */
657
719
  function sameOriginOnly(req, res, next) {
658
720
  const origin = req.get('origin');
659
721
 
660
- if (origin && origin !== `http://${req.headers.host}`) {
661
- return res.status(403).json({ error: 'Not available to another origin' });
722
+ if (origin) {
723
+ let originHost = null;
724
+ try {
725
+ originHost = new URL(origin).host;
726
+ } catch {
727
+ // `Origin: null` (a sandboxed frame, a file:// page) names no host.
728
+ }
729
+ if (originHost !== req.headers.host) {
730
+ return res.status(403).json({ error: 'Not available to another origin' });
731
+ }
662
732
  }
663
733
 
664
734
  next();
@@ -671,9 +741,9 @@ function createApp(options = {}) {
671
741
  * The connection *is* the signal. An open page holds this response open and
672
742
  * closing the page closes it, so there is no heartbeat interval to tune, and
673
743
  * two tabs are two connections rather than one flag that either close
674
- * clears. `sameOriginOnly` is here for the same reason it is on the two
675
- * below: without it a page on another site could hold this open and keep a
676
- * server alive that its owner had finished with.
744
+ * clears. Like every `/api` route it goes through `sameOriginOnly`, which
745
+ * matters here too: without it a page on another site could hold this open
746
+ * and keep alive a server its owner had finished with.
677
747
  *
678
748
  * Nothing happens at the moment a watcher leaves. A reload closes and
679
749
  * reopens within milliseconds, and a dropped connection reopens when
@@ -686,7 +756,7 @@ function createApp(options = {}) {
686
756
  * after having been above it, so `reviewer` whose browser never launched
687
757
  * keeps serving rather than exiting into an empty terminal.
688
758
  */
689
- app.get('/api/alive', sameOriginOnly, (req, res) => {
759
+ app.get('/api/alive', (req, res) => {
690
760
  if (idleTimer) {
691
761
  clearTimeout(idleTimer);
692
762
  idleTimer = null;
@@ -718,7 +788,7 @@ function createApp(options = {}) {
718
788
  });
719
789
 
720
790
  /** List the directories inside one, so a repository can be found by looking. */
721
- app.get('/api/browse', sameOriginOnly, async (req, res) => {
791
+ app.get('/api/browse', async (req, res) => {
722
792
  try {
723
793
  res.json(await browse(req.query.path));
724
794
  } catch (error) {
@@ -735,7 +805,7 @@ function createApp(options = {}) {
735
805
  });
736
806
 
737
807
  /** Repositories opened before now, newest first. */
738
- app.get('/api/recent', sameOriginOnly, async (req, res) => {
808
+ app.get('/api/recent', async (req, res) => {
739
809
  try {
740
810
  // Annotated with what is saved against each, because "3 comments" is
741
811
  // what tells you which of two checkouts you were in the middle of.
@@ -768,7 +838,10 @@ function createApp(options = {}) {
768
838
  // Resolve to the root of the working tree before anything is keyed on
769
839
  // this path. A subdirectory passes `checkIsRepo()` quite happily and
770
840
  // then produces a file list whose diffs are all empty; see lib/repo.js.
771
- const { base, head, commits: run } = req.body ?? {};
841
+ const { base, head, commits: run, staged = false } = req.body ?? {};
842
+ if (typeof staged !== 'boolean' || (staged && (base != null || head != null || run != null))) {
843
+ return res.status(400).json({ error: 'Choose staged changes or a commit comparison, not both.' });
844
+ }
772
845
  const root = await repoRoot(repoPath, gitFactory);
773
846
  if (!root) {
774
847
  return res.status(400).json({ error: 'Not a valid git repository' });
@@ -781,7 +854,15 @@ function createApp(options = {}) {
781
854
  let range = null;
782
855
  let message;
783
856
 
784
- if (run && typeof run === 'object') {
857
+ if (staged) {
858
+ try {
859
+ files = await collectStagedChanges(git);
860
+ } catch (error) {
861
+ return res.status(400).json({ error: error.message });
862
+ }
863
+ mode = 'staged';
864
+ message = files.length ? `Found ${files.length} staged file(s)` : 'No staged changes';
865
+ } else if (run && typeof run === 'object') {
785
866
  // Commits themselves, one or a run: see resolveCommitRun.
786
867
  try {
787
868
  range = await resolveCommitRun(git, `${run.from ?? ''}`.trim(), `${run.to ?? run.from ?? ''}`.trim());
@@ -1083,8 +1164,9 @@ function startServer(options = {}) {
1083
1164
  ...appOptions
1084
1165
  } = options;
1085
1166
  // Everything else in `options` -- reviewsDir, sessions, git, handoff and
1086
- // onReviewSubmitted -- goes through to the app.
1087
- const app = createApp(appOptions);
1167
+ // onReviewSubmitted -- goes through to the app. Bound to loopback, the app
1168
+ // also refuses any Host but a loopback name (see `loopbackHostOnly`).
1169
+ const app = createApp({ loopbackOnly: LOOPBACK_ADDRESSES.has(host), ...appOptions });
1088
1170
 
1089
1171
  return new Promise((resolve, reject) => {
1090
1172
  const server = app.listen(port, host, () => {