@in-the-loop-labs/pair-review 3.3.6 → 3.4.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/src/config.js CHANGED
@@ -19,6 +19,7 @@ const DEFAULT_CONFIG = {
19
19
  github_token: "",
20
20
  github_token_command: "gh auth token", // Shell command whose stdout is used as the GitHub token
21
21
  port: 7247,
22
+ single_port: true, // When true, reuse a single server on the configured port; new invocations delegate to the running server
22
23
  theme: "light",
23
24
  default_provider: "claude", // AI provider: 'claude', 'gemini', 'codex', 'copilot', 'opencode', 'cursor-agent', 'pi'
24
25
  default_model: "opus", // Model within the provider (e.g., 'opus' for Claude, 'gemini-2.5-pro' for Gemini)
@@ -128,7 +129,7 @@ async function copyExampleConfig() {
128
129
  try {
129
130
  await fs.access(sourceExample);
130
131
  await fs.copyFile(sourceExample, CONFIG_EXAMPLE_FILE);
131
- console.log(`Copied config.example.json to: ${CONFIG_EXAMPLE_FILE}`);
132
+ logger.info(`Copied config.example.json to: ${CONFIG_EXAMPLE_FILE}`);
132
133
  return true;
133
134
  } catch (error) {
134
135
  if (error.code === 'ENOENT') {
@@ -154,13 +155,13 @@ async function ensureConfigDir() {
154
155
  if (error.code === 'ENOENT') {
155
156
  try {
156
157
  await fs.mkdir(CONFIG_DIR, { recursive: true });
157
- console.log(`Created config directory: ${CONFIG_DIR}`);
158
+ logger.info(`Created config directory: ${CONFIG_DIR}`);
158
159
  // Copy example config to new directory
159
160
  await copyExampleConfig();
160
161
  return true; // Directory was newly created
161
162
  } catch (mkdirError) {
162
163
  if (mkdirError.code === 'EACCES' || mkdirError.code === 'EPERM') {
163
- console.error(`Cannot create configuration directory at ~/.pair-review/`);
164
+ logger.error(`Cannot create configuration directory at ~/.pair-review/`);
164
165
  process.exit(1);
165
166
  }
166
167
  throw mkdirError;
@@ -211,7 +212,7 @@ async function loadConfig() {
211
212
  // Optional files or managed-config-present: skip silently
212
213
  } else if (error instanceof SyntaxError) {
213
214
  if (source.required) {
214
- console.error(`Invalid configuration file at ~/.pair-review/config.json`);
215
+ logger.error(`Invalid configuration file at ~/.pair-review/config.json`);
215
216
  process.exit(1);
216
217
  }
217
218
  logger.warn(`Malformed config at ${source.label}, skipping`);
@@ -235,9 +236,19 @@ async function loadConfig() {
235
236
  mergedConfig.repos = normalized;
236
237
  }
237
238
 
239
+ // PORT env var overrides all config layers (used by Preview and similar harnesses)
240
+ if (process.env.PORT) {
241
+ const envPort = Number(process.env.PORT);
242
+ if (!validatePort(envPort)) {
243
+ logger.error(`Invalid PORT env var "${process.env.PORT}" (must be an integer between 1024 and 65535)`);
244
+ process.exit(1);
245
+ }
246
+ mergedConfig.port = envPort;
247
+ }
248
+
238
249
  // Validate port
239
250
  if (!validatePort(mergedConfig.port)) {
240
- console.error(`Invalid port number ${mergedConfig.port}`);
251
+ logger.error(`Invalid port number ${mergedConfig.port}`);
241
252
  process.exit(1);
242
253
  }
243
254
 
@@ -269,7 +280,7 @@ async function saveConfig(config) {
269
280
  await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
270
281
  } catch (error) {
271
282
  if (error.code === 'EACCES' || error.code === 'EPERM') {
272
- console.error(`Cannot create configuration directory at ~/.pair-review/`);
283
+ logger.error(`Cannot create configuration directory at ~/.pair-review/`);
273
284
  process.exit(1);
274
285
  }
275
286
  throw error;
@@ -517,6 +528,17 @@ function getRepoResetScript(config, repository) {
517
528
  return repoConfig?.reset_script || null;
518
529
  }
519
530
 
531
+ /**
532
+ * Gets whether updateWorktree should skip the bulk `git fetch <remote> --prune`
533
+ * @param {Object} config - Configuration object from loadConfig()
534
+ * @param {string} repository - Repository in "owner/repo" format
535
+ * @returns {boolean} - true if the bulk fetch should be skipped (default: false)
536
+ */
537
+ function getRepoSkipBulkFetch(config, repository) {
538
+ const repoConfig = getRepoConfig(config, repository);
539
+ return repoConfig?.skip_bulk_fetch === true;
540
+ }
541
+
520
542
  /**
521
543
  * Gets the configured pool size for a repository from file config only.
522
544
  * Prefer resolvePoolConfig() when DB repo_settings are available.
@@ -750,6 +772,7 @@ module.exports = {
750
772
  getRepoCheckoutTimeout,
751
773
  resolveRepoOptions,
752
774
  getRepoResetScript,
775
+ getRepoSkipBulkFetch,
753
776
  getRepoPoolSize,
754
777
  getRepoPoolFetchInterval,
755
778
  getRepoLoadSkills,
@@ -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 = {
@@ -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
- console.log(`Fetching latest changes from ${remote}...`);
761
- await worktreeGit.fetch([remote, '--prune']);
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
 
@@ -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
@@ -20,6 +20,7 @@ const { GIT_DIFF_FLAGS_ARRAY, GIT_DIFF_SUMMARY_FLAGS_ARRAY } = require('./git/di
20
20
  const { getEmoji: getCategoryEmoji } = require('./utils/category-emoji');
21
21
  const open = (...args) => process.env.PAIR_REVIEW_NO_OPEN ? Promise.resolve() : import('open').then(({default: open}) => open(...args));
22
22
  const { registerProtocolHandler, unregisterProtocolHandler } = require('./protocol-handler');
23
+ const { attemptDelegation } = require('./single-port');
23
24
 
24
25
  let db = null;
25
26
 
@@ -432,26 +433,8 @@ AI PROVIDERS:
432
433
  showWelcomeMessage();
433
434
  }
434
435
 
435
- // Initialize database
436
- console.log('Initializing database...');
437
- db = await initializeDatabase(resolveDbName(config));
438
-
439
- // Migrate existing worktrees to database (if any)
440
- const path = require('path');
441
- const worktreeBaseDir = path.join(getConfigDir(), 'worktrees');
442
- const migrationResult = await migrateExistingWorktrees(db, worktreeBaseDir);
443
- if (migrationResult.migrated > 0) {
444
- console.log(`Migrated ${migrationResult.migrated} existing worktrees to database`);
445
- }
446
- if (migrationResult.errors.length > 0) {
447
- console.warn('Some worktrees could not be migrated:', migrationResult.errors);
448
- }
449
-
450
- // Reset stale pool entries, wire idle callbacks, and rehydrate preserved entries
451
- const poolLifecycle = new WorktreePoolLifecycle(db, config);
452
- await poolLifecycle.resetAndRehydrate();
453
-
454
- // Parse command line arguments including flags
436
+ // Parse command line arguments including flags (before DB init so
437
+ // single-port delegation can skip DB entirely)
455
438
  const { prArgs, flags } = parseArgs(args);
456
439
 
457
440
  // Apply debug_stream from config if not already enabled by CLI flag
@@ -474,6 +457,36 @@ AI PROVIDERS:
474
457
  // server, so we must also apply here.
475
458
  applyConfigOverrides(config);
476
459
 
460
+ // Single-port delegation: if a pair-review server is already running on the
461
+ // configured port, delegate to it (open browser URL) and exit immediately.
462
+ // Skipped for: headless modes (no browser), single_port: false (dev mode).
463
+ if (config.single_port !== false && !flags.aiReview && !flags.aiDraft) {
464
+ const delegated = await attemptDelegation(config, flags, prArgs);
465
+ if (delegated) {
466
+ process.exit(0);
467
+ }
468
+ // Not delegated — no server running, proceed to start one
469
+ }
470
+
471
+ // Initialize database
472
+ console.log('Initializing database...');
473
+ db = await initializeDatabase(resolveDbName(config));
474
+
475
+ // Migrate existing worktrees to database (if any)
476
+ const path = require('path');
477
+ const worktreeBaseDir = path.join(getConfigDir(), 'worktrees');
478
+ const migrationResult = await migrateExistingWorktrees(db, worktreeBaseDir);
479
+ if (migrationResult.migrated > 0) {
480
+ console.log(`Migrated ${migrationResult.migrated} existing worktrees to database`);
481
+ }
482
+ if (migrationResult.errors.length > 0) {
483
+ console.warn('Some worktrees could not be migrated:', migrationResult.errors);
484
+ }
485
+
486
+ // Reset stale pool entries, wire idle callbacks, and rehydrate preserved entries
487
+ const poolLifecycle = new WorktreePoolLifecycle(db, config);
488
+ await poolLifecycle.resetAndRehydrate();
489
+
477
490
  // Check for local mode (review uncommitted local changes)
478
491
  if (flags.local) {
479
492
  // Resolve localPath, defaulting to cwd if not provided
package/src/mcp-stdio.js CHANGED
@@ -45,6 +45,13 @@ async function startMCPStdio() {
45
45
  console.error(`[MCP] Warning: failed to load config, using defaults: ${err.message}`);
46
46
  }
47
47
 
48
+ // MCP mode needs its own Express server for stdio↔HTTP bridging and cannot
49
+ // delegate to a running pair-review instance (the stdio transport owns this
50
+ // process). Force auto-port selection to avoid EADDRINUSE when a regular
51
+ // pair-review server is already running on config.port.
52
+ // startServer (src/server.js) reads this env var and flips config.single_port.
53
+ process.env.PAIR_REVIEW_SINGLE_PORT = 'false';
54
+
48
55
  const db = await initializeDatabase(resolveDbName(config));
49
56
  const port = await startServer(db);
50
57
 
@@ -22,11 +22,19 @@ const {
22
22
  const { normalizeRepository } = require('../utils/paths');
23
23
  const { isRunningViaNpx, getGitHubToken } = require('../config');
24
24
  const { version } = require('../../package.json');
25
+ const semver = require('semver');
25
26
  const { getAllChatProviders, getAllCachedChatAvailability } = require('../chat/chat-providers');
26
27
  const logger = require('../utils/logger');
27
28
 
28
29
  const router = express.Router();
29
30
 
31
+ // Module-level state: the most recent version we've been told about that's
32
+ // newer than the running server. Plain string, not an object. `null` means
33
+ // nothing is pending. Reset on process restart — which is fine because a
34
+ // restart either IS the update (running version is now newer) or loses no
35
+ // information (the next notifier will re-populate it).
36
+ let pendingUpdateVersion = null;
37
+
30
38
  /**
31
39
  * Get user configuration (for frontend use)
32
40
  * Returns safe-to-expose configuration values
@@ -71,10 +79,46 @@ router.get('/api/config', (req, res) => {
71
79
  icon: config.share.icon || null,
72
80
  label: config.share.label || null,
73
81
  description: config.share.description || null
74
- } : null
82
+ } : null,
83
+ pending_update: pendingUpdateVersion
75
84
  });
76
85
  });
77
86
 
87
+ /**
88
+ * Notify the running server that a newer version is available.
89
+ * Called by a newer CLI invocation delegating to this server.
90
+ * Stores state so browser tabs can pick it up via GET /api/config.
91
+ *
92
+ * Suppression is version-based, not time-based: a POST is accepted only
93
+ * when the incoming version is strictly newer than both the running version
94
+ * and any currently-pending version. This means `pendingUpdateVersion`
95
+ * monotonically increases for the life of the process.
96
+ */
97
+ router.post('/api/notify-update', (req, res) => {
98
+ const incomingVersion = req.body?.version;
99
+ if (!incomingVersion || !semver.valid(incomingVersion)) {
100
+ return res.status(400).json({ error: 'Invalid version' });
101
+ }
102
+
103
+ if (!semver.gt(incomingVersion, version)) {
104
+ return res.json({ ok: true, notified: false, reason: 'not_newer' });
105
+ }
106
+
107
+ // Suppress unless the incoming version is STRICTLY newer than what's
108
+ // already pending. Handles three cases at once:
109
+ // - incoming == pending → suppressed (nothing new)
110
+ // - incoming > pending → accepted (genuinely newer, falls through)
111
+ // - incoming < pending → suppressed (downgrade — user already knows)
112
+ if (pendingUpdateVersion && !semver.gt(incomingVersion, pendingUpdateVersion)) {
113
+ return res.json({ ok: true, notified: false, reason: 'not_newer_than_pending' });
114
+ }
115
+
116
+ pendingUpdateVersion = incomingVersion;
117
+ logger.info(`New version available: ${incomingVersion} (running ${version})`);
118
+
119
+ res.json({ ok: true, notified: true });
120
+ });
121
+
78
122
  /**
79
123
  * Get repository-specific settings
80
124
  * Returns default_instructions, default_provider, and default_model for the repository
@@ -328,4 +372,14 @@ router.post('/api/providers/refresh-availability', async (req, res) => {
328
372
  }
329
373
  });
330
374
 
375
+ /**
376
+ * Test-only helper: reset the in-memory pending-update state.
377
+ * Not exported from index — intended for use by integration tests that
378
+ * share the same module instance and need isolation between cases.
379
+ */
380
+ function _resetPendingUpdate() {
381
+ pendingUpdateVersion = null;
382
+ }
383
+
331
384
  module.exports = router;
385
+ module.exports._resetPendingUpdate = _resetPendingUpdate;
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
package/src/server.js CHANGED
@@ -14,6 +14,19 @@ let db = null;
14
14
  let server = null;
15
15
  let chatSessionManager = null;
16
16
 
17
+ /**
18
+ * Apply env var overrides to config after loadConfig().
19
+ * Currently handles PAIR_REVIEW_SINGLE_PORT — a bridge for callers that
20
+ * need to force multi-port mode (e.g. mcp-stdio.js). Matches the
21
+ * PAIR_REVIEW_YOLO bridge pattern.
22
+ */
23
+ function applyEnvOverrides(config) {
24
+ if (process.env.PAIR_REVIEW_SINGLE_PORT === 'false') {
25
+ config.single_port = false;
26
+ }
27
+ return config;
28
+ }
29
+
17
30
  /**
18
31
  * Request logging middleware (disabled for cleaner output)
19
32
  */
@@ -93,6 +106,7 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
93
106
  try {
94
107
  // Load configuration
95
108
  const { config } = await loadConfig();
109
+ applyEnvOverrides(config);
96
110
 
97
111
  // Apply provider configuration overrides (custom models, commands, etc.)
98
112
  applyConfigOverrides(config);
@@ -294,9 +308,14 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
294
308
  res.sendFile(path.join(__dirname, '..', 'public', 'local.html'));
295
309
  });
296
310
 
297
- // Health check endpoint
311
+ // Health check endpoint (also used by single-port detection)
298
312
  app.get('/health', (req, res) => {
299
- res.json({ status: 'ok', timestamp: new Date().toISOString() });
313
+ res.json({
314
+ status: 'ok',
315
+ service: 'pair-review',
316
+ version: require('../package.json').version,
317
+ timestamp: new Date().toISOString()
318
+ });
300
319
  });
301
320
 
302
321
  // Store database instance, GitHub token, and config for routes
@@ -365,7 +384,9 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
365
384
  });
366
385
 
367
386
  // Find available port and start server
368
- const port = await findAvailablePort(app, config.port);
387
+ const port = config.single_port !== false
388
+ ? config.port // single-port mode: use exact port, fail if unavailable
389
+ : await findAvailablePort(app, config.port);
369
390
 
370
391
  // Check provider availability before accepting requests so /api/config
371
392
  // returns accurate pi_available on the very first request (avoids race
@@ -381,14 +402,35 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
381
402
  console.warn('Provider availability check failed:', err.message);
382
403
  }
383
404
 
384
- server = app.listen(port, () => {
385
- console.log(`Server running on http://localhost:${port}`);
386
- attachWebSocket(server, db, poolLifecycle);
405
+ await new Promise((resolve, reject) => {
406
+ server = app.listen(port, () => {
407
+ console.log(`Server running on http://localhost:${port}`);
408
+ attachWebSocket(server, db, poolLifecycle);
409
+ resolve();
410
+ });
411
+
412
+ // .once instead of .on: this handler detaches after firing exactly once,
413
+ // so the post-startup handler below doesn't double-handle EADDRINUSE/EACCES
414
+ // during the initial bind.
415
+ server.once('error', (error) => {
416
+ if (error.code === 'EADDRINUSE' && config.single_port !== false) {
417
+ reject(new Error(
418
+ `Port ${port} is already in use. A pair-review server may already be running, ` +
419
+ `or another service is using this port. ` +
420
+ `Set "single_port": false in ~/.pair-review/config.json to use automatic port selection.`
421
+ ));
422
+ } else {
423
+ reject(error);
424
+ }
425
+ });
387
426
  });
388
427
 
428
+ // Post-startup error handler. Express middleware handles request-level errors,
429
+ // so this only fires for lower-level issues like accept-loop failures (EMFILE,
430
+ // ENFILE from file descriptor exhaustion). Log but do NOT process.exit — the
431
+ // old code did that and it was too aggressive for transient errors.
389
432
  server.on('error', (error) => {
390
- console.error('Server error:', error);
391
- process.exit(1);
433
+ console.error('Server error after startup:', error);
392
434
  });
393
435
 
394
436
  // Return the actual port the server started on
@@ -468,4 +510,4 @@ if (require.main === module) {
468
510
  startServer();
469
511
  }
470
512
 
471
- module.exports = { startServer };
513
+ module.exports = { startServer, applyEnvOverrides };