@in-the-loop-labs/pair-review 3.3.5 → 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.
@@ -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 {string} wFlag - Whitespace flag (e.g. ' -w' or '')
397
- * @param {string} [contextFlag=''] - Unified context flag (e.g. ' --unified=3')
398
- * @param {string} [extraArgsStr=''] - Additional git diff flags (e.g. ' --patience')
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, wFlag, contextFlag = '', extraArgsStr = '') {
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 = execSync(`git diff --no-index ${GIT_DIFF_FLAGS}${contextFlag}${extraArgsStr}${wFlag} -- /dev/null "${filePath}"`, {
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 && typeof diffError.stdout === 'string') {
418
- fileDiff = diffError.stdout;
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, wFlag, contextFlag, extraArgsStr);
571
+ const untrackedDiff = generateUntrackedDiffs(repoPath, untrackedFiles, options);
556
572
  if (untrackedDiff) {
557
573
  if (diff) diff += '\n';
558
574
  diff += untrackedDiff;
package/src/main.js CHANGED
@@ -116,7 +116,7 @@ OPTIONS:
116
116
  --model <name> Override the AI model. Claude Code is the default provider.
117
117
  Available models: opus, sonnet, haiku (Claude Code);
118
118
  also: opus-4.5, opus-4.6-low, opus-4.6-medium, opus-4.6-1m,
119
- opus-4.7-xhigh
119
+ opus-4.7-high, opus-4.7-xhigh
120
120
  or use provider-specific models with Gemini/Codex
121
121
  --use-checkout Use current directory instead of creating worktree
122
122
  (automatic in GitHub Actions)
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: extendedData.changed_files ? extendedData.changed_files.length : 0,
278
- changed_files: extendedData.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(owner, repo, prNumber, prData);
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
- const gitattributes = await getGeneratedFilePatterns(worktreePath);
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
- const gitattributes = await getGeneratedFilePatterns(worktreeRecord.path);
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 || []).map(f => f.file);
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 = { getDiffFileList };
162
+ module.exports = {
163
+ getDiffFileList,
164
+ parseUnifiedDiffPatches,
165
+ countPatchStats,
166
+ mergeChangedFilesWithDiff
167
+ };
@@ -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