git-reviewer 2.14.1 → 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,7 +3,7 @@
3
3
  [![CI](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml/badge.svg)](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
5
  [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](package.json)
6
- [![Tests](https://img.shields.io/badge/tests-379-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
9
  **English** · [简体中文](docs/README.zh-CN.md) · [日本語](docs/README.ja.md)
@@ -79,7 +79,9 @@ git reviewer export [repository] [--format json|prompt]
79
79
  -p, --port <n> Port to listen on (default 4500; falls back to a free
80
80
  port if that one is taken)
81
81
  --no-open Print the URL instead of opening a browser
82
+ --no-intro Open the review without the first-run help
82
83
  --staged Review only staged changes (HEAD vs index)
84
+ --no-untracked Review tracked working-tree changes only
83
85
  -f, --format <f> Export format: json (default) or prompt
84
86
  -h, --help Show help
85
87
  -v, --version Show the version
@@ -103,6 +105,19 @@ changes. As with the default view, reload after changing what is staged. Saved c
103
105
  are shared between views and may refer to code outside the current comparison;
104
106
  check their anchors before applying them.
105
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
+
106
121
  ### When you are not standing in a repository
107
122
 
108
123
  The page opens on a picker rather than on an empty box:
@@ -388,6 +403,48 @@ The UI is a client of this; nothing is hidden from you.
388
403
  | `POST /api/submit-review` | Render the saved comments to a review file |
389
404
  | `DELETE /api/cleanup/:repoId` | End the session; files on disk are untouched |
390
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
+
391
448
  A `repoId` is a random per-session handle, not a path. Requests that resolve
392
449
  outside the opened repository are refused, and a path that names nothing gets a
393
450
  404.
@@ -410,7 +467,7 @@ rather than a public issue.
410
467
 
411
468
  ```bash
412
469
  npm install # 3 dependencies, no build step, ~6MB
413
- npm test # 379 tests
470
+ npm test # 415 tests
414
471
  npm run test:watch
415
472
  npm run test:coverage
416
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
@@ -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/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.1",
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",
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
@@ -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');
@@ -838,7 +838,10 @@ function createApp(options = {}) {
838
838
  // Resolve to the root of the working tree before anything is keyed on
839
839
  // this path. A subdirectory passes `checkIsRepo()` quite happily and
840
840
  // then produces a file list whose diffs are all empty; see lib/repo.js.
841
- 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
+ }
842
845
  if (typeof staged !== 'boolean' || (staged && (base != null || head != null || run != null))) {
843
846
  return res.status(400).json({ error: 'Choose staged changes or a commit comparison, not both.' });
844
847
  }
@@ -887,12 +890,14 @@ function createApp(options = {}) {
887
890
  mode = 'range';
888
891
  message = describeRange(range, files.length);
889
892
  } else {
890
- files = collectWorkingChanges(await git.status());
893
+ files = await collectWorkingTreeChanges(git, untracked);
891
894
  mode = 'working';
892
- message = `Found ${files.length} changed file(s)`;
895
+ message = describeWorkingChanges(files, untracked);
893
896
 
894
897
  // A clean tree has nothing to review, so fall back to the last commit.
895
- 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) {
896
901
  try {
897
902
  const lastCommit = await resolveRange(git, 'HEAD~1', 'HEAD');
898
903
  const commitFiles = await collectRangeChanges(git, lastCommit);
@@ -910,7 +915,10 @@ function createApp(options = {}) {
910
915
  }
911
916
  }
912
917
 
913
- 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
+ );
914
922
  // stderr, not stdout. `reviewer | claude -p ...` puts the finished
915
923
  // review on stdout, and anything else written there lands in the middle
916
924
  // of it. Progress is for the person watching; stdout is for the pipe.
@@ -925,7 +933,7 @@ function createApp(options = {}) {
925
933
 
926
934
  // `repoPath` in the response is the resolved root, not what was asked
927
935
  // for, so the page can show which repository it actually opened.
928
- res.json({ repoId, files, repoPath: root, mode, range, message });
936
+ res.json({ repoId, files, repoPath: root, mode, range, message, untracked });
929
937
  } catch (error) {
930
938
  console.error('Load repo error:', error);
931
939
  res.status(500).json({ error: error.message });
@@ -946,6 +954,10 @@ function createApp(options = {}) {
946
954
  // works because of statement order is not a containment check.
947
955
  resolveRepoFile(session.repoPath, filePath);
948
956
 
957
+ if (session.foldedDirectories?.has(filePath)) {
958
+ return res.json({ filePath, directory: true, fileCount: session.foldedDirectories.get(filePath), binary: false, diffLines: [] });
959
+ }
960
+
949
961
  const git = gitFactory(session.repoPath);
950
962
  const diffText = await diffForFile(git, session, filePath);
951
963
 
@@ -978,6 +990,11 @@ function createApp(options = {}) {
978
990
  const session = requireSession(res, repoId);
979
991
  if (!session) return;
980
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
+
981
998
  const content = await readFileForMode(session, filePath);
982
999
  res.json({ filePath, lines: content.split('\n') });
983
1000
  } catch (error) {
@@ -1018,6 +1035,7 @@ function createApp(options = {}) {
1018
1035
  lastUpdated: new Date().toISOString(),
1019
1036
  mode: session.mode,
1020
1037
  range: session.range,
1038
+ ...(session.untracked === false ? { untracked: false } : {}),
1021
1039
  comments: normalized
1022
1040
  };
1023
1041
 
@@ -1066,7 +1084,6 @@ function createApp(options = {}) {
1066
1084
  }
1067
1085
 
1068
1086
  const generatedAt = new Date();
1069
- const reviewContent = formatReview({ repoPath: session.repoPath, comments, generatedAt });
1070
1087
  const filename = reviewFilename(session.repoPath, generatedAt);
1071
1088
 
1072
1089
  // The commit is worth recording for anything acting on the review later:
@@ -1082,7 +1099,12 @@ function createApp(options = {}) {
1082
1099
  head,
1083
1100
  branch,
1084
1101
  mode: session.mode,
1085
- 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
1086
1108
  });
1087
1109
  const documentFilename = filename.replace(/\.txt$/, '.json');
1088
1110