git-reviewer 2.6.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 CHANGED
@@ -3,7 +3,7 @@
3
3
  [![CI](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml/badge.svg)](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
5
  [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](package.json)
6
- [![Tests](https://img.shields.io/badge/tests-272-brightgreen.svg)](test/)
6
+ [![Tests](https://img.shields.io/badge/tests-281-brightgreen.svg)](test/)
7
7
  [![Dependencies](https://img.shields.io/badge/dependencies-3-brightgreen.svg)](package.json)
8
8
 
9
9
  When an agent writes the code, reading it becomes the bottleneck — and the tool
@@ -294,7 +294,7 @@ rather than a public issue.
294
294
 
295
295
  ```bash
296
296
  npm install # 3 dependencies, no build step, ~6MB
297
- npm test # 272 tests
297
+ npm test # 281 tests
298
298
  npm run test:watch
299
299
  npm run test:coverage
300
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 filePath = typeof entry === 'string' ? entry : entry?.to;
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 => ({ path: file.file, status: classifyCommitFile(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 = { collectWorkingChanges, collectCommitChanges, classifyCommitFile };
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.6.0",
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",