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 +87 -2
- package/bin/reviewer.js +30 -6
- package/lib/agent.js +13 -3
- package/lib/changes.js +83 -4
- package/lib/cli.js +50 -13
- package/lib/export.js +3 -3
- package/lib/repo.js +1 -1
- package/lib/review.js +3 -1
- package/lib/sessions.js +8 -4
- package/lib/store.js +11 -3
- package/man/git-reviewer.1 +11 -0
- package/package.json +2 -2
- package/public/app.js +72 -12
- package/public/style.css +6 -0
- package/server.js +108 -30
package/README.md
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
[](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](package.json)
|
|
6
|
-
[](test/)
|
|
7
7
|
[](package.json)
|
|
8
8
|
|
|
9
|
+
**English** · [简体中文](docs/README.zh-CN.md) · [日本語](docs/README.ja.md)
|
|
10
|
+
|
|
9
11
|
When an agent writes the code, reading it becomes the bottleneck — and the tool
|
|
10
12
|
built for reading a diff, the pull request, wants a branch, a remote and a push
|
|
11
13
|
you are not ready to make yet.
|
|
@@ -77,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 #
|
|
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
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
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, {
|
|
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 {
|
|
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 {
|
|
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 };
|
package/man/git-reviewer.1
CHANGED
|
@@ -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.
|
|
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": "^
|
|
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
|
-
|
|
499
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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/${
|
|
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/${
|
|
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
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 {
|
|
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
|
-
* `
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
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
|
|
676
|
-
|
|
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.
|
|
690
|
-
*
|
|
691
|
-
*
|
|
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',
|
|
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',
|
|
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',
|
|
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 =
|
|
893
|
+
files = await collectWorkingTreeChanges(git, untracked);
|
|
836
894
|
mode = 'working';
|
|
837
|
-
message =
|
|
895
|
+
message = describeWorkingChanges(files, untracked);
|
|
838
896
|
|
|
839
897
|
// A clean tree has nothing to review, so fall back to the last commit.
|
|
840
|
-
|
|
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
|
-
|
|
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, () => {
|