git-reviewer 2.5.0 → 2.7.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 +8 -6
- package/lib/changes.js +129 -4
- package/package.json +1 -1
- package/public/app.js +38 -0
- package/public/style.css +21 -0
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
|
When an agent writes the code, reading it becomes the bottleneck — and the tool
|
|
@@ -32,16 +32,18 @@ review was saved and keeps serving, because you may have more to read.
|
|
|
32
32
|
Everything it says to you goes to stderr either way, so only the review is
|
|
33
33
|
ever on stdout.
|
|
34
34
|
|
|
35
|
-

|
|
36
36
|
|
|
37
|
-
*
|
|
37
|
+
*One command, start to finish, unedited — [full resolution](docs/demo.mp4). Every
|
|
38
|
+
frame is the real tool: the terminal output is captured from the run you are
|
|
39
|
+
watching, and the code at the end is what the agent actually wrote.*
|
|
38
40
|
|
|
39
41
|
Nothing is installed into the project, there is nothing to sign in to, and
|
|
40
42
|
nothing leaves your machine. If you would rather not install it,
|
|
41
43
|
`npx git-reviewer` works the same way.
|
|
42
44
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
When the review and the hand-off happen at different times, the two-step form
|
|
46
|
+
still works and is the one to reach for:
|
|
45
47
|
|
|
46
48
|
```bash
|
|
47
49
|
reviewer # read the diff, comment on it
|
|
@@ -292,7 +294,7 @@ rather than a public issue.
|
|
|
292
294
|
|
|
293
295
|
```bash
|
|
294
296
|
npm install # 3 dependencies, no build step, ~6MB
|
|
295
|
-
npm test #
|
|
297
|
+
npm test # 281 tests
|
|
296
298
|
npm run test:watch
|
|
297
299
|
npm run test:coverage
|
|
298
300
|
```
|
package/lib/changes.js
CHANGED
|
@@ -14,6 +14,119 @@
|
|
|
14
14
|
* @property {'A'|'M'|'D'|'R'|'B'} status
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Unquotes git's octal-escaped, C-quoted filename strings (e.g. "\"caf\\303\\251.txt\"" -> "café.txt").
|
|
19
|
+
*
|
|
20
|
+
* When core.quotePath is enabled (default in git), paths containing non-ASCII
|
|
21
|
+
* or control characters are enclosed in double quotes and byte-escaped with octal
|
|
22
|
+
* sequences.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} str
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
function unquoteGitPath(str) {
|
|
28
|
+
if (typeof str !== 'string') return str;
|
|
29
|
+
if (str.length >= 2 && str.startsWith('"') && str.endsWith('"')) {
|
|
30
|
+
const inner = str.slice(1, -1);
|
|
31
|
+
const bytes = [];
|
|
32
|
+
for (let i = 0; i < inner.length; i++) {
|
|
33
|
+
if (inner[i] === '\\' && i + 1 < inner.length) {
|
|
34
|
+
i++;
|
|
35
|
+
const next = inner[i];
|
|
36
|
+
if (next >= '0' && next <= '7') {
|
|
37
|
+
let octal = next;
|
|
38
|
+
if (i + 1 < inner.length && inner[i + 1] >= '0' && inner[i + 1] <= '7') {
|
|
39
|
+
i++;
|
|
40
|
+
octal += inner[i];
|
|
41
|
+
if (i + 1 < inner.length && inner[i + 1] >= '0' && inner[i + 1] <= '7') {
|
|
42
|
+
i++;
|
|
43
|
+
octal += inner[i];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
bytes.push(parseInt(octal, 8));
|
|
47
|
+
} else if (next === 'a') {
|
|
48
|
+
bytes.push(0x07);
|
|
49
|
+
} else if (next === 'b') {
|
|
50
|
+
bytes.push(0x08);
|
|
51
|
+
} else if (next === 't') {
|
|
52
|
+
bytes.push(0x09);
|
|
53
|
+
} else if (next === 'n') {
|
|
54
|
+
bytes.push(0x0a);
|
|
55
|
+
} else if (next === 'v') {
|
|
56
|
+
bytes.push(0x0b);
|
|
57
|
+
} else if (next === 'f') {
|
|
58
|
+
bytes.push(0x0c);
|
|
59
|
+
} else if (next === 'r') {
|
|
60
|
+
bytes.push(0x0d);
|
|
61
|
+
} else if (next === '"') {
|
|
62
|
+
bytes.push(0x22);
|
|
63
|
+
} else if (next === '\\') {
|
|
64
|
+
bytes.push(0x5c);
|
|
65
|
+
} else {
|
|
66
|
+
const code = next.charCodeAt(0);
|
|
67
|
+
if (code >= 0xd800 && code <= 0xdbff && i + 1 < inner.length) {
|
|
68
|
+
const nextCode = inner.charCodeAt(i + 1);
|
|
69
|
+
if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
|
|
70
|
+
const pair = inner.slice(i, i + 2);
|
|
71
|
+
i++;
|
|
72
|
+
const buf = Buffer.from(pair, 'utf8');
|
|
73
|
+
for (const b of buf) bytes.push(b);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const buf = Buffer.from(next, 'utf8');
|
|
78
|
+
for (const b of buf) bytes.push(b);
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
const code = inner.charCodeAt(i);
|
|
82
|
+
if (code >= 0xd800 && code <= 0xdbff && i + 1 < inner.length) {
|
|
83
|
+
const nextCode = inner.charCodeAt(i + 1);
|
|
84
|
+
if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
|
|
85
|
+
const pair = inner.slice(i, i + 2);
|
|
86
|
+
i++;
|
|
87
|
+
const buf = Buffer.from(pair, 'utf8');
|
|
88
|
+
for (const b of buf) bytes.push(b);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const buf = Buffer.from(inner[i], 'utf8');
|
|
93
|
+
for (const b of buf) bytes.push(b);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return Buffer.from(bytes).toString('utf8');
|
|
97
|
+
}
|
|
98
|
+
return str;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Extracts the target path and whether the file entry represents a rename
|
|
103
|
+
* from git diff --stat display format (e.g. "old => new", "prefix/{old => new}.ext").
|
|
104
|
+
*
|
|
105
|
+
* @param {string} raw
|
|
106
|
+
* @returns {{ isRename: boolean, path: string }}
|
|
107
|
+
*/
|
|
108
|
+
function parseCommitFilePath(raw) {
|
|
109
|
+
if (typeof raw !== 'string') return { isRename: false, path: raw ?? '' };
|
|
110
|
+
|
|
111
|
+
// 1. Brace rename: prefix{old => new}suffix or {old => new}
|
|
112
|
+
const braceMatch = raw.match(/^(.*?)\{(?:.*?) => (.*?)\}(.*)$/);
|
|
113
|
+
if (braceMatch) {
|
|
114
|
+
const rawTo = braceMatch[1] + braceMatch[2] + braceMatch[3];
|
|
115
|
+
const normalized = unquoteGitPath(rawTo).replace(/\/{2,}/g, '/').replace(/^\/+/, '');
|
|
116
|
+
return { isRename: true, path: normalized };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 2. Arrow rename: old => new (or "old" => "new")
|
|
120
|
+
const arrowMatch = raw.match(/^(?:.*?) => (.*)$/);
|
|
121
|
+
if (arrowMatch) {
|
|
122
|
+
const rawTo = arrowMatch[1].trim();
|
|
123
|
+
const normalized = unquoteGitPath(rawTo).replace(/\/{2,}/g, '/').replace(/^\/+/, '');
|
|
124
|
+
return { isRename: true, path: normalized };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { isRename: false, path: unquoteGitPath(raw).replace(/^\/+/, '') };
|
|
128
|
+
}
|
|
129
|
+
|
|
17
130
|
/**
|
|
18
131
|
* Collect working-directory changes from a `simple-git` status result.
|
|
19
132
|
*
|
|
@@ -39,7 +152,8 @@ function collectWorkingChanges(status) {
|
|
|
39
152
|
for (const [files, letter] of buckets) {
|
|
40
153
|
for (const entry of files) {
|
|
41
154
|
// `renamed` holds `{ from, to }`; every other bucket holds plain strings.
|
|
42
|
-
const
|
|
155
|
+
const rawPath = typeof entry === 'string' ? entry : entry?.to;
|
|
156
|
+
const filePath = unquoteGitPath(rawPath);
|
|
43
157
|
if (!filePath || seen.has(filePath)) continue;
|
|
44
158
|
seen.set(filePath, { path: filePath, status: letter });
|
|
45
159
|
}
|
|
@@ -56,9 +170,10 @@ function collectWorkingChanges(status) {
|
|
|
56
170
|
* it deletes none.
|
|
57
171
|
*
|
|
58
172
|
* @param {object} file entry from `git.diffSummary()`
|
|
59
|
-
* @returns {'A'|'M'|'D'|'B'}
|
|
173
|
+
* @returns {'A'|'M'|'D'|'R'|'B'}
|
|
60
174
|
*/
|
|
61
175
|
function classifyCommitFile(file) {
|
|
176
|
+
if (file?.file && parseCommitFilePath(file.file).isRename) return 'R';
|
|
62
177
|
if (file?.binary) return 'B';
|
|
63
178
|
|
|
64
179
|
const insertions = file?.insertions ?? 0;
|
|
@@ -78,7 +193,17 @@ function classifyCommitFile(file) {
|
|
|
78
193
|
*/
|
|
79
194
|
function collectCommitChanges(diffSummary) {
|
|
80
195
|
const files = diffSummary?.files ?? [];
|
|
81
|
-
return files.map(file =>
|
|
196
|
+
return files.map(file => {
|
|
197
|
+
const { path, isRename } = parseCommitFilePath(file?.file);
|
|
198
|
+
const status = isRename ? 'R' : classifyCommitFile(file);
|
|
199
|
+
return { path, status };
|
|
200
|
+
});
|
|
82
201
|
}
|
|
83
202
|
|
|
84
|
-
module.exports = {
|
|
203
|
+
module.exports = {
|
|
204
|
+
collectWorkingChanges,
|
|
205
|
+
collectCommitChanges,
|
|
206
|
+
classifyCommitFile,
|
|
207
|
+
unquoteGitPath,
|
|
208
|
+
parseCommitFilePath
|
|
209
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-reviewer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.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
|
@@ -254,8 +254,12 @@ function displayFiles(files) {
|
|
|
254
254
|
// before the handler is parsed, so an escaped quote is a quote again by
|
|
255
255
|
// the time it matters. The row carries its position instead, and the name
|
|
256
256
|
// is read back out of `currentFiles`, where it stays data.
|
|
257
|
+
// The count is filled in by `updateFileCommentCounts`, not here. Rendering
|
|
258
|
+
// it now would mean re-rendering this list on every comment to keep it
|
|
259
|
+
// true, and re-rendering the list is what loses the selected row.
|
|
257
260
|
return `<div class="file-item" onclick="openFileAt(${Number(index)})" title="${escapeHtml(file)}">
|
|
258
261
|
${statusBadge}<span class="file-path-text">${displayText}</span>
|
|
262
|
+
<span class="file-comment-count" data-file-index="${Number(index)}" hidden></span>
|
|
259
263
|
</div>`;
|
|
260
264
|
}).join('');
|
|
261
265
|
|
|
@@ -1470,12 +1474,46 @@ function initResizeHandle() {
|
|
|
1470
1474
|
let currentFileIndex = -1;
|
|
1471
1475
|
|
|
1472
1476
|
// Update comments sidebar
|
|
1477
|
+
/**
|
|
1478
|
+
* How many comments sit on each file, on the file rows themselves.
|
|
1479
|
+
*
|
|
1480
|
+
* In a review of any size the question you keep asking is which files you have
|
|
1481
|
+
* already covered, and the only answer on screen was a total in the sidebar
|
|
1482
|
+
* header — so you found out by clicking through every file (#48).
|
|
1483
|
+
*
|
|
1484
|
+
* Updated in place rather than by re-rendering the list. Re-rendering would be
|
|
1485
|
+
* one line, and it would drop the selected row and the scroll position of the
|
|
1486
|
+
* file list every time anyone typed a comment.
|
|
1487
|
+
*/
|
|
1488
|
+
function updateFileCommentCounts() {
|
|
1489
|
+
const badges = document.querySelectorAll('.file-comment-count');
|
|
1490
|
+
if (badges.length === 0) return;
|
|
1491
|
+
|
|
1492
|
+
const perFile = new Map();
|
|
1493
|
+
for (const comment of comments) {
|
|
1494
|
+
perFile.set(comment.file, (perFile.get(comment.file) ?? 0) + 1);
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
for (const badge of badges) {
|
|
1498
|
+
const file = currentFiles[Number(badge.dataset.fileIndex)];
|
|
1499
|
+
const count = file ? perFile.get(file.path) ?? 0 : 0;
|
|
1500
|
+
|
|
1501
|
+
badge.textContent = count;
|
|
1502
|
+
badge.title = `${count} comment${count === 1 ? '' : 's'} on this file`;
|
|
1503
|
+
badge.hidden = count === 0;
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1473
1507
|
function updateCommentsSidebar() {
|
|
1474
1508
|
const sidebar = document.getElementById('commentsSidebar');
|
|
1475
1509
|
const commentsList = document.getElementById('commentsList');
|
|
1476
1510
|
const commentsCount = document.getElementById('commentsCount');
|
|
1477
1511
|
const commentsResizeHandle = document.getElementById('commentsResizeHandle');
|
|
1478
1512
|
|
|
1513
|
+
// Every path that changes a comment already calls this, so the per-file
|
|
1514
|
+
// counts ride along rather than needing ten more call sites of their own.
|
|
1515
|
+
updateFileCommentCounts();
|
|
1516
|
+
|
|
1479
1517
|
commentsCount.textContent = comments.length;
|
|
1480
1518
|
|
|
1481
1519
|
if (comments.length === 0) {
|
package/public/style.css
CHANGED
|
@@ -1836,3 +1836,24 @@ button:disabled {
|
|
|
1836
1836
|
@media (max-width: 640px) {
|
|
1837
1837
|
.help-steps { grid-template-columns: 1fr; }
|
|
1838
1838
|
}
|
|
1839
|
+
|
|
1840
|
+
/* Comments per file
|
|
1841
|
+
*
|
|
1842
|
+
* In a review of any size the question you keep asking is which files you have
|
|
1843
|
+
* already covered. The only answer on screen was a total in the sidebar
|
|
1844
|
+
* header, so you found out by clicking through every file.
|
|
1845
|
+
*/
|
|
1846
|
+
.file-comment-count {
|
|
1847
|
+
flex: none;
|
|
1848
|
+
min-width: 18px;
|
|
1849
|
+
margin-left: auto;
|
|
1850
|
+
padding: 1px 6px;
|
|
1851
|
+
border-radius: 20px;
|
|
1852
|
+
background: #fff3cd;
|
|
1853
|
+
border: 1px solid #f0e0a0;
|
|
1854
|
+
font-size: 11px;
|
|
1855
|
+
font-weight: 600;
|
|
1856
|
+
line-height: 1.5;
|
|
1857
|
+
color: #7a5d00;
|
|
1858
|
+
text-align: center;
|
|
1859
|
+
}
|