git-reviewer 2.14.0 → 2.15.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,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-372-brightgreen.svg)](test/)
6
+ [![Tests](https://img.shields.io/badge/tests-415-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,7 +79,9 @@ 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
+ --no-intro Open the review without the first-run help
80
83
  --staged Review only staged changes (HEAD vs index)
84
+ --no-untracked Review tracked working-tree changes only
81
85
  -f, --format <f> Export format: json (default) or prompt
82
86
  -h, --help Show help
83
87
  -v, --version Show the version
@@ -101,6 +105,19 @@ changes. As with the default view, reload after changing what is staged. Saved c
101
105
  are shared between views and may refer to code outside the current comparison;
102
106
  check their anchors before applying them.
103
107
 
108
+ Wholly untracked directories with more than 200 files appear as one directory
109
+ row with a file count and no diff. Smaller directories stay reviewable file by
110
+ file. Tracked changes are always listed individually, and `.gitignore` still
111
+ applies. The scope note names any folded directories and their counts.
112
+ The headline counts individual files separately from folded folders and the
113
+ files inside them. Tracked changes appear first; reloading keeps your selection.
114
+
115
+ Use `git reviewer --no-untracked` to exclude all untracked files. If there are
116
+ no tracked working-tree changes, the view stays empty instead of loading the
117
+ last commit. Staged and commit comparisons keep their usual behavior.
118
+ Saved reviews and exports retain this tracked-only scope. The text review and
119
+ agent prompt explain the exclusion; older comments remain shared between views.
120
+
104
121
  ### When you are not standing in a repository
105
122
 
106
123
  The page opens on a picker rather than on an empty box:
@@ -341,6 +358,32 @@ rather than guess when an anchor has vanished.
341
358
  Submitting in the UI writes this alongside the `.txt`. The full schema is in
342
359
  [docs/agent-format.md](docs/agent-format.md).
343
360
 
361
+ ## Use it from inside your agent
362
+
363
+ Installed as a plugin, the agent opens the review itself, waits while you read
364
+ and comment, and works through your comments the moment you press **Submit
365
+ Review**. You never leave the conversation to start it.
366
+
367
+ ```
368
+ /plugin install git-reviewer --marketplace dheerajjha/reviewer
369
+ ```
370
+
371
+ That is Claude Code; then ask to review the changes, or run
372
+ `/git-reviewer:review`. The same skill installs elsewhere:
373
+
374
+ | Agent | Install |
375
+ |---|---|
376
+ | Codex CLI | `codex plugin marketplace add dheerajjha/reviewer`, then `codex plugin add git-reviewer@git-reviewer` |
377
+ | Cursor | **Customize** → **From GitHub Repository** → `dheerajjha/reviewer` |
378
+ | Copilot CLI | `copilot plugin marketplace add dheerajjha/reviewer`, then `copilot plugin install git-reviewer@git-reviewer` |
379
+ | Anything else | `npx skills add dheerajjha/reviewer` |
380
+
381
+ The skill is plain instructions, [skills/review/SKILL.md](skills/review/SKILL.md):
382
+ start `git reviewer` in the background, wait for the submit, then apply every
383
+ comment, finding the code a comment quotes rather than trusting its line
384
+ number. Where an agent cannot leave a command running, it asks you to start
385
+ `git reviewer` yourself and reads the result with `git reviewer export`.
386
+
344
387
  ## HTTP API
345
388
 
346
389
  The UI is a client of this; nothing is hidden from you.
@@ -360,6 +403,48 @@ The UI is a client of this; nothing is hidden from you.
360
403
  | `POST /api/submit-review` | Render the saved comments to a review file |
361
404
  | `DELETE /api/cleanup/:repoId` | End the session; files on disk are untouched |
362
405
 
406
+ ### POST request bodies
407
+
408
+ `POST /api/load-repo` opens a repository. `repoPath` is required; choose a comparison or staged changes:
409
+
410
+ ```json
411
+ {"repoPath":"/home/you/work/api","base":"main","head":"feature"}
412
+ ```
413
+
414
+ To review a commit range, use `"commits": {"from":"abc123","to":"def456"}` instead of `base`/`head`. To review staged changes, use `"staged": true` without comparison fields. `head` is optional when using `base`.
415
+
416
+ For tracked working-tree changes only, send `"untracked": false`. It defaults
417
+ to `true`. Folded directory entries carry `directory: true` and `fileCount`;
418
+ they have no file diff.
419
+
420
+ `POST /api/save-comments` persists comments for an open session:
421
+
422
+ ```json
423
+ {
424
+ "repoId": "<repoId returned by load-repo>",
425
+ "comments": [
426
+ {
427
+ "file": "src/app.js",
428
+ "line": 12,
429
+ "lineContent": "const value = 1;",
430
+ "text": "Consider renaming this variable.",
431
+ "selectedText": "const value",
432
+ "followUps": [{"text":"Please also update its uses.","timestamp":"2026-01-01T00:00:00.000Z"}]
433
+ }
434
+ ]
435
+ }
436
+ ```
437
+
438
+ Each comment requires `file` (non-empty string), `line` (positive integer), and `text` (non-empty string). `lineContent`, `selectedText`, and `followUps` are optional.
439
+
440
+ `POST /api/submit-review` renders the previously saved comments:
441
+
442
+ ```json
443
+ {"repoId":"<repoId returned by load-repo>"}
444
+ ```
445
+
446
+ The startup `--json` output does not include `repoId`: no session exists at startup. A client obtains its own `repoId` by calling `POST /api/load-repo`.
447
+
363
448
  A `repoId` is a random per-session handle, not a path. Requests that resolve
364
449
  outside the opened repository are refused, and a path that names nothing gets a
365
450
  404.
@@ -382,7 +467,7 @@ rather than a public issue.
382
467
 
383
468
  ```bash
384
469
  npm install # 3 dependencies, no build step, ~6MB
385
- npm test # 372 tests
470
+ npm test # 415 tests
386
471
  npm run test:watch
387
472
  npm run test:coverage
388
473
  ```
package/bin/reviewer.js CHANGED
@@ -92,6 +92,9 @@ async function exportReview(options) {
92
92
  error instanceof NoMatchingCommentsError ||
93
93
  error instanceof ReviewOwnershipError
94
94
  ) {
95
+ if (repoPath === given && error instanceof NoReviewError && !(await repoRoot(given))) {
96
+ throw new UsageError(`${given} is not inside a git repository, and no review is saved for it.`);
97
+ }
95
98
  throw new UsageError(error.message);
96
99
  }
97
100
  throw error;
@@ -250,13 +253,34 @@ async function main() {
250
253
  options.port ?? (Number(process.env.PORT) || DEFAULT_PORT),
251
254
  { onReviewSubmitted, handoff: handingOff, onIdle }
252
255
  );
253
- const url = buildUrl(`http://${DEFAULT_HOST}:${server.address().port}`, repoPath, options.staged);
256
+ const port = server.address().port;
257
+ const url = buildUrl(
258
+ `http://${DEFAULT_HOST}:${port}`,
259
+ repoPath,
260
+ options.staged,
261
+ options.noIntro,
262
+ options.untracked
263
+ );
254
264
 
255
- note(`\n Code Reviewer ${url}`);
256
- if (repoPath) note(` reviewing ${repoPath}`);
257
- note(handingOff
258
- ? '\n Submit the review in the browser and it will be written here.\n'
259
- : '\n Close the tab when you are done, or press Ctrl+C.\n');
265
+ if (options.json) {
266
+ // One machine-readable line instead of the banner, so an agent can read
267
+ // the URL without a regex that breaks the day the banner is reworded.
268
+ // stderr, like everything else here: stdout is reserved for the review.
269
+ // No repoId: none exists yet. The page creates one via POST /api/load-repo.
270
+ note(JSON.stringify({
271
+ url,
272
+ port,
273
+ repository: repoPath,
274
+ pid: process.pid,
275
+ version
276
+ }));
277
+ } else {
278
+ note(`\n Code Reviewer ${url}`);
279
+ if (repoPath) note(` reviewing ${repoPath}`);
280
+ note(handingOff
281
+ ? '\n Submit the review in the browser and it will be written here.\n'
282
+ : '\n Close the tab when you are done, or press Ctrl+C.\n');
283
+ }
260
284
 
261
285
  if (options.open && !(await openInBrowser(url))) {
262
286
  note(' Could not open a browser — open the URL above yourself.\n');
package/lib/agent.js CHANGED
@@ -23,6 +23,7 @@ const SCHEMA = 'code-review/v1';
23
23
  * @property {string} generatedAt ISO 8601
24
24
  * @property {{path: string, name: string, head: string|null, branch: string|null}} repository
25
25
  * @property {'working'|'staged'|'lastCommit'|'range'|null} mode
26
+ * @property {boolean} [untracked] false when the working comparison excluded untracked files
26
27
  * @property {{comments: number, files: number}} summary
27
28
  * @property {AgentComment[]} comments
28
29
  */
@@ -48,9 +49,10 @@ const SCHEMA = 'code-review/v1';
48
49
  * @param {string|null} [args.head] the commit the review was written against
49
50
  * @param {string|null} [args.branch]
50
51
  * @param {string|null} [args.mode]
52
+ * @param {boolean} [args.untracked]
51
53
  * @returns {ReviewDocument}
52
54
  */
53
- function buildReviewDocument({ repoPath, comments, generatedAt, head = null, branch = null, mode = null, range = null }) {
55
+ function buildReviewDocument({ repoPath, comments, generatedAt, head = null, branch = null, mode = null, range = null, untracked }) {
54
56
  const grouped = groupByFile(comments);
55
57
 
56
58
  return {
@@ -63,6 +65,9 @@ function buildReviewDocument({ repoPath, comments, generatedAt, head = null, bra
63
65
  branch
64
66
  },
65
67
  mode,
68
+ // Older saves say nothing about untracked files. Only record an explicit
69
+ // exclusion in a working comparison; other scopes do not use this flag.
70
+ ...(mode === 'working' && untracked === false ? { untracked: false } : {}),
66
71
  // Omitted rather than null when there is no range, so `"range" in
67
72
  // document` is a meaningful test -- the same rule the optional comment
68
73
  // fields follow. A `working` review has no range and never will.
@@ -198,10 +203,15 @@ function toAgentComment(comment, id) {
198
203
  * @param {ReviewDocument} document
199
204
  * @returns {string|null}
200
205
  */
201
- function describeScope({ repository, mode, range }) {
206
+ function describeScope({ repository, mode, range, untracked }) {
202
207
  const at = repository.head ? ` (${repository.head})` : '';
203
208
 
204
209
  if (mode === 'working') {
210
+ if (untracked === false) {
211
+ return `Comparison: tracked changes in the working tree, against ${repository.branch ?? 'HEAD'}${at}. ` +
212
+ 'Untracked files are excluded from this comparison. Saved comments are shared between review views ' +
213
+ 'and may refer to changes outside this comparison; check their anchors before applying them.';
214
+ }
205
215
  return `Reviewed: uncommitted changes in the working tree, against ${repository.branch ?? 'HEAD'}${at}.`;
206
216
  }
207
217
 
@@ -322,4 +332,4 @@ function formatPrompt(document) {
322
332
  return `${lines.join('\n')}`;
323
333
  }
324
334
 
325
- module.exports = { buildReviewDocument, filterReviewDocument, formatPrompt, SCHEMA };
335
+ module.exports = { buildReviewDocument, filterReviewDocument, formatPrompt, describeScope, SCHEMA };
package/lib/changes.js CHANGED
@@ -12,8 +12,14 @@
12
12
  * @typedef {object} ChangedFile
13
13
  * @property {string} path repo-relative path
14
14
  * @property {'A'|'M'|'D'|'R'|'B'} status
15
+ * @property {boolean} [directory] a folded, wholly untracked directory
16
+ * @property {number} [fileCount] untracked files represented by that directory
15
17
  */
16
18
 
19
+ // Keep a new source folder reviewable file by file, while hundreds of
20
+ // untracked files take one row instead of pushing tracked edits off screen.
21
+ const UNTRACKED_DIRECTORY_LIMIT = 200;
22
+
17
23
  /**
18
24
  * Unquotes git's octal-escaped, C-quoted filename strings (e.g. "\"caf\\303\\251.txt\"" -> "café.txt").
19
25
  *
@@ -136,30 +142,101 @@ function parseCommitFilePath(raw) {
136
142
  * most- to least-specific for that reason.
137
143
  *
138
144
  * @param {import('simple-git').StatusResult} status
145
+ * @param {string[]} [untrackedDirectories] directory entries from Git's normal untracked status
139
146
  * @returns {ChangedFile[]}
140
147
  */
141
- function collectWorkingChanges(status) {
148
+ function collectWorkingChanges(status, untrackedDirectories = []) {
149
+ const directories = new Set(untrackedDirectories.map(unquoteGitPath).filter(p => p?.endsWith('/')));
150
+ const directoryOf = new Map();
151
+ const counts = new Map();
152
+
153
+ for (const rawPath of status?.not_added ?? []) {
154
+ const filePath = unquoteGitPath(rawPath);
155
+ if (!filePath || directoryOf.has(filePath)) continue;
156
+ // Check path components, not every directory against every file. The
157
+ // trailing slash also keeps output/ separate from output-neighbor/.
158
+ for (let slash = filePath.indexOf('/'); slash !== -1; slash = filePath.indexOf('/', slash + 1)) {
159
+ const directory = filePath.slice(0, slash + 1);
160
+ if (!directories.has(directory)) continue;
161
+ directoryOf.set(filePath, directory);
162
+ counts.set(directory, (counts.get(directory) ?? 0) + 1);
163
+ break;
164
+ }
165
+ }
166
+
142
167
  const buckets = [
143
168
  [status?.renamed ?? [], 'R'],
144
169
  [status?.deleted ?? [], 'D'],
145
170
  [status?.created ?? [], 'A'],
146
- [status?.not_added ?? [], 'A'],
171
+ [status?.not_added ?? [], 'A', true],
147
172
  [status?.modified ?? [], 'M']
148
173
  ];
149
174
 
150
175
  const seen = new Map();
176
+ const untrackedPaths = new Set();
151
177
 
152
- for (const [files, letter] of buckets) {
178
+ for (const [files, letter, untracked] of buckets) {
153
179
  for (const entry of files) {
154
180
  // `renamed` holds `{ from, to }`; every other bucket holds plain strings.
155
181
  const rawPath = typeof entry === 'string' ? entry : entry?.to;
156
182
  const filePath = unquoteGitPath(rawPath);
157
183
  if (!filePath || seen.has(filePath)) continue;
184
+ const directory = untracked ? directoryOf.get(filePath) : null;
185
+ const fileCount = counts.get(directory) ?? 0;
186
+ if (fileCount > UNTRACKED_DIRECTORY_LIMIT) {
187
+ if (!seen.has(directory)) {
188
+ seen.set(directory, { path: directory, status: 'A', directory: true, fileCount });
189
+ untrackedPaths.add(directory);
190
+ }
191
+ continue;
192
+ }
158
193
  seen.set(filePath, { path: filePath, status: letter });
194
+ if (untracked) untrackedPaths.add(filePath);
159
195
  }
160
196
  }
161
197
 
162
- return [...seen.values()];
198
+ // Put tracked edits on screen first without changing status precedence.
199
+ // The source bucket matters: a staged addition also has status A.
200
+ const files = [...seen.values()];
201
+ return [
202
+ ...files.filter(file => !untrackedPaths.has(file.path)),
203
+ ...files.filter(file => untrackedPaths.has(file.path))
204
+ ];
205
+ }
206
+
207
+ /**
208
+ * Git's normal status identifies wholly untracked directories; its all
209
+ * status supplies the files to count and keep individually in small ones.
210
+ * Turning untracked files off must avoid enumerating them in the first place.
211
+ * @param {import('simple-git').SimpleGit} git
212
+ * @param {boolean} [untracked]
213
+ * @returns {Promise<ChangedFile[]>}
214
+ */
215
+ async function collectWorkingTreeChanges(git, untracked = true) {
216
+ if (!untracked) return collectWorkingChanges(await git.status(['--untracked-files=no']));
217
+ const status = await git.status(['--untracked-files=normal']);
218
+ const directories = (status.not_added ?? []).filter(p => unquoteGitPath(p).endsWith('/'));
219
+ if (!directories.length) return collectWorkingChanges(status);
220
+ return collectWorkingChanges(await git.status(['--untracked-files=all']), directories);
221
+ }
222
+
223
+ /** Explain the visible rows and the files represented by folded directories. */
224
+ function describeWorkingChanges(files, untracked = true) {
225
+ const directories = files.filter(file => file.directory);
226
+ const count = files.length - directories.length;
227
+ const headline = `Found ${count} changed file${count === 1 ? '' : 's'}`;
228
+ if (!untracked) {
229
+ const changes = count ? headline : 'No tracked changes';
230
+ return `${changes} — untracked files hidden`;
231
+ }
232
+ const total = directories.reduce((sum, file) => sum + file.fileCount, 0);
233
+ const summary = directories.length
234
+ ? `; ${directories.length} folder${directories.length === 1 ? '' : 's'} folded (${total.toLocaleString('en-US')} untracked files)`
235
+ : '';
236
+ const folded = directories.map(file =>
237
+ `${file.path} holds ${file.fileCount.toLocaleString('en-US')} untracked files; individual files are not listed.`
238
+ ).join(' ');
239
+ return headline + summary + (folded ? `. ${folded}` : '');
163
240
  }
164
241
 
165
242
  /**
@@ -287,6 +364,8 @@ async function collectStagedChanges(git) {
287
364
  module.exports = {
288
365
  collectStagedChanges,
289
366
  collectWorkingChanges,
367
+ collectWorkingTreeChanges,
368
+ describeWorkingChanges,
290
369
  collectCommitChanges,
291
370
  classifyCommitFile,
292
371
  unquoteGitPath,
package/lib/cli.js CHANGED
@@ -43,7 +43,10 @@ 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
+ --no-intro Open the review without the first-run help
47
+ --no-untracked Exclude untracked files from working directory reviews
46
48
  --staged Review only staged changes (HEAD vs index)
49
+ --json Print one JSON line on stderr at startup instead of the banner
47
50
  -f, --format <f> Export format: json (default) or prompt
48
51
  --file <path> Export comments for one repo-relative file
49
52
  -h, --help Show this help
@@ -84,7 +87,10 @@ class UsageError extends Error {
84
87
  * @property {string|null} repoPath absolute path, or null when not given
85
88
  * @property {number|null} port explicit port, or null to use the default
86
89
  * @property {boolean} open whether to launch a browser
90
+ * @property {boolean} noIntro skip the first-run help dialog
87
91
  * @property {boolean} staged whether to review only the index
92
+ * @property {boolean} untracked whether to include untracked files
93
+ * @property {boolean} json whether to print the startup info as one JSON line
88
94
  * @property {'json'|'prompt'} format for `export`
89
95
  * @property {string|null} file repo-relative file to export, or all files
90
96
  * @property {boolean} help
@@ -106,7 +112,10 @@ function parseArgs(argv, cwd = process.cwd()) {
106
112
  repoPath: null,
107
113
  port: null,
108
114
  open: true,
115
+ noIntro: false,
109
116
  staged: false,
117
+ untracked: true,
118
+ json: false,
110
119
  format: 'json',
111
120
  file: null,
112
121
  help: false,
@@ -141,10 +150,22 @@ function parseArgs(argv, cwd = process.cwd()) {
141
150
  options.open = false;
142
151
  break;
143
152
 
153
+ case '--no-intro':
154
+ options.noIntro = true;
155
+ break;
156
+
144
157
  case '--staged':
145
158
  options.staged = true;
146
159
  break;
147
160
 
161
+ case '--no-untracked':
162
+ options.untracked = false;
163
+ break;
164
+
165
+ case '--json':
166
+ options.json = true;
167
+ break;
168
+
148
169
  case '-p':
149
170
  case '--port': {
150
171
  const value = rest[++i];
@@ -211,7 +232,16 @@ function parseArgs(argv, cwd = process.cwd()) {
211
232
  if (options.staged && options.command !== 'serve') {
212
233
  throw new UsageError('--staged is only valid when serving a review');
213
234
  }
235
+ if (options.noIntro && options.command !== 'serve') {
236
+ throw new UsageError('--no-intro is only valid when serving a review');
237
+ }
238
+ if (!options.untracked && options.command !== 'serve') {
239
+ throw new UsageError('--no-untracked is only valid when serving a review');
240
+ }
214
241
 
242
+ if (options.json && options.command !== 'serve') {
243
+ throw new UsageError('--json is only valid when serving a review');
244
+ }
215
245
  return options;
216
246
  }
217
247
 
@@ -261,21 +291,28 @@ function parseFile(value, flag) {
261
291
  * @param {string} origin e.g. http://127.0.0.1:4500
262
292
  * @param {string|null} repoPath
263
293
  * @param {boolean} [staged]
294
+ * @param {boolean} [noIntro] add intro=0 so a fresh browser skips the first-run help
295
+ * @param {boolean} [untracked] include untracked files in working directory reviews
264
296
  * @returns {string}
265
297
  */
266
- function buildUrl(origin, repoPath, staged = false) {
267
- if (!repoPath) return staged ? `${origin}/?staged=1` : origin;
268
-
269
- // Slashes are left as slashes. They are legal in a query value -- RFC 3986
270
- // has `query = *( pchar / "/" / "?" )` -- and encoding them turns the most
271
- // prominent line the command prints into %2F soup:
272
- //
273
- // before http://127.0.0.1:4500/?repo=%2FUsers%2Fyou%2Fwork%2Fapi
274
- // after http://127.0.0.1:4500/?repo=/Users/you/work/api
275
- //
276
- // Everything else stays encoded, including the characters that would
277
- // otherwise end the query and take the rest of the path with them.
278
- return `${origin}/?repo=${encodeURIComponent(repoPath).replace(/%2F/g, '/')}${staged ? '&staged=1' : ''}`;
298
+ function buildUrl(origin, repoPath, staged = false, noIntro = false, untracked = true) {
299
+ const params = [];
300
+ if (repoPath) {
301
+ // Slashes are left as slashes. They are legal in a query value -- RFC 3986
302
+ // has `query = *( pchar / "/" / "?" )` -- and encoding them turns the most
303
+ // prominent line the command prints into %2F soup:
304
+ //
305
+ // before http://127.0.0.1:4500/?repo=%2FUsers%2Fyou%2Fwork%2Fapi
306
+ // after http://127.0.0.1:4500/?repo=/Users/you/work/api
307
+ //
308
+ // Everything else stays encoded, including the characters that would
309
+ // otherwise end the query and take the rest of the path with them.
310
+ params.push(`repo=${encodeURIComponent(repoPath).replace(/%2F/g, '/')}`);
311
+ }
312
+ if (staged) params.push('staged=1');
313
+ if (noIntro) params.push('intro=0');
314
+ if (!untracked) params.push('untracked=0');
315
+ return params.length === 0 ? origin : `${origin}/?${params.join('&')}`;
279
316
  }
280
317
 
281
318
  /**
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');
@@ -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, range } = await readSavedComments(reviewsDir, repoPath);
103
+ const { comments, mode, range, untracked } = 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, range }),
124
+ buildReviewDocument({ repoPath, comments, generatedAt: new Date(), head, branch, mode, range, untracked }),
125
125
  { file }
126
126
  );
127
127
 
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/review.js CHANGED
@@ -124,13 +124,15 @@ function reviewFilename(repoPath, generatedAt) {
124
124
  * @param {string} args.repoPath repository the review covers
125
125
  * @param {import('./comments').Comment[]} args.comments
126
126
  * @param {Date} args.generatedAt
127
+ * @param {string|null} [args.scope] an explanation of an explicitly restricted comparison
127
128
  * @returns {string}
128
129
  */
129
- function formatReview({ repoPath, comments, generatedAt }) {
130
+ function formatReview({ repoPath, comments, generatedAt, scope = null }) {
130
131
  const sections = [
131
132
  '# Code Review\n',
132
133
  `Repository: ${repoPath}`,
133
134
  `Generated: ${generatedAt.toISOString()}`,
135
+ ...(scope ? [scope] : []),
134
136
  `Total Comments: ${comments.length}\n`
135
137
  ];
136
138
 
package/lib/sessions.js CHANGED
@@ -35,7 +35,7 @@ class SessionStore {
35
35
  */
36
36
  constructor(generateId = () => crypto.randomBytes(8).toString('hex')) {
37
37
  this.generateId = generateId;
38
- /** @type {Map<string, {repoPath: string, mode: ReviewMode, range: ReviewRange|null}>} */
38
+ /** @type {Map<string, {repoPath: string, mode: ReviewMode, range: ReviewRange|null, untracked?: boolean}>} */
39
39
  this.sessions = new Map();
40
40
  }
41
41
 
@@ -43,17 +43,21 @@ class SessionStore {
43
43
  * @param {string} repoPath absolute path of the opened repository
44
44
  * @param {ReviewMode} mode
45
45
  * @param {ReviewRange|null} [range] the two refs, for a commit-based review
46
+ * @param {boolean} [untracked] whether a working comparison includes untracked files
46
47
  * @returns {string} the new session id
47
48
  */
48
- create(repoPath, mode, range = null) {
49
+ create(repoPath, mode, range = null, untracked = true) {
49
50
  const repoId = this.generateId();
50
- this.sessions.set(repoId, { repoPath, mode, range });
51
+ this.sessions.set(repoId, {
52
+ repoPath, mode, range,
53
+ ...(mode === 'working' && untracked === false ? { untracked: false } : {})
54
+ });
51
55
  return repoId;
52
56
  }
53
57
 
54
58
  /**
55
59
  * @param {string} repoId
56
- * @returns {{repoPath: string, mode: ReviewMode, range: ReviewRange|null}|undefined}
60
+ * @returns {{repoPath: string, mode: ReviewMode, range: ReviewRange|null, untracked?: boolean}|undefined}
57
61
  */
58
62
  get(repoId) {
59
63
  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, range: object|null, adopted: boolean}>}
77
+ * @returns {Promise<{comments: object[], mode: string|null, range: object|null, untracked?: boolean, 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,7 +90,11 @@ 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, range: saved.range ?? null, adopted: false };
93
+ return {
94
+ comments: saved.comments ?? [], mode: saved.mode ?? null, range: saved.range ?? null,
95
+ ...(saved.mode === 'working' && saved.untracked === false ? { untracked: false } : {}),
96
+ adopted: false
97
+ };
94
98
  }
95
99
 
96
100
  const legacyName = legacyCommentsFilename(repoPath);
@@ -110,7 +114,11 @@ async function readSavedComments(reviewsDir, repoPath) {
110
114
  /* the comments are already in hand; renaming is housekeeping */
111
115
  }
112
116
 
113
- return { comments: legacy.comments ?? [], mode: legacy.mode ?? null, range: legacy.range ?? null, adopted: true };
117
+ return {
118
+ comments: legacy.comments ?? [], mode: legacy.mode ?? null, range: legacy.range ?? null,
119
+ ...(legacy.mode === 'working' && legacy.untracked === false ? { untracked: false } : {}),
120
+ adopted: true
121
+ };
114
122
  }
115
123
 
116
124
  module.exports = { readSavedComments, ReviewOwnershipError, belongsTo };
@@ -9,6 +9,7 @@ git-reviewer, reviewer \- review local git changes in your browser, and hand the
9
9
  .RB [ \-p
10
10
  .IR port ]
11
11
  .RB [ \-\-no\-open ]
12
+ .RB [ \-\-no\-untracked ]
12
13
  .br
13
14
  .B git reviewer export
14
15
  .RI [ repository ]
@@ -51,10 +52,20 @@ Port to listen on. The default is 4500; if it is taken, a free one is used.
51
52
  .B \-\-no\-open
52
53
  Print the address instead of opening a browser.
53
54
  .TP
55
+ .B \-\-no\-intro
56
+ Open the review without the first-run help. The ? button still opens it.
57
+ The page is told with
58
+ .B intro=0
59
+ in the URL.
60
+ .TP
54
61
  .B \-\-staged
55
62
  Review only staged changes, comparing HEAD with the index. Unstaged edits are
56
63
  excluded, and an empty index comparison does not fall back to the last commit.
57
64
  .TP
65
+ .B \-\-no\-untracked
66
+ Exclude untracked files from working directory reviews. Tracked changes remain
67
+ reviewable. This option is only valid when serving a review.
68
+ .TP
58
69
  .BR \-f ", " \-\-format " \fIjson\fR|\fIprompt\fR"
59
70
  Export format. The default is json.
60
71
  .TP
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-reviewer",
3
- "version": "2.14.0",
3
+ "version": "2.15.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",
@@ -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
@@ -3,6 +3,7 @@
3
3
  // In Electron, this will be the random port Electron assigned
4
4
  const API_BASE = `${window.location.origin}/api`;
5
5
  const stagedByDefault = new URLSearchParams(window.location.search).get('staged') === '1';
6
+ const untrackedByDefault = new URLSearchParams(window.location.search).get('untracked') !== '0';
6
7
 
7
8
  let currentRepoId = null;
8
9
  let currentFiles = [];
@@ -495,8 +496,15 @@ function renderScope(data) {
495
496
  } else {
496
497
  label.append(data.mode === 'staged' ? 'Staged changes' : 'Uncommitted changes');
497
498
  if (branch) label.append(' on ', strong(branch));
498
- meta.textContent = plural(data.files.length, 'file') + ' changed';
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
+ const folders = data.files.filter(file => file.directory);
500
+ const foldedFiles = folders.reduce((sum, file) => sum + file.fileCount, 0);
501
+ meta.textContent = plural(data.files.length - folders.length, 'file') + ' changed' + (folders.length
502
+ ? `; ${plural(folders.length, 'folder')} folded (${foldedFiles.toLocaleString('en-US')} untracked files)`
503
+ : '');
504
+ setNote(data.mode === 'staged'
505
+ ? 'The diff excludes unstaged changes. Saved comments are shared between views and may refer to other code; check their anchors.'
506
+ : data.files.some(file => file.directory) ? data.message
507
+ : data.untracked === false ? 'Untracked files are excluded.' : null);
500
508
  }
501
509
 
502
510
  row.classList.add('hidden');
@@ -782,7 +790,7 @@ async function loadRepo(scope = {}) {
782
790
  body: JSON.stringify(
783
791
  scope.commits ? { repoPath, commits: scope.commits }
784
792
  : scope.base ? { repoPath, base: scope.base, ...(scope.head ? { head: scope.head } : {}) }
785
- : { repoPath, staged: stagedByDefault }
793
+ : { repoPath, staged: stagedByDefault, ...(untrackedByDefault ? {} : { untracked: false }) }
786
794
  )
787
795
  });
788
796
 
@@ -818,13 +826,13 @@ async function loadRepo(scope = {}) {
818
826
  // `message`, and the scope bar above is still showing how many commits
819
827
  // that covers -- so this is information, not an error, and it must not
820
828
  // be dressed as one.
821
- if (data.mode === 'range' || data.mode === 'staged') {
829
+ if (data.mode === 'range' || data.mode === 'staged' || data.untracked === false) {
822
830
  // The scope bar is already explaining this; a status line repeating
823
831
  // it underneath is two bars saying one thing.
824
832
  clearStatus();
825
833
  hidePicker();
826
834
  displayFiles([]);
827
- showEmptyFileList(data.mode === 'staged' ? 'No staged changes.' : data.range?.kind !== 'commits'
835
+ showEmptyFileList(data.mode === 'staged' ? 'No staged changes.' : data.mode === 'working' ? 'No tracked changes.' : data.range?.kind !== 'commits'
828
836
  ? 'No files differ between these two.'
829
837
  : data.range.first === data.range.last
830
838
  ? 'This commit changes no files.'
@@ -882,7 +890,8 @@ async function loadRepo(scope = {}) {
882
890
  // repository does not move you off what you were reading.
883
891
  if (currentFiles.length > 0) {
884
892
  const previous = currentFiles.findIndex(file => file.path === currentFile);
885
- const opening = previous === -1 ? 0 : previous;
893
+ const firstFile = currentFiles.findIndex(file => !file.directory);
894
+ const opening = previous === -1 ? Math.max(0, firstFile) : previous;
886
895
 
887
896
  loadFile(currentFiles[opening].path, opening);
888
897
  offerHelpOnce();
@@ -927,7 +936,7 @@ function displayFiles(files) {
927
936
  filesList.innerHTML = files.map((fileObj, index) => {
928
937
  const file = fileObj.path;
929
938
  const status = fileObj.status;
930
- const parts = file.split('/');
939
+ const parts = (fileObj.directory ? file.slice(0, -1) : file).split('/');
931
940
  const filename = parts[parts.length - 1];
932
941
  const path = parts.slice(0, -1).join('/');
933
942
 
@@ -936,9 +945,10 @@ function displayFiles(files) {
936
945
  // both halves are escaped going in. They were not, and a name is chosen by
937
946
  // whatever repository is open: markup in one was drawn as markup, without
938
947
  // anyone clicking anything.
939
- const displayText = path
948
+ let displayText = path
940
949
  ? `${escapeHtml(path)}/<span class="filename">${escapeHtml(filename)}</span>`
941
950
  : `<span class="filename">${escapeHtml(filename)}</span>`;
951
+ if (fileObj.directory) displayText = `<bdi dir="ltr">${displayText}/</bdi>`;
942
952
  const statusClass = fileStatusClass(status);
943
953
  // One letter is git's vocabulary, not everyone's. The name rides along as
944
954
  // a tooltip and as the accessible label, so "D" is never the only thing
@@ -959,11 +969,16 @@ function displayFiles(files) {
959
969
  // true, and re-rendering the list is what loses the selected row.
960
970
  return `<div class="file-item" onclick="openFileAt(${Number(index)})" title="${escapeHtml(file)}">
961
971
  ${statusBadge}<span class="file-path-text">${displayText}</span>
972
+ ${fileObj.directory ? `<span class="file-fold-count">${Number(fileObj.fileCount).toLocaleString('en-US')} files</span>` : ''}
962
973
  <span class="file-comment-count" data-file-index="${Number(index)}" hidden></span>
963
974
  </div>`;
964
975
  }).join('');
965
976
 
966
- fileCount.textContent = `${files.length} file${files.length !== 1 ? 's' : ''}`;
977
+ const folders = files.filter(file => file.directory).length;
978
+ const individualFiles = files.length - folders;
979
+ fileCount.textContent = `${individualFiles} file${individualFiles !== 1 ? 's' : ''}` + (folders
980
+ ? ` · ${folders} folder${folders !== 1 ? 's' : ''}`
981
+ : '');
967
982
  sidebar.classList.remove('hidden');
968
983
  resizeHandle.classList.remove('hidden');
969
984
  submitBtn.classList.remove('hidden');
@@ -991,6 +1006,7 @@ function openFileAt(index) {
991
1006
  // Load file content
992
1007
  async function loadFile(filePath, index) {
993
1008
  if (!currentRepoId) return Promise.resolve();
1009
+ const repoId = currentRepoId;
994
1010
 
995
1011
  currentFile = filePath;
996
1012
  currentFileIndex = index; // Track for keyboard navigation
@@ -1007,9 +1023,30 @@ async function loadFile(filePath, index) {
1007
1023
  }
1008
1024
  });
1009
1025
 
1026
+ const file = currentFiles[index];
1027
+ const fullContextButton = document.querySelector('.full-context-btn');
1028
+ if (fullContextButton) fullContextButton.disabled = !!file?.directory;
1029
+ if (file?.directory) {
1030
+ // A folded row describes a directory; there is no file or diff to fetch.
1031
+ currentDiffLines = [];
1032
+ document.getElementById('currentFile').textContent = filePath;
1033
+ const section = document.getElementById('codeSection');
1034
+ section.classList.remove('hidden');
1035
+ section.style.display = 'block';
1036
+ document.getElementById('codeViewer').innerHTML =
1037
+ `<div class="binary-notice">${Number(file.fileCount).toLocaleString('en-US')} untracked files` +
1038
+ '<span>This directory is folded because it contains more than 200 files. ' +
1039
+ 'Individual files and diffs are not shown. Add generated output to .gitignore to exclude it from future reviews.</span></div>';
1040
+ updateFullContextButton();
1041
+ return;
1042
+ }
1043
+
1010
1044
  try {
1011
- const response = await fetch(`${API_BASE}/file/${currentRepoId}/${filePath}`);
1045
+ const response = await fetch(`${API_BASE}/file/${repoId}/${filePath}`);
1012
1046
  const data = await response.json();
1047
+ // A directory can be selected while a previous file request is in flight.
1048
+ // Its no-diff pane must not be replaced with that file's editable lines.
1049
+ if (currentRepoId !== repoId || currentFile !== filePath) return;
1013
1050
 
1014
1051
  if (!response.ok) {
1015
1052
  throw new Error(data.error || 'Failed to load file');
@@ -1076,6 +1113,7 @@ async function loadFile(filePath, index) {
1076
1113
  return Promise.resolve();
1077
1114
 
1078
1115
  } catch (error) {
1116
+ if (currentRepoId !== repoId || currentFile !== filePath) return;
1079
1117
  showStatus(`Error loading file: ${error.message}`, 'error');
1080
1118
  return Promise.reject(error);
1081
1119
  }
@@ -1956,6 +1994,9 @@ let storedFullFileLines = null;
1956
1994
  // Toggle between full context and diff-only view
1957
1995
  async function showFullContext() {
1958
1996
  if (!currentRepoId || !currentFile) return;
1997
+ if (currentFiles.some(file => file.path === currentFile && file.directory)) return;
1998
+ const repoId = currentRepoId;
1999
+ const filePath = currentFile;
1959
2000
 
1960
2001
  if (isFullContextMode) {
1961
2002
  // Switch back to diff view
@@ -1965,8 +2006,9 @@ async function showFullContext() {
1965
2006
  } else {
1966
2007
  // Switch to full context
1967
2008
  try {
1968
- const response = await fetch(`${API_BASE}/file-full/${currentRepoId}/${currentFile}`);
2009
+ const response = await fetch(`${API_BASE}/file-full/${repoId}/${filePath}`);
1969
2010
  const data = await response.json();
2011
+ if (currentRepoId !== repoId || currentFile !== filePath) return;
1970
2012
 
1971
2013
  if (!response.ok) {
1972
2014
  throw new Error(data.error || 'Failed to load full file content');
@@ -1978,6 +2020,7 @@ async function showFullContext() {
1978
2020
  updateFullContextButton();
1979
2021
 
1980
2022
  } catch (error) {
2023
+ if (currentRepoId !== repoId || currentFile !== filePath) return;
1981
2024
  showStatus(`Error loading full context: ${error.message}`, 'error');
1982
2025
  }
1983
2026
  }
@@ -2842,6 +2885,23 @@ function closeHelp() {
2842
2885
  document.getElementById('helpModal').classList.add('hidden');
2843
2886
  }
2844
2887
 
2888
+ /**
2889
+ * Whether the first-run help should open on its own.
2890
+ *
2891
+ * `intro=0` is an explicit no, for an automation that starts from a fresh
2892
+ * browser every time. Otherwise the dialog opens once per browser, until it
2893
+ * has been seen. Asking for it with the ? button does not come through here.
2894
+ *
2895
+ * @param {string} search location.search, with or without the leading ?
2896
+ * @param {boolean} seen
2897
+ * @returns {boolean}
2898
+ */
2899
+ function shouldOfferHelp(search, seen) {
2900
+ // The same reader the page uses for `repo` and `staged`.
2901
+ if (new URLSearchParams(search || '').get('intro') === '0') return false;
2902
+ return !seen;
2903
+ }
2904
+
2845
2905
  /**
2846
2906
  * Offer it unprompted the first time, once a repository is actually on screen.
2847
2907
  *
@@ -2849,7 +2909,7 @@ function closeHelp() {
2849
2909
  * would be the first thing between somebody and the tool they just started.
2850
2910
  */
2851
2911
  function offerHelpOnce() {
2852
- if (helpWasSeen()) return;
2912
+ if (!shouldOfferHelp(window.location.search, helpWasSeen())) return;
2853
2913
  openHelp();
2854
2914
  }
2855
2915
 
package/public/style.css CHANGED
@@ -211,6 +211,12 @@ button:disabled {
211
211
  flex-shrink: 0;
212
212
  }
213
213
 
214
+ .file-fold-count {
215
+ flex-shrink: 0;
216
+ font-size: 11px;
217
+ color: #6a737d;
218
+ }
219
+
214
220
  .file-status-added {
215
221
  background: #28a745;
216
222
  color: white;
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,11 +12,11 @@ const {
12
12
  resolveRepoFile,
13
13
  PathEscapeError
14
14
  } = require('./lib/paths');
15
- const { collectWorkingChanges, collectStagedChanges, collectCommitChanges, parseNameStatus } = require('./lib/changes');
15
+ const { collectWorkingTreeChanges, describeWorkingChanges, 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');
19
- const { buildReviewDocument } = require('./lib/agent');
19
+ const { buildReviewDocument, describeScope } = require('./lib/agent');
20
20
  const { readSavedComments, ReviewOwnershipError } = require('./lib/store');
21
21
  const { repoRoot } = require('./lib/repo');
22
22
  const { browse } = require('./lib/browse');
@@ -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
 
@@ -660,20 +698,37 @@ function createApp(options = {}) {
660
698
  /**
661
699
  * Refuse a request a browser has marked as coming from somewhere else.
662
700
  *
663
- * `cors()` above is open, deliberately, and that is a settled decision this
664
- * does not reopen. It is a rule for the two endpoints below, which differ
665
- * from every other one in kind: they enumerate directories and name
666
- * projects, rather than answering about a repository whose path the caller
667
- * already had. A browser sends `Origin` on exactly the requests where that
668
- * distinction matters and omits it on same-origin GETs, so the page keeps
669
- * working and a page on another site does not. Anything that is not a
670
- * 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.
671
718
  */
672
719
  function sameOriginOnly(req, res, next) {
673
720
  const origin = req.get('origin');
674
721
 
675
- if (origin && origin !== `http://${req.headers.host}`) {
676
- 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
+ }
677
732
  }
678
733
 
679
734
  next();
@@ -686,9 +741,9 @@ function createApp(options = {}) {
686
741
  * The connection *is* the signal. An open page holds this response open and
687
742
  * closing the page closes it, so there is no heartbeat interval to tune, and
688
743
  * two tabs are two connections rather than one flag that either close
689
- * clears. `sameOriginOnly` is here for the same reason it is on the two
690
- * below: without it a page on another site could hold this open and keep a
691
- * 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.
692
747
  *
693
748
  * Nothing happens at the moment a watcher leaves. A reload closes and
694
749
  * reopens within milliseconds, and a dropped connection reopens when
@@ -701,7 +756,7 @@ function createApp(options = {}) {
701
756
  * after having been above it, so `reviewer` whose browser never launched
702
757
  * keeps serving rather than exiting into an empty terminal.
703
758
  */
704
- app.get('/api/alive', sameOriginOnly, (req, res) => {
759
+ app.get('/api/alive', (req, res) => {
705
760
  if (idleTimer) {
706
761
  clearTimeout(idleTimer);
707
762
  idleTimer = null;
@@ -733,7 +788,7 @@ function createApp(options = {}) {
733
788
  });
734
789
 
735
790
  /** List the directories inside one, so a repository can be found by looking. */
736
- app.get('/api/browse', sameOriginOnly, async (req, res) => {
791
+ app.get('/api/browse', async (req, res) => {
737
792
  try {
738
793
  res.json(await browse(req.query.path));
739
794
  } catch (error) {
@@ -750,7 +805,7 @@ function createApp(options = {}) {
750
805
  });
751
806
 
752
807
  /** Repositories opened before now, newest first. */
753
- app.get('/api/recent', sameOriginOnly, async (req, res) => {
808
+ app.get('/api/recent', async (req, res) => {
754
809
  try {
755
810
  // Annotated with what is saved against each, because "3 comments" is
756
811
  // what tells you which of two checkouts you were in the middle of.
@@ -783,7 +838,10 @@ function createApp(options = {}) {
783
838
  // Resolve to the root of the working tree before anything is keyed on
784
839
  // this path. A subdirectory passes `checkIsRepo()` quite happily and
785
840
  // then produces a file list whose diffs are all empty; see lib/repo.js.
786
- const { base, head, commits: run, staged = false } = req.body ?? {};
841
+ const { base, head, commits: run, staged = false, untracked = true } = req.body ?? {};
842
+ if (typeof untracked !== 'boolean') {
843
+ return res.status(400).json({ error: 'The untracked option must be a boolean.' });
844
+ }
787
845
  if (typeof staged !== 'boolean' || (staged && (base != null || head != null || run != null))) {
788
846
  return res.status(400).json({ error: 'Choose staged changes or a commit comparison, not both.' });
789
847
  }
@@ -832,12 +890,14 @@ function createApp(options = {}) {
832
890
  mode = 'range';
833
891
  message = describeRange(range, files.length);
834
892
  } else {
835
- files = collectWorkingChanges(await git.status());
893
+ files = await collectWorkingTreeChanges(git, untracked);
836
894
  mode = 'working';
837
- message = `Found ${files.length} changed file(s)`;
895
+ message = describeWorkingChanges(files, untracked);
838
896
 
839
897
  // A clean tree has nothing to review, so fall back to the last commit.
840
- if (files.length === 0) {
898
+ // Hiding untracked files is an explicit working-tree scope. An empty
899
+ // tracked review must not silently become a review of committed work.
900
+ if (files.length === 0 && untracked) {
841
901
  try {
842
902
  const lastCommit = await resolveRange(git, 'HEAD~1', 'HEAD');
843
903
  const commitFiles = await collectRangeChanges(git, lastCommit);
@@ -855,7 +915,10 @@ function createApp(options = {}) {
855
915
  }
856
916
  }
857
917
 
858
- const repoId = sessions.create(root, mode, range);
918
+ const repoId = sessions.create(root, mode, range, untracked);
919
+ sessions.get(repoId).foldedDirectories = new Map(
920
+ files.filter(file => file.directory).map(file => [file.path, file.fileCount])
921
+ );
859
922
  // stderr, not stdout. `reviewer | claude -p ...` puts the finished
860
923
  // review on stdout, and anything else written there lands in the middle
861
924
  // of it. Progress is for the person watching; stdout is for the pipe.
@@ -870,7 +933,7 @@ function createApp(options = {}) {
870
933
 
871
934
  // `repoPath` in the response is the resolved root, not what was asked
872
935
  // for, so the page can show which repository it actually opened.
873
- res.json({ repoId, files, repoPath: root, mode, range, message });
936
+ res.json({ repoId, files, repoPath: root, mode, range, message, untracked });
874
937
  } catch (error) {
875
938
  console.error('Load repo error:', error);
876
939
  res.status(500).json({ error: error.message });
@@ -891,6 +954,10 @@ function createApp(options = {}) {
891
954
  // works because of statement order is not a containment check.
892
955
  resolveRepoFile(session.repoPath, filePath);
893
956
 
957
+ if (session.foldedDirectories?.has(filePath)) {
958
+ return res.json({ filePath, directory: true, fileCount: session.foldedDirectories.get(filePath), binary: false, diffLines: [] });
959
+ }
960
+
894
961
  const git = gitFactory(session.repoPath);
895
962
  const diffText = await diffForFile(git, session, filePath);
896
963
 
@@ -923,6 +990,11 @@ function createApp(options = {}) {
923
990
  const session = requireSession(res, repoId);
924
991
  if (!session) return;
925
992
 
993
+ resolveRepoFile(session.repoPath, filePath);
994
+ if (session.foldedDirectories?.has(filePath)) {
995
+ return res.json({ filePath, directory: true, fileCount: session.foldedDirectories.get(filePath), lines: [] });
996
+ }
997
+
926
998
  const content = await readFileForMode(session, filePath);
927
999
  res.json({ filePath, lines: content.split('\n') });
928
1000
  } catch (error) {
@@ -963,6 +1035,7 @@ function createApp(options = {}) {
963
1035
  lastUpdated: new Date().toISOString(),
964
1036
  mode: session.mode,
965
1037
  range: session.range,
1038
+ ...(session.untracked === false ? { untracked: false } : {}),
966
1039
  comments: normalized
967
1040
  };
968
1041
 
@@ -1011,7 +1084,6 @@ function createApp(options = {}) {
1011
1084
  }
1012
1085
 
1013
1086
  const generatedAt = new Date();
1014
- const reviewContent = formatReview({ repoPath: session.repoPath, comments, generatedAt });
1015
1087
  const filename = reviewFilename(session.repoPath, generatedAt);
1016
1088
 
1017
1089
  // The commit is worth recording for anything acting on the review later:
@@ -1027,7 +1099,12 @@ function createApp(options = {}) {
1027
1099
  head,
1028
1100
  branch,
1029
1101
  mode: session.mode,
1030
- range: session.range
1102
+ range: session.range,
1103
+ untracked: session.untracked
1104
+ });
1105
+ const reviewContent = formatReview({
1106
+ repoPath: session.repoPath, comments, generatedAt,
1107
+ scope: document.untracked === false ? describeScope(document) : null
1031
1108
  });
1032
1109
  const documentFilename = filename.replace(/\.txt$/, '.json');
1033
1110
 
@@ -1109,8 +1186,9 @@ function startServer(options = {}) {
1109
1186
  ...appOptions
1110
1187
  } = options;
1111
1188
  // Everything else in `options` -- reviewsDir, sessions, git, handoff and
1112
- // onReviewSubmitted -- goes through to the app.
1113
- const app = createApp(appOptions);
1189
+ // onReviewSubmitted -- goes through to the app. Bound to loopback, the app
1190
+ // also refuses any Host but a loopback name (see `loopbackHostOnly`).
1191
+ const app = createApp({ loopbackOnly: LOOPBACK_ADDRESSES.has(host), ...appOptions });
1114
1192
 
1115
1193
  return new Promise((resolve, reject) => {
1116
1194
  const server = app.listen(port, host, () => {