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 +59 -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 +2 -2
- 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 +1 -1
- package/public/app.js +72 -12
- package/public/style.css +6 -0
- package/server.js +32 -10
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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
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 #
|
|
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
|
|
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
|
@@ -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, {
|
|
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",
|
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
|
@@ -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');
|
|
@@ -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 =
|
|
893
|
+
files = await collectWorkingTreeChanges(git, untracked);
|
|
891
894
|
mode = 'working';
|
|
892
|
-
message =
|
|
895
|
+
message = describeWorkingChanges(files, untracked);
|
|
893
896
|
|
|
894
897
|
// A clean tree has nothing to review, so fall back to the last commit.
|
|
895
|
-
|
|
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
|
|