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 +49 -4
- package/bin/reviewer.js +1 -1
- package/lib/agent.js +5 -1
- package/lib/changes.js +17 -0
- package/lib/cli.js +15 -3
- package/lib/export.js +1 -1
- package/lib/repo.js +1 -1
- package/lib/sessions.js +2 -1
- package/man/git-reviewer.1 +4 -0
- package/package.json +2 -2
- package/public/app.js +6 -5
- package/server.js +105 -23
package/README.md
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
[](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](package.json)
|
|
6
|
-
[](test/)
|
|
7
7
|
[](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 #
|
|
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
|
|
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) —
|
|
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
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
|
/**
|
package/man/git-reviewer.1
CHANGED
|
@@ -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.
|
|
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": "^
|
|
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
|
-
* `
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
655
|
-
*
|
|
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
|
|
661
|
-
|
|
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.
|
|
675
|
-
*
|
|
676
|
-
*
|
|
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',
|
|
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',
|
|
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',
|
|
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 (
|
|
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
|
-
|
|
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, () => {
|