@in-the-loop-labs/pair-review 3.3.6 → 3.3.7
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/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/js/components/AIPanel.js +3 -14
- package/public/js/modules/diff-renderer.js +100 -7
- package/public/js/modules/file-comment-manager.js +34 -13
- package/public/js/modules/suggestion-manager.js +22 -6
- package/public/js/pr.js +3 -3
- package/src/config.js +12 -0
- package/src/git/diff-flags.js +5 -1
- package/src/git/worktree.js +23 -4
- package/src/local-review.js +26 -10
- package/src/routes/pr.js +26 -10
- package/src/utils/diff-file-list.js +111 -2
- package/src/utils/paths.js +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pair-review",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.7",
|
|
4
4
|
"description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "in-the-loop-labs",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "code-critic",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.7",
|
|
4
4
|
"description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "in-the-loop-labs",
|
|
@@ -965,20 +965,9 @@ class AIPanel {
|
|
|
965
965
|
expandFileIfCollapsed(file) {
|
|
966
966
|
if (!file) return false;
|
|
967
967
|
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
// Fallback: partial path match
|
|
972
|
-
if (!fileWrapper) {
|
|
973
|
-
const allWrappers = document.querySelectorAll('.d2h-file-wrapper');
|
|
974
|
-
for (const wrapper of allWrappers) {
|
|
975
|
-
const wrapperFile = wrapper.dataset.fileName;
|
|
976
|
-
if (wrapperFile && (wrapperFile.includes(file) || file.includes(wrapperFile))) {
|
|
977
|
-
fileWrapper = wrapper;
|
|
978
|
-
break;
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
}
|
|
968
|
+
const fileWrapper = window.prManager?.findFileElement
|
|
969
|
+
? window.prManager.findFileElement(file)
|
|
970
|
+
: window.DiffRenderer?.findFileElement?.(file);
|
|
982
971
|
|
|
983
972
|
if (!fileWrapper) return false;
|
|
984
973
|
|
|
@@ -194,6 +194,88 @@ class DiffRenderer {
|
|
|
194
194
|
return div.innerHTML;
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Normalize a file path for DOM matching.
|
|
199
|
+
*
|
|
200
|
+
* Frontend mirror of `normalizePath()` + `resolveRenamedFile()` from
|
|
201
|
+
* `src/utils/paths.js`. Browser code cannot `require()` the backend module,
|
|
202
|
+
* so the logic is duplicated here. Keep this function in sync with those two
|
|
203
|
+
* when modifying normalization rules, otherwise the frontend and backend
|
|
204
|
+
* will disagree on which paths are equivalent.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} filePath - File path to normalize
|
|
207
|
+
* @returns {string} Normalized file path
|
|
208
|
+
*/
|
|
209
|
+
static normalizeFilePath(filePath) {
|
|
210
|
+
if (typeof filePath !== 'string') return '';
|
|
211
|
+
|
|
212
|
+
let normalized = filePath.trim();
|
|
213
|
+
if (!normalized) return '';
|
|
214
|
+
|
|
215
|
+
// Resolve git rename syntax to the new path.
|
|
216
|
+
normalized = normalized.replace(/\{[^}]*\s*=>\s*([^}]*)\}/, '$1');
|
|
217
|
+
normalized = normalized.replace(/\/+/g, '/');
|
|
218
|
+
|
|
219
|
+
// Strip leading './' and '/' segments until the string stops changing.
|
|
220
|
+
// This mirrors normalizePath in src/utils/paths.js for interleaved cases
|
|
221
|
+
// like '/./src/foo.js'.
|
|
222
|
+
let prevLength;
|
|
223
|
+
do {
|
|
224
|
+
prevLength = normalized.length;
|
|
225
|
+
|
|
226
|
+
while (normalized.startsWith('./')) {
|
|
227
|
+
normalized = normalized.slice(2);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
while (normalized.startsWith('/')) {
|
|
231
|
+
normalized = normalized.slice(1);
|
|
232
|
+
}
|
|
233
|
+
} while (normalized.length !== prevLength);
|
|
234
|
+
|
|
235
|
+
return normalized;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Check whether two file paths should be treated as the same DOM target.
|
|
240
|
+
* @param {string} left - First file path
|
|
241
|
+
* @param {string} right - Second file path
|
|
242
|
+
* @returns {boolean} True when the paths refer to the same file
|
|
243
|
+
*/
|
|
244
|
+
static pathsMatch(left, right) {
|
|
245
|
+
const normalizedLeft = DiffRenderer.normalizeFilePath(left);
|
|
246
|
+
const normalizedRight = DiffRenderer.normalizeFilePath(right);
|
|
247
|
+
|
|
248
|
+
if (!normalizedLeft || !normalizedRight) return false;
|
|
249
|
+
|
|
250
|
+
return normalizedLeft === normalizedRight ||
|
|
251
|
+
normalizedLeft.endsWith(`/${normalizedRight}`) ||
|
|
252
|
+
normalizedRight.endsWith(`/${normalizedLeft}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Try a direct selector lookup for a file path.
|
|
257
|
+
* Falls back silently when CSS.escape is unavailable or the selector is invalid.
|
|
258
|
+
* @param {string} attribute - data attribute to match (without brackets)
|
|
259
|
+
* @param {string} filePath - File path to search for
|
|
260
|
+
* @returns {Element|null} Matching element if found
|
|
261
|
+
*/
|
|
262
|
+
static queryFileElement(attribute, filePath) {
|
|
263
|
+
if (!filePath) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const css = globalThis.CSS;
|
|
268
|
+
const escapedValue = css && typeof css.escape === 'function'
|
|
269
|
+
? css.escape(filePath)
|
|
270
|
+
: filePath;
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
return document.querySelector(`[${attribute}="${escapedValue}"]`);
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
197
279
|
/**
|
|
198
280
|
* Render a single diff line
|
|
199
281
|
* @param {HTMLElement|DocumentFragment} container - Container to append to
|
|
@@ -696,18 +778,29 @@ class DiffRenderer {
|
|
|
696
778
|
* @returns {Element|null} The file wrapper element or null if not found
|
|
697
779
|
*/
|
|
698
780
|
static findFileElement(filePath) {
|
|
699
|
-
|
|
700
|
-
let fileElement = document.querySelector(`[data-file-name="${filePath}"]`);
|
|
701
|
-
if (fileElement) return fileElement;
|
|
781
|
+
const normalizedPath = DiffRenderer.normalizeFilePath(filePath);
|
|
702
782
|
|
|
703
|
-
|
|
704
|
-
|
|
783
|
+
// Try direct selector lookups first when we can safely escape the value.
|
|
784
|
+
const selectorCandidates = [filePath];
|
|
785
|
+
if (normalizedPath && normalizedPath !== filePath) {
|
|
786
|
+
selectorCandidates.push(normalizedPath);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
for (const candidate of selectorCandidates) {
|
|
790
|
+
let fileElement = DiffRenderer.queryFileElement('data-file-name', candidate);
|
|
791
|
+
if (fileElement) return fileElement;
|
|
792
|
+
|
|
793
|
+
fileElement = DiffRenderer.queryFileElement('data-file-path', candidate);
|
|
794
|
+
if (fileElement) return fileElement;
|
|
795
|
+
}
|
|
705
796
|
|
|
706
|
-
//
|
|
797
|
+
// Fall back to normalized iteration so special characters, rename syntax,
|
|
798
|
+
// and formatted variants still resolve to the rendered wrapper.
|
|
707
799
|
const allFileWrappers = document.querySelectorAll('.d2h-file-wrapper');
|
|
708
800
|
for (const wrapper of allFileWrappers) {
|
|
709
801
|
const fileName = wrapper.dataset.fileName;
|
|
710
|
-
|
|
802
|
+
const filePathAttr = wrapper.dataset.filePath;
|
|
803
|
+
if (DiffRenderer.pathsMatch(fileName, filePath) || DiffRenderer.pathsMatch(filePathAttr, filePath)) {
|
|
711
804
|
return wrapper;
|
|
712
805
|
}
|
|
713
806
|
}
|
|
@@ -1168,17 +1168,22 @@ class FileCommentManager {
|
|
|
1168
1168
|
* @param {Array} suggestions - Array of file-level AI suggestions
|
|
1169
1169
|
*/
|
|
1170
1170
|
loadFileComments(comments, suggestions) {
|
|
1171
|
-
// Group by file
|
|
1172
|
-
|
|
1173
|
-
const
|
|
1171
|
+
// Group by rendered file comments zone so path variants still attach to the
|
|
1172
|
+
// correct file on initial load.
|
|
1173
|
+
const commentsByZone = new Map();
|
|
1174
|
+
const suggestionsByZone = new Map();
|
|
1174
1175
|
|
|
1175
1176
|
if (comments) {
|
|
1176
1177
|
for (const comment of comments) {
|
|
1177
1178
|
if (comment.is_file_level === 1) {
|
|
1178
|
-
|
|
1179
|
-
|
|
1179
|
+
const zone = this.findZoneForFile(comment.file);
|
|
1180
|
+
if (!zone) {
|
|
1181
|
+
continue;
|
|
1180
1182
|
}
|
|
1181
|
-
|
|
1183
|
+
if (!commentsByZone.has(zone)) {
|
|
1184
|
+
commentsByZone.set(zone, []);
|
|
1185
|
+
}
|
|
1186
|
+
commentsByZone.get(zone).push(comment);
|
|
1182
1187
|
}
|
|
1183
1188
|
}
|
|
1184
1189
|
}
|
|
@@ -1186,10 +1191,14 @@ class FileCommentManager {
|
|
|
1186
1191
|
if (suggestions) {
|
|
1187
1192
|
for (const suggestion of suggestions) {
|
|
1188
1193
|
if (suggestion.is_file_level === 1) {
|
|
1189
|
-
|
|
1190
|
-
|
|
1194
|
+
const zone = this.findZoneForFile(suggestion.file);
|
|
1195
|
+
if (!zone) {
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
if (!suggestionsByZone.has(zone)) {
|
|
1199
|
+
suggestionsByZone.set(zone, []);
|
|
1191
1200
|
}
|
|
1192
|
-
|
|
1201
|
+
suggestionsByZone.get(zone).push(suggestion);
|
|
1193
1202
|
}
|
|
1194
1203
|
}
|
|
1195
1204
|
}
|
|
@@ -1197,7 +1206,6 @@ class FileCommentManager {
|
|
|
1197
1206
|
// Find all file comment zones and populate them
|
|
1198
1207
|
const zones = document.querySelectorAll('.file-comments-zone');
|
|
1199
1208
|
for (const zone of zones) {
|
|
1200
|
-
const fileName = zone.dataset.fileName;
|
|
1201
1209
|
const container = zone.querySelector('.file-comments-container');
|
|
1202
1210
|
|
|
1203
1211
|
// Selectively clear existing cards based on what we're about to reload
|
|
@@ -1221,8 +1229,8 @@ class FileCommentManager {
|
|
|
1221
1229
|
}
|
|
1222
1230
|
}
|
|
1223
1231
|
|
|
1224
|
-
const fileComments =
|
|
1225
|
-
const fileSuggestions =
|
|
1232
|
+
const fileComments = commentsByZone.get(zone) || [];
|
|
1233
|
+
const fileSuggestions = suggestionsByZone.get(zone) || [];
|
|
1226
1234
|
|
|
1227
1235
|
// Display AI suggestions first
|
|
1228
1236
|
for (const suggestion of fileSuggestions) {
|
|
@@ -1245,7 +1253,20 @@ class FileCommentManager {
|
|
|
1245
1253
|
* @returns {HTMLElement|null} The zone element or null
|
|
1246
1254
|
*/
|
|
1247
1255
|
findZoneForFile(fileName) {
|
|
1248
|
-
|
|
1256
|
+
const fileWrapper = window.DiffRenderer?.findFileElement
|
|
1257
|
+
? window.DiffRenderer.findFileElement(fileName)
|
|
1258
|
+
: null;
|
|
1259
|
+
|
|
1260
|
+
if (fileWrapper) {
|
|
1261
|
+
return fileWrapper.querySelector('.file-comments-zone');
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
try {
|
|
1265
|
+
const escaped = globalThis.CSS?.escape ? CSS.escape(fileName) : fileName;
|
|
1266
|
+
return document.querySelector(`.file-comments-zone[data-file-name="${escaped}"]`);
|
|
1267
|
+
} catch {
|
|
1268
|
+
return null;
|
|
1269
|
+
}
|
|
1249
1270
|
}
|
|
1250
1271
|
|
|
1251
1272
|
/**
|
|
@@ -160,6 +160,26 @@ class SuggestionManager {
|
|
|
160
160
|
return window.CategoryEmoji?.getEmoji(category) || '\u{1F4AC}';
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Resolve a diff file wrapper for the provided path.
|
|
165
|
+
* @param {string} file - File path
|
|
166
|
+
* @returns {Element|null} Matching file wrapper
|
|
167
|
+
*/
|
|
168
|
+
findFileElement(file) {
|
|
169
|
+
if (this.prManager?.findFileElement) {
|
|
170
|
+
return this.prManager.findFileElement(file);
|
|
171
|
+
}
|
|
172
|
+
if (window.DiffRenderer?.findFileElement) {
|
|
173
|
+
return window.DiffRenderer.findFileElement(file);
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const escaped = globalThis.CSS?.escape ? CSS.escape(file) : file;
|
|
177
|
+
return document.querySelector(`[data-file-name="${escaped}"]`);
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
163
183
|
/**
|
|
164
184
|
* Find suggestions that target lines currently hidden in gaps
|
|
165
185
|
* @param {Array} suggestions - Array of suggestions
|
|
@@ -176,9 +196,7 @@ class SuggestionManager {
|
|
|
176
196
|
const side = suggestion.side || 'RIGHT';
|
|
177
197
|
|
|
178
198
|
// Find the file wrapper
|
|
179
|
-
const fileElement =
|
|
180
|
-
window.DiffRenderer.findFileElement(file) :
|
|
181
|
-
document.querySelector(`[data-file-name="${file}"]`);
|
|
199
|
+
const fileElement = this.findFileElement(file);
|
|
182
200
|
|
|
183
201
|
if (!fileElement) {
|
|
184
202
|
// File not in diff at all, not a hidden line issue
|
|
@@ -329,9 +347,7 @@ class SuggestionManager {
|
|
|
329
347
|
const line = parseInt(lineStr);
|
|
330
348
|
|
|
331
349
|
// Use helper method for file lookup
|
|
332
|
-
const fileElement =
|
|
333
|
-
window.DiffRenderer.findFileElement(file) :
|
|
334
|
-
document.querySelector(`[data-file-name="${file}"]`);
|
|
350
|
+
const fileElement = this.findFileElement(file);
|
|
335
351
|
|
|
336
352
|
if (!fileElement) {
|
|
337
353
|
// This can happen when AI suggests a file path that doesn't exist in the diff
|
package/public/js/pr.js
CHANGED
|
@@ -2032,7 +2032,7 @@ class PRManager {
|
|
|
2032
2032
|
* @param {string} filePath - Path of the file
|
|
2033
2033
|
*/
|
|
2034
2034
|
toggleFileCollapse(filePath) {
|
|
2035
|
-
const wrapper =
|
|
2035
|
+
const wrapper = this.findFileElement(filePath);
|
|
2036
2036
|
if (!wrapper) return;
|
|
2037
2037
|
|
|
2038
2038
|
const isCollapsed = wrapper.classList.contains('collapsed');
|
|
@@ -2058,7 +2058,7 @@ class PRManager {
|
|
|
2058
2058
|
* @param {boolean} isViewed - Whether the file is now viewed
|
|
2059
2059
|
*/
|
|
2060
2060
|
toggleFileViewed(filePath, isViewed) {
|
|
2061
|
-
const wrapper =
|
|
2061
|
+
const wrapper = this.findFileElement(filePath);
|
|
2062
2062
|
|
|
2063
2063
|
if (isViewed) {
|
|
2064
2064
|
this.viewedFiles.add(filePath);
|
|
@@ -4359,7 +4359,7 @@ class PRManager {
|
|
|
4359
4359
|
}
|
|
4360
4360
|
|
|
4361
4361
|
scrollToFile(filePath) {
|
|
4362
|
-
const fileWrapper =
|
|
4362
|
+
const fileWrapper = this.findFileElement(filePath);
|
|
4363
4363
|
if (fileWrapper) {
|
|
4364
4364
|
fileWrapper.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
4365
4365
|
}
|
package/src/config.js
CHANGED
|
@@ -517,6 +517,17 @@ function getRepoResetScript(config, repository) {
|
|
|
517
517
|
return repoConfig?.reset_script || null;
|
|
518
518
|
}
|
|
519
519
|
|
|
520
|
+
/**
|
|
521
|
+
* Gets whether updateWorktree should skip the bulk `git fetch <remote> --prune`
|
|
522
|
+
* @param {Object} config - Configuration object from loadConfig()
|
|
523
|
+
* @param {string} repository - Repository in "owner/repo" format
|
|
524
|
+
* @returns {boolean} - true if the bulk fetch should be skipped (default: false)
|
|
525
|
+
*/
|
|
526
|
+
function getRepoSkipBulkFetch(config, repository) {
|
|
527
|
+
const repoConfig = getRepoConfig(config, repository);
|
|
528
|
+
return repoConfig?.skip_bulk_fetch === true;
|
|
529
|
+
}
|
|
530
|
+
|
|
520
531
|
/**
|
|
521
532
|
* Gets the configured pool size for a repository from file config only.
|
|
522
533
|
* Prefer resolvePoolConfig() when DB repo_settings are available.
|
|
@@ -750,6 +761,7 @@ module.exports = {
|
|
|
750
761
|
getRepoCheckoutTimeout,
|
|
751
762
|
resolveRepoOptions,
|
|
752
763
|
getRepoResetScript,
|
|
764
|
+
getRepoSkipBulkFetch,
|
|
753
765
|
getRepoPoolSize,
|
|
754
766
|
getRepoPoolFetchInterval,
|
|
755
767
|
getRepoLoadSkills,
|
package/src/git/diff-flags.js
CHANGED
|
@@ -30,12 +30,16 @@ const GIT_DIFF_FLAGS_ARRAY = [
|
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* Array form for simple-git .diffSummary() calls.
|
|
33
|
+
* Use --numstat so simple-git parses machine-readable output with exact file paths.
|
|
34
|
+
* The default --stat output is display-oriented and may abbreviate long paths,
|
|
35
|
+
* which breaks downstream matching for generated route files and similar cases.
|
|
33
36
|
* Omits --src-prefix/--dst-prefix since diffSummary doesn't output file content with prefixes.
|
|
34
37
|
*/
|
|
35
38
|
const GIT_DIFF_SUMMARY_FLAGS_ARRAY = [
|
|
36
39
|
'--no-color',
|
|
37
40
|
'--no-ext-diff',
|
|
38
|
-
'--no-relative'
|
|
41
|
+
'--no-relative',
|
|
42
|
+
'--numstat'
|
|
39
43
|
];
|
|
40
44
|
|
|
41
45
|
module.exports = {
|
package/src/git/worktree.js
CHANGED
|
@@ -733,9 +733,11 @@ class GitWorktreeManager {
|
|
|
733
733
|
* @param {string} repo - Repository name
|
|
734
734
|
* @param {number} number - PR number
|
|
735
735
|
* @param {Object} prData - PR data from GitHub API (for remote resolution)
|
|
736
|
+
* @param {Object} [options]
|
|
737
|
+
* @param {boolean} [options.skipBulkFetch=false] - Skip the bulk `git fetch <remote> --prune`; targeted base-SHA and PR-head fetches still run
|
|
736
738
|
* @returns {Promise<string>} Path to updated worktree
|
|
737
739
|
*/
|
|
738
|
-
async updateWorktree(owner, repo, number, prData) {
|
|
740
|
+
async updateWorktree(owner, repo, number, prData, options = {}) {
|
|
739
741
|
const prInfo = { owner, repo, number };
|
|
740
742
|
const headSha = this.getPRHeadSha(prData);
|
|
741
743
|
const worktreePath = await this.getWorktreePath(prInfo);
|
|
@@ -756,9 +758,26 @@ class GitWorktreeManager {
|
|
|
756
758
|
const remote = await this.resolveRemoteForPR(worktreeGit, prData, prInfo);
|
|
757
759
|
|
|
758
760
|
// Fetch the latest from the resolved remote (--prune removes stale
|
|
759
|
-
// tracking refs that would otherwise block fetch on ref hierarchy conflicts)
|
|
760
|
-
|
|
761
|
-
|
|
761
|
+
// tracking refs that would otherwise block fetch on ref hierarchy conflicts).
|
|
762
|
+
// Opt out via skip_bulk_fetch on very large monorepos where this is too slow;
|
|
763
|
+
// the targeted base-SHA and PR-head ref fetches below still run.
|
|
764
|
+
if (options.skipBulkFetch) {
|
|
765
|
+
console.log(`Skipping bulk fetch from ${remote} (skip_bulk_fetch enabled)`);
|
|
766
|
+
// Still fetch only the PR's base branch so ensureBaseShaAvailable does not
|
|
767
|
+
// have to fall back to `git fetch <remote> <sha>`, which some Git servers
|
|
768
|
+
// and mirrors reject (they require uploadpack.allowReachableSHA1InWant).
|
|
769
|
+
// This mirrors the targeted fetch used in createWorktreeForPR.
|
|
770
|
+
if (prData?.base_branch) {
|
|
771
|
+
try {
|
|
772
|
+
await worktreeGit.fetch([remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`]);
|
|
773
|
+
} catch (fetchError) {
|
|
774
|
+
console.warn(`Targeted base-branch fetch failed, will rely on existing refs: ${fetchError.message}`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
} else {
|
|
778
|
+
console.log(`Fetching latest changes from ${remote}...`);
|
|
779
|
+
await worktreeGit.fetch([remote, '--prune']);
|
|
780
|
+
}
|
|
762
781
|
|
|
763
782
|
await this.ensureBaseShaAvailable(worktreeGit, prData, remote);
|
|
764
783
|
|
package/src/local-review.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
const { execSync, exec } = require('child_process');
|
|
2
|
+
const { execSync, exec, execFileSync } = require('child_process');
|
|
3
3
|
const { promisify } = require('util');
|
|
4
4
|
const crypto = require('crypto');
|
|
5
5
|
const path = require('path');
|
|
@@ -15,7 +15,7 @@ const { initializeDatabase, ReviewRepository, RepoSettingsRepository } = require
|
|
|
15
15
|
const { startServer } = require('./server');
|
|
16
16
|
const { localReviewDiffs } = require('./routes/shared');
|
|
17
17
|
const { getShaAbbrevLength } = require('./git/sha-abbrev');
|
|
18
|
-
const { GIT_DIFF_FLAGS } = require('./git/diff-flags');
|
|
18
|
+
const { GIT_DIFF_FLAGS, GIT_DIFF_FLAGS_ARRAY } = require('./git/diff-flags');
|
|
19
19
|
const open = (...args) => process.env.PAIR_REVIEW_NO_OPEN ? Promise.resolve() : import('open').then(({ default: open }) => open(...args));
|
|
20
20
|
|
|
21
21
|
// Design note: This module uses execSync for git commands despite async function signatures.
|
|
@@ -393,12 +393,23 @@ async function findMergeBase(repoPath, baseBranch) {
|
|
|
393
393
|
* Generate diff output for untracked files using git diff --no-index.
|
|
394
394
|
* @param {string} repoPath - Path to the git repository
|
|
395
395
|
* @param {Array} untrackedFiles - Array from getUntrackedFiles()
|
|
396
|
-
* @param {
|
|
397
|
-
* @param {
|
|
398
|
-
* @param {
|
|
396
|
+
* @param {Object} [options]
|
|
397
|
+
* @param {boolean} [options.hideWhitespace=false] - Whether to pass -w
|
|
398
|
+
* @param {number} [options.contextLines=25] - Number of unified context lines
|
|
399
|
+
* @param {string[]} [options.extraArgs=[]] - Additional git diff flags
|
|
399
400
|
* @returns {string} Combined diff text for untracked files
|
|
400
401
|
*/
|
|
401
|
-
function generateUntrackedDiffs(repoPath, untrackedFiles,
|
|
402
|
+
function generateUntrackedDiffs(repoPath, untrackedFiles, options = {}) {
|
|
403
|
+
const diffArgs = [
|
|
404
|
+
'diff',
|
|
405
|
+
'--no-index',
|
|
406
|
+
...GIT_DIFF_FLAGS_ARRAY,
|
|
407
|
+
`--unified=${options.contextLines ?? 25}`,
|
|
408
|
+
...(options.extraArgs || []),
|
|
409
|
+
...(options.hideWhitespace ? ['-w'] : []),
|
|
410
|
+
'--',
|
|
411
|
+
'/dev/null'
|
|
412
|
+
];
|
|
402
413
|
let diff = '';
|
|
403
414
|
for (const untracked of untrackedFiles) {
|
|
404
415
|
if (!untracked.skipped) {
|
|
@@ -406,16 +417,21 @@ function generateUntrackedDiffs(repoPath, untrackedFiles, wFlag, contextFlag = '
|
|
|
406
417
|
const filePath = path.join(repoPath, untracked.file);
|
|
407
418
|
let fileDiff;
|
|
408
419
|
try {
|
|
409
|
-
fileDiff =
|
|
420
|
+
fileDiff = execFileSync('git', [...diffArgs, filePath], {
|
|
410
421
|
cwd: repoPath,
|
|
411
422
|
encoding: 'utf8',
|
|
412
423
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
413
424
|
maxBuffer: 10 * 1024 * 1024
|
|
414
425
|
});
|
|
415
426
|
} catch (diffError) {
|
|
427
|
+
const diffStdout = typeof diffError?.stdout === 'string'
|
|
428
|
+
? diffError.stdout
|
|
429
|
+
: Buffer.isBuffer(diffError?.stdout)
|
|
430
|
+
? diffError.stdout.toString('utf8')
|
|
431
|
+
: null;
|
|
416
432
|
if (diffError && typeof diffError === 'object' &&
|
|
417
|
-
diffError.status === GIT_DIFF_HAS_DIFFERENCES &&
|
|
418
|
-
fileDiff =
|
|
433
|
+
diffError.status === GIT_DIFF_HAS_DIFFERENCES && diffStdout !== null) {
|
|
434
|
+
fileDiff = diffStdout;
|
|
419
435
|
} else {
|
|
420
436
|
throw diffError;
|
|
421
437
|
}
|
|
@@ -552,7 +568,7 @@ async function generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch, op
|
|
|
552
568
|
const untrackedFiles = await getUntrackedFiles(repoPath);
|
|
553
569
|
stats.untrackedFiles = untrackedFiles.length;
|
|
554
570
|
|
|
555
|
-
const untrackedDiff = generateUntrackedDiffs(repoPath, untrackedFiles,
|
|
571
|
+
const untrackedDiff = generateUntrackedDiffs(repoPath, untrackedFiles, options);
|
|
556
572
|
if (untrackedDiff) {
|
|
557
573
|
if (diff) diff += '\n';
|
|
558
574
|
diff += untrackedDiff;
|
package/src/routes/pr.js
CHANGED
|
@@ -25,7 +25,7 @@ const Analyzer = require('../ai/analyzer');
|
|
|
25
25
|
const { v4: uuidv4 } = require('uuid');
|
|
26
26
|
const fs = require('fs').promises;
|
|
27
27
|
const path = require('path');
|
|
28
|
-
const { getGitHubToken, getWorktreeDisplayName, resolveLoadSkills, buildCouncilProviderOverrides } = require('../config');
|
|
28
|
+
const { getGitHubToken, getWorktreeDisplayName, resolveLoadSkills, buildCouncilProviderOverrides, getRepoSkipBulkFetch } = require('../config');
|
|
29
29
|
const logger = require('../utils/logger');
|
|
30
30
|
const { buildDiffLineSet } = require('../utils/diff-annotator');
|
|
31
31
|
const { broadcastReviewEvent } = require('../events/review-events');
|
|
@@ -45,6 +45,7 @@ const {
|
|
|
45
45
|
registerProcess: registerProcessForCancellation
|
|
46
46
|
} = require('./shared');
|
|
47
47
|
const { safeParseJson } = require('../utils/safe-parse-json');
|
|
48
|
+
const { mergeChangedFilesWithDiff } = require('../utils/diff-file-list');
|
|
48
49
|
const { resolveOriginalFileContentSpecs } = require('../utils/diff-file-content');
|
|
49
50
|
const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
|
|
50
51
|
const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
|
|
@@ -254,6 +255,8 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
254
255
|
}
|
|
255
256
|
|
|
256
257
|
// Prepare response
|
|
258
|
+
const changedFiles = mergeChangedFilesWithDiff(extendedData.changed_files || [], extendedData.diff || '');
|
|
259
|
+
|
|
257
260
|
// Use review.id instead of prMetadata.id to avoid ID collision with local mode
|
|
258
261
|
const response = {
|
|
259
262
|
success: true,
|
|
@@ -274,8 +277,8 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
274
277
|
shaAbbrevLength,
|
|
275
278
|
created_at: prMetadata.created_at,
|
|
276
279
|
updated_at: prMetadata.updated_at,
|
|
277
|
-
file_changes:
|
|
278
|
-
changed_files:
|
|
280
|
+
file_changes: changedFiles.length,
|
|
281
|
+
changed_files: changedFiles,
|
|
279
282
|
additions: extendedData.additions || 0,
|
|
280
283
|
deletions: extendedData.deletions || 0,
|
|
281
284
|
diff_content: extendedData.diff || '',
|
|
@@ -375,7 +378,13 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
|
|
|
375
378
|
|
|
376
379
|
// Update worktree with latest changes
|
|
377
380
|
const worktreeManager = new GitWorktreeManager(db);
|
|
378
|
-
const worktreePath = await worktreeManager.updateWorktree(
|
|
381
|
+
const worktreePath = await worktreeManager.updateWorktree(
|
|
382
|
+
owner,
|
|
383
|
+
repo,
|
|
384
|
+
prNumber,
|
|
385
|
+
prData,
|
|
386
|
+
{ skipBulkFetch: getRepoSkipBulkFetch(config, repository) }
|
|
387
|
+
);
|
|
379
388
|
|
|
380
389
|
// Generate fresh diff and get changed files
|
|
381
390
|
const diffPrData = {
|
|
@@ -759,6 +768,8 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
|
|
|
759
768
|
let diffContent = prData.diff || '';
|
|
760
769
|
let changedFiles = prData.changed_files || [];
|
|
761
770
|
|
|
771
|
+
let gitattributes = null;
|
|
772
|
+
|
|
762
773
|
if (hideWhitespace && worktreeRecord && worktreeRecord.path) {
|
|
763
774
|
try {
|
|
764
775
|
const worktreePath = worktreeRecord.path;
|
|
@@ -774,7 +785,7 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
|
|
|
774
785
|
|
|
775
786
|
const summaryArgs = [`${baseSha}...${headSha}`, ...GIT_DIFF_SUMMARY_FLAGS_ARRAY, '-w'];
|
|
776
787
|
const diffSummary = await git.diffSummary(summaryArgs);
|
|
777
|
-
|
|
788
|
+
gitattributes = await getGeneratedFilePatterns(worktreePath);
|
|
778
789
|
changedFiles = diffSummary.files.map(file => {
|
|
779
790
|
const resolvedFile = resolveRenamedFile(file.file);
|
|
780
791
|
const isRenamed = resolvedFile !== file.file;
|
|
@@ -799,17 +810,22 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
|
|
|
799
810
|
} else if (worktreeRecord && worktreeRecord.path) {
|
|
800
811
|
// Add generated flag to changed files based on .gitattributes
|
|
801
812
|
try {
|
|
802
|
-
|
|
803
|
-
changedFiles = changedFiles.map(file => ({
|
|
804
|
-
...file,
|
|
805
|
-
generated: gitattributes.isGenerated(file.file)
|
|
806
|
-
}));
|
|
813
|
+
gitattributes = await getGeneratedFilePatterns(worktreeRecord.path);
|
|
807
814
|
} catch (error) {
|
|
808
815
|
logger.warn(`Could not load .gitattributes: ${error.message}`);
|
|
809
816
|
// Continue without generated flags
|
|
810
817
|
}
|
|
811
818
|
}
|
|
812
819
|
|
|
820
|
+
changedFiles = mergeChangedFilesWithDiff(changedFiles, diffContent);
|
|
821
|
+
|
|
822
|
+
if (gitattributes) {
|
|
823
|
+
changedFiles = changedFiles.map(file => ({
|
|
824
|
+
...file,
|
|
825
|
+
generated: gitattributes.isGenerated(file.file)
|
|
826
|
+
}));
|
|
827
|
+
}
|
|
828
|
+
|
|
813
829
|
// When diff was regenerated (whitespace), compute aggregate stats from
|
|
814
830
|
// the regenerated changedFiles instead of using stale cached values from prData.
|
|
815
831
|
const additions = hideWhitespace
|
|
@@ -3,9 +3,111 @@ const { promisify } = require('util');
|
|
|
3
3
|
const { exec } = require('child_process');
|
|
4
4
|
const { queryOne } = require('../database');
|
|
5
5
|
const { GIT_DIFF_FLAGS } = require('../git/diff-flags');
|
|
6
|
+
const { normalizePath, resolveRenamedFile } = require('./paths');
|
|
6
7
|
|
|
7
8
|
const execPromise = promisify(exec);
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Parse a unified diff into a map of file path -> per-file patch.
|
|
12
|
+
* Uses the "b/" path from the diff header as the canonical file path.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} diff - Full unified diff
|
|
15
|
+
* @returns {Map<string, string>} Map of file paths to full patch text
|
|
16
|
+
*/
|
|
17
|
+
function parseUnifiedDiffPatches(diff) {
|
|
18
|
+
const filePatchMap = new Map();
|
|
19
|
+
if (!diff) return filePatchMap;
|
|
20
|
+
|
|
21
|
+
const parts = diff.split(/(?=^diff --git )/m);
|
|
22
|
+
|
|
23
|
+
for (const part of parts) {
|
|
24
|
+
if (!part.trim()) continue;
|
|
25
|
+
|
|
26
|
+
const match = part.match(/^diff --git a\/(.+?) b\/(.+)$/m);
|
|
27
|
+
if (match) {
|
|
28
|
+
filePatchMap.set(match[2], part);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return filePatchMap;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Count additions and deletions inside a single patch body.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} patch - Per-file patch text
|
|
39
|
+
* @returns {{ insertions: number, deletions: number }}
|
|
40
|
+
*/
|
|
41
|
+
function countPatchStats(patch) {
|
|
42
|
+
let insertions = 0;
|
|
43
|
+
let deletions = 0;
|
|
44
|
+
|
|
45
|
+
for (const line of patch.split('\n')) {
|
|
46
|
+
if (line.startsWith('+') && !line.startsWith('+++ ')) {
|
|
47
|
+
insertions++;
|
|
48
|
+
} else if (line.startsWith('-') && !line.startsWith('--- ')) {
|
|
49
|
+
deletions++;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { insertions, deletions };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Merge changed_files metadata with the authoritative file list from the diff.
|
|
58
|
+
* This recovers files when cached changed_files were derived from abbreviated
|
|
59
|
+
* diff --stat output and no longer match the full patch headers.
|
|
60
|
+
*
|
|
61
|
+
* @param {Array<object|string>} changedFiles - Existing changed_files array
|
|
62
|
+
* @param {string} diff - Full unified diff
|
|
63
|
+
* @returns {Array<object|string>} Merged changed_files array
|
|
64
|
+
*/
|
|
65
|
+
function mergeChangedFilesWithDiff(changedFiles, diff) {
|
|
66
|
+
const patchMap = parseUnifiedDiffPatches(diff);
|
|
67
|
+
if (patchMap.size === 0) {
|
|
68
|
+
return Array.isArray(changedFiles) ? changedFiles : [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Drop cached `git diff --stat` ellipsis stubs once we have authoritative
|
|
72
|
+
// patch headers to recover the full file paths from.
|
|
73
|
+
const existing = (Array.isArray(changedFiles) ? changedFiles : []).filter(entry => {
|
|
74
|
+
const filePath = typeof entry === 'string' ? entry : entry?.file;
|
|
75
|
+
return filePath && !filePath.includes('...');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const normalizedExisting = new Set(existing.map(file => {
|
|
79
|
+
const filePath = typeof file === 'string' ? file : file?.file;
|
|
80
|
+
return normalizePath(resolveRenamedFile(filePath));
|
|
81
|
+
}).filter(Boolean));
|
|
82
|
+
|
|
83
|
+
const merged = [...existing];
|
|
84
|
+
|
|
85
|
+
for (const [filePath, patch] of patchMap.entries()) {
|
|
86
|
+
const normalizedPatchPath = normalizePath(resolveRenamedFile(filePath));
|
|
87
|
+
if (normalizedExisting.has(normalizedPatchPath)) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { insertions, deletions } = countPatchStats(patch);
|
|
92
|
+
const renameFrom = patch.match(/^rename from (.+)$/m)?.[1] || null;
|
|
93
|
+
const renameTo = patch.match(/^rename to (.+)$/m)?.[1] || null;
|
|
94
|
+
const binary = /^Binary files .* differ$/m.test(patch) || /^GIT binary patch$/m.test(patch);
|
|
95
|
+
|
|
96
|
+
merged.push({
|
|
97
|
+
file: filePath,
|
|
98
|
+
insertions,
|
|
99
|
+
deletions,
|
|
100
|
+
changes: insertions + deletions,
|
|
101
|
+
binary,
|
|
102
|
+
renamed: Boolean(renameFrom && renameTo),
|
|
103
|
+
renamedFrom: renameFrom
|
|
104
|
+
});
|
|
105
|
+
normalizedExisting.add(normalizedPatchPath);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return merged;
|
|
109
|
+
}
|
|
110
|
+
|
|
9
111
|
/**
|
|
10
112
|
* Return the list of file paths that belong to the review's diff.
|
|
11
113
|
* Works for both PR-mode and local-mode reviews.
|
|
@@ -25,7 +127,9 @@ async function getDiffFileList(db, review) {
|
|
|
25
127
|
|
|
26
128
|
if (prRecord?.pr_data) {
|
|
27
129
|
const prData = JSON.parse(prRecord.pr_data);
|
|
28
|
-
return (prData.changed_files || []
|
|
130
|
+
return mergeChangedFilesWithDiff(prData.changed_files || [], prData.diff || '')
|
|
131
|
+
.map(f => typeof f === 'string' ? f : f.file)
|
|
132
|
+
.filter(Boolean);
|
|
29
133
|
}
|
|
30
134
|
} catch {
|
|
31
135
|
// parse / query error – fall through to empty list
|
|
@@ -55,4 +159,9 @@ async function getDiffFileList(db, review) {
|
|
|
55
159
|
return [];
|
|
56
160
|
}
|
|
57
161
|
|
|
58
|
-
module.exports = {
|
|
162
|
+
module.exports = {
|
|
163
|
+
getDiffFileList,
|
|
164
|
+
parseUnifiedDiffPatches,
|
|
165
|
+
countPatchStats,
|
|
166
|
+
mergeChangedFilesWithDiff
|
|
167
|
+
};
|
package/src/utils/paths.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* - Handles interleaved patterns like '/./src' by iterating
|
|
14
14
|
* - Normalizes multiple consecutive slashes to single slash
|
|
15
15
|
* - Does NOT modify case (paths are case-sensitive on most systems)
|
|
16
|
+
* - Frontend mirror lives in `DiffRenderer.normalizeFilePath`
|
|
17
|
+
* (`public/js/modules/diff-renderer.js`); keep behavior in sync
|
|
16
18
|
*
|
|
17
19
|
* @param {string} filePath - The file path to normalize
|
|
18
20
|
* @returns {string} Normalized path
|
|
@@ -159,6 +161,8 @@ function normalizeRepository(owner, repo) {
|
|
|
159
161
|
* "tests/{old.js => new.js}" → "tests/new.js"
|
|
160
162
|
* "{old-dir => new-dir}/file.js" → "new-dir/file.js"
|
|
161
163
|
* "a/{b => c}/d.js" → "a/c/d.js"
|
|
164
|
+
* Frontend mirror lives in `DiffRenderer.normalizeFilePath`
|
|
165
|
+
* (`public/js/modules/diff-renderer.js`); keep behavior in sync.
|
|
162
166
|
*
|
|
163
167
|
* @param {string} fileName - File name possibly containing rename syntax
|
|
164
168
|
* @returns {string} Resolved file name with the new path, or original if no rename syntax
|