@in-the-loop-labs/pair-review 3.0.2 → 3.0.3

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.
@@ -8,10 +8,13 @@ const pkg = require('../package.json');
8
8
  const args = process.argv.slice(2);
9
9
  const isMCP = args.includes('--mcp');
10
10
 
11
- // Check for updates and notify user (skip in MCP mode to avoid stdout pollution)
11
+ // Check for updates and notify user (skip in MCP mode and when config suppresses it)
12
12
  if (!isMCP) {
13
- const updateNotifier = require('update-notifier');
14
- updateNotifier({ pkg }).notify();
13
+ const { shouldSkipUpdateNotifier } = require('../src/config');
14
+ if (!shouldSkipUpdateNotifier()) {
15
+ const updateNotifier = require('update-notifier');
16
+ updateNotifier({ pkg }).notify();
17
+ }
15
18
  }
16
19
 
17
20
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "3.0.2",
3
+ "version": "3.0.3",
4
4
  "description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "3.0.2",
3
+ "version": "3.0.3",
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.0.2",
3
+ "version": "3.0.3",
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",
package/public/css/pr.css CHANGED
@@ -1616,9 +1616,13 @@
1616
1616
  }
1617
1617
 
1618
1618
  /* Scrollable wrapper for diff tables — provides per-file horizontal scroll
1619
- now that .d2h-file-wrapper and .diff-container use overflow:visible for sticky headers */
1619
+ now that .d2h-file-wrapper and .diff-container use overflow:visible for sticky headers.
1620
+ overflow-y must be explicitly hidden: CSS spec coerces it to 'auto' when overflow-x
1621
+ is non-visible, which creates an accidental vertical scroll trap that eats wheel events
1622
+ at the boundary instead of propagating them to .diff-view. */
1620
1623
  .d2h-file-body {
1621
1624
  overflow-x: auto;
1625
+ overflow-y: hidden;
1622
1626
  }
1623
1627
 
1624
1628
  .d2h-diff-table {
@@ -5964,6 +5968,7 @@ body::before {
5964
5968
  .header-center {
5965
5969
  flex: 1;
5966
5970
  min-width: 0;
5971
+ overflow: hidden;
5967
5972
  text-align: center;
5968
5973
  }
5969
5974
 
package/public/local.html CHANGED
@@ -112,10 +112,24 @@
112
112
  font-family: 'JetBrains Mono', monospace;
113
113
  font-size: 11px;
114
114
  color: var(--color-text-primary);
115
+ white-space: nowrap;
116
+ overflow: hidden;
117
+ text-overflow: ellipsis;
118
+ max-width: 260px;
115
119
  }
116
120
 
117
121
  .local-branch-badge svg {
118
122
  color: var(--color-text-tertiary);
123
+ flex-shrink: 0;
124
+ }
125
+
126
+ .local-branch-badge span {
127
+ overflow: hidden;
128
+ text-overflow: ellipsis;
129
+ }
130
+
131
+ .local-branch-vs {
132
+ flex-shrink: 0;
119
133
  }
120
134
 
121
135
  /* Dark mode improvements for header icon buttons */
@@ -139,18 +153,25 @@
139
153
  gap: 16px;
140
154
  font-size: 13px;
141
155
  color: var(--color-text-secondary);
156
+ min-width: 0;
157
+ overflow: hidden;
142
158
  }
143
159
 
144
160
  .local-header-info .info-item {
145
161
  display: flex;
146
162
  align-items: center;
147
163
  gap: 6px;
164
+ min-width: 0;
165
+ overflow: hidden;
148
166
  }
149
167
 
150
168
  .local-header-info .info-value {
151
169
  color: var(--color-text-primary);
152
170
  font-weight: 600;
153
171
  font-size: 14px;
172
+ white-space: nowrap;
173
+ overflow: hidden;
174
+ text-overflow: ellipsis;
154
175
  }
155
176
 
156
177
  /* Path display in toolbar-meta (with left truncation) */
@@ -193,11 +214,12 @@
193
214
  border-radius: 4px;
194
215
  border: 1px dashed var(--color-border-primary);
195
216
  transition: all 0.15s ease;
196
- max-width: 360px;
217
+ max-width: 100%;
197
218
  overflow: hidden;
198
219
  text-overflow: ellipsis;
199
220
  white-space: nowrap;
200
221
  position: relative;
222
+ display: inline-block;
201
223
  }
202
224
 
203
225
  .local-review-name:hover {
@@ -13,6 +13,7 @@ const { normalizePath, pathExistsInList, resolveRenamedFile } = require('../util
13
13
  const { buildFileLineCountMap, validateSuggestionLineNumbers } = require('../utils/line-validation');
14
14
  const { getPromptBuilder } = require('./prompts');
15
15
  const { formatValidFiles } = require('./prompts/shared/valid-files');
16
+ const { GIT_DIFF_FLAGS } = require('../git/diff-flags');
16
17
  const {
17
18
  buildAnalysisLineNumberGuidance,
18
19
  buildOrchestrationLineNumberGuidance: buildOrchestrationGuidance,
@@ -27,13 +28,7 @@ const { buildSparseCheckoutGuidance } = require('./prompts/sparse-checkout-guida
27
28
  /** Minimum total suggestion count across all voices before consolidation is applied */
28
29
  const COUNCIL_CONSOLIDATION_THRESHOLD = 8;
29
30
 
30
- /**
31
- * Common git diff flags used across all diff operations.
32
- * - --no-color: Disable color output (guards against color.diff=always in user config)
33
- * - --no-ext-diff: Disable external diff drivers
34
- * - --src-prefix/--dst-prefix: Ensure consistent a/ b/ prefixes (overrides user's diff.noprefix)
35
- */
36
- const GIT_DIFF_COMMON_FLAGS = '--no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/';
31
+ // GIT_DIFF_FLAGS imported from ../git/diff-flags
37
32
 
38
33
  /**
39
34
  * Build a human-readable display label for a council voice/reviewer.
@@ -667,7 +662,7 @@ Do NOT create suggestions for any files not in this list. If you cannot find iss
667
662
  async getChangedFilesList(worktreePath, prMetadata) {
668
663
  try {
669
664
  const { stdout } = await execPromise(
670
- `git diff --no-ext-diff ${prMetadata.base_sha}...${prMetadata.head_sha} --name-only`,
665
+ `git diff ${GIT_DIFF_FLAGS} ${prMetadata.base_sha}...${prMetadata.head_sha} --name-only`,
671
666
  { cwd: worktreePath }
672
667
  );
673
668
  return stdout.trim().split('\n').filter(f => f.length > 0);
@@ -691,7 +686,7 @@ Do NOT create suggestions for any files not in this list. If you cannot find iss
691
686
  try {
692
687
  // Get modified tracked files (unstaged)
693
688
  const { stdout: unstaged } = await execPromise(
694
- 'git diff --no-ext-diff --name-only',
689
+ `git diff ${GIT_DIFF_FLAGS} --name-only`,
695
690
  { cwd: localPath }
696
691
  );
697
692
 
@@ -708,7 +703,7 @@ Do NOT create suggestions for any files not in this list. If you cannot find iss
708
703
  // Include staged files when scope includes staged
709
704
  if (options.includeStaged) {
710
705
  const { stdout: staged } = await execPromise(
711
- 'git diff --no-ext-diff --cached --name-only',
706
+ `git diff ${GIT_DIFF_FLAGS} --cached --name-only`,
712
707
  { cwd: localPath }
713
708
  );
714
709
  const stagedFiles = staged.trim().split('\n').filter(f => f.length > 0);
@@ -997,10 +992,10 @@ ${prMetadata.description || '(No description provided)'}
997
992
  const isLocal = prMetadata.reviewType === 'local';
998
993
  if (isLocal) {
999
994
  // For local mode, diff against HEAD to see working directory changes
1000
- return suffix ? `git diff ${GIT_DIFF_COMMON_FLAGS} HEAD ${suffix}` : `git diff ${GIT_DIFF_COMMON_FLAGS} HEAD`;
995
+ return suffix ? `git diff ${GIT_DIFF_FLAGS} HEAD ${suffix}` : `git diff ${GIT_DIFF_FLAGS} HEAD`;
1001
996
  }
1002
997
  // For PR mode, diff between base and head commits
1003
- const baseCmd = `git diff ${GIT_DIFF_COMMON_FLAGS} ${prMetadata.base_sha}...${prMetadata.head_sha}`;
998
+ const baseCmd = `git diff ${GIT_DIFF_FLAGS} ${prMetadata.base_sha}...${prMetadata.head_sha}`;
1004
999
  return suffix ? `${baseCmd} ${suffix}` : baseCmd;
1005
1000
  }
1006
1001
 
@@ -117,6 +117,7 @@ function getChatProvider(id) {
117
117
  env: overrides.env || {},
118
118
  };
119
119
  if (overrides.model) provider.model = overrides.model;
120
+ if (overrides.provider) provider.provider = overrides.provider;
120
121
  if (overrides.extra_args && Array.isArray(overrides.extra_args)) {
121
122
  provider.args = [...provider.args, ...overrides.extra_args];
122
123
  }
@@ -132,6 +133,7 @@ function getChatProvider(id) {
132
133
  if (overrides.name || overrides.label) merged.name = overrides.name || overrides.label;
133
134
  if (overrides.command) merged.command = overrides.command;
134
135
  if (overrides.model) merged.model = overrides.model;
136
+ if (overrides.provider) merged.provider = overrides.provider;
135
137
  if (overrides.env) merged.env = { ...merged.env, ...overrides.env };
136
138
  if (overrides.args) {
137
139
  merged.args = overrides.args;
@@ -14,6 +14,7 @@ const { EventEmitter } = require('events');
14
14
  const { spawn } = require('child_process');
15
15
  const { createInterface } = require('readline');
16
16
  const logger = require('../utils/logger');
17
+ const { quoteShellArgs } = require('../ai/provider');
17
18
 
18
19
  /**
19
20
  * Dialog methods in extension_ui_request that expect a response.
@@ -30,6 +31,8 @@ class PiBridge extends EventEmitter {
30
31
  * @param {string} [options.systemPrompt] - System prompt text
31
32
  * @param {string} [options.tools] - Comma-separated tool list (default: 'read,grep,find,ls')
32
33
  * @param {string} [options.piCommand] - Override Pi command (default: 'pi')
34
+ * @param {Object} [options.env] - Extra env vars for subprocess
35
+ * @param {boolean} [options.useShell] - Use shell mode for multi-word commands
33
36
  * @param {string[]} [options.skills] - Array of skill file paths to load via --skill
34
37
  * @param {string[]} [options.extensions] - Array of extension directory paths to load via -e
35
38
  * @param {string} [options.sessionPath] - Path to a session file for resumption
@@ -42,6 +45,8 @@ class PiBridge extends EventEmitter {
42
45
  this.systemPrompt = options.systemPrompt || null;
43
46
  this.tools = options.tools || 'read,grep,find,ls';
44
47
  this.piCommand = options.piCommand || process.env.PAIR_REVIEW_PI_CMD || 'pi';
48
+ this.env = options.env || {};
49
+ this.useShell = options.useShell || false;
45
50
  this.skills = options.skills || [];
46
51
  this.extensions = options.extensions || [];
47
52
  this.sessionPath = options.sessionPath || null;
@@ -69,15 +74,20 @@ class PiBridge extends EventEmitter {
69
74
 
70
75
  const args = this._buildArgs();
71
76
  const command = this.piCommand;
72
- const spawnArgs = args;
77
+ const useShell = this.useShell;
73
78
 
74
- logger.info(`[PiBridge] Starting Pi RPC: ${command} ${spawnArgs.join(' ')}`);
79
+ // For multi-word commands (e.g. "devx pi"), use shell mode.
80
+ const spawnCmd = useShell ? `${command} ${quoteShellArgs(args).join(' ')}` : command;
81
+ const spawnArgs = useShell ? [] : args;
82
+
83
+ logger.info(`[PiBridge] Starting Pi RPC: ${command} ${args.join(' ')}`);
75
84
 
76
85
  return new Promise((resolve, reject) => {
77
- const proc = spawn(command, spawnArgs, {
86
+ const proc = spawn(spawnCmd, spawnArgs, {
78
87
  cwd: this.cwd,
79
- env: { ...process.env },
80
- stdio: ['pipe', 'pipe', 'pipe']
88
+ env: { ...process.env, ...this.env },
89
+ stdio: ['pipe', 'pipe', 'pipe'],
90
+ shell: useShell,
81
91
  });
82
92
 
83
93
  this._process = proc;
@@ -548,6 +548,7 @@ class ChatSessionManager {
548
548
  const providerDef = getChatProvider(provider);
549
549
  return new ClaudeCodeBridge({
550
550
  ...options,
551
+ model: options.model || providerDef?.model,
551
552
  claudeCommand: providerDef?.command,
552
553
  env: providerDef?.env,
553
554
  useShell: providerDef?.useShell,
@@ -564,8 +565,19 @@ class ChatSessionManager {
564
565
  useShell: providerDef?.useShell,
565
566
  });
566
567
  }
568
+ // Pi provider — resolve config overrides (command, model, env) from provider def.
569
+ // options.provider is the chat provider ID (e.g. "pi") — do NOT pass it to PiBridge,
570
+ // which would forward it as `--provider pi` to the Pi CLI. The CLI's --provider flag
571
+ // expects a model provider ("google", "anthropic", etc.) and should only come from
572
+ // explicit user configuration (providerDef.provider).
573
+ const providerDef = getChatProvider(provider);
567
574
  return new PiBridge({
568
575
  ...options,
576
+ provider: providerDef?.provider || null,
577
+ model: options.model || providerDef?.model,
578
+ piCommand: providerDef?.command,
579
+ env: providerDef?.env,
580
+ useShell: providerDef?.useShell,
569
581
  tools: CHAT_TOOLS,
570
582
  extensions: [taskExtensionDir],
571
583
  });
package/src/config.js CHANGED
@@ -37,7 +37,8 @@ const DEFAULT_CONFIG = {
37
37
  monorepos: {}, // Monorepo configurations: { "owner/repo": { path: "~/path/to/clone" } }
38
38
  assisted_by_url: "https://github.com/in-the-loop-labs/pair-review", // URL for "Review assisted by" footer link
39
39
  hooks: {}, // Hook commands per event: { "review.started": { "my_hook": { "command": "..." } } }
40
- enable_graphite: false // When true, shows Graphite links alongside GitHub links
40
+ enable_graphite: false, // When true, shows Graphite links alongside GitHub links
41
+ skip_update_notifier: false // When true, suppresses the "update available" notification on exit
41
42
  };
42
43
 
43
44
  /**
@@ -495,6 +496,42 @@ function warnIfDevModeWithoutDbName(config) {
495
496
  }
496
497
  }
497
498
 
499
+ /**
500
+ * Synchronously checks whether the update notifier should be skipped.
501
+ * Reads config files in the standard merge order (managed → global → global.local
502
+ * → project → project.local) and returns the resolved boolean value of
503
+ * `skip_update_notifier`. Designed for use in bin/pair-review.js which runs
504
+ * before the async main process.
505
+ *
506
+ * @returns {boolean} True if the update notifier should be suppressed
507
+ */
508
+ function shouldSkipUpdateNotifier() {
509
+ const fsSync = require('fs');
510
+ const localDir = path.join(process.cwd(), '.pair-review');
511
+ // Keep in sync with the sources list in loadConfig()
512
+ const sources = [
513
+ MANAGED_CONFIG_FILE,
514
+ CONFIG_FILE,
515
+ CONFIG_LOCAL_FILE,
516
+ path.join(localDir, 'config.json'),
517
+ path.join(localDir, 'config.local.json'),
518
+ ];
519
+
520
+ let skip = false;
521
+ for (const filePath of sources) {
522
+ try {
523
+ const data = fsSync.readFileSync(filePath, 'utf8');
524
+ const parsed = JSON.parse(data);
525
+ if ('skip_update_notifier' in parsed) {
526
+ skip = Boolean(parsed.skip_update_notifier);
527
+ }
528
+ } catch {
529
+ // File missing or malformed — skip silently
530
+ }
531
+ }
532
+ return skip;
533
+ }
534
+
498
535
  module.exports = {
499
536
  deepMerge,
500
537
  loadConfig,
@@ -515,6 +552,7 @@ module.exports = {
515
552
  resolveMonorepoOptions,
516
553
  resolveDbName,
517
554
  warnIfDevModeWithoutDbName,
555
+ shouldSkipUpdateNotifier,
518
556
  _resetTokenCache,
519
557
  DEFAULT_CHECKOUT_TIMEOUT_MS
520
558
  };
@@ -0,0 +1,43 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+
3
+ /**
4
+ * Shared git diff flags used across all diff operations.
5
+ *
6
+ * Rationale for each flag:
7
+ * - --no-color: Disable color output for consistent parsing (overrides color.diff / color.ui)
8
+ * - --no-ext-diff: Disable external diff drivers (overrides diff.external)
9
+ * - --src-prefix=a/ --dst-prefix=b/: Ensure consistent a/ b/ prefixes (overrides diff.noprefix / diff.mnemonicPrefix)
10
+ * - --no-relative: Ensure paths are repo-root-relative (overrides diff.relative)
11
+ */
12
+
13
+ /**
14
+ * String form for execSync / exec shell calls (e.g. `git diff ${GIT_DIFF_FLAGS} ...`).
15
+ */
16
+ const GIT_DIFF_FLAGS = '--no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/ --no-relative';
17
+
18
+ /**
19
+ * Array form for simple-git .diff() calls (full diff output including file content).
20
+ */
21
+ const GIT_DIFF_FLAGS_ARRAY = [
22
+ '--no-color',
23
+ '--no-ext-diff',
24
+ '--src-prefix=a/',
25
+ '--dst-prefix=b/',
26
+ '--no-relative'
27
+ ];
28
+
29
+ /**
30
+ * Array form for simple-git .diffSummary() calls.
31
+ * Omits --src-prefix/--dst-prefix since diffSummary doesn't output file content with prefixes.
32
+ */
33
+ const GIT_DIFF_SUMMARY_FLAGS_ARRAY = [
34
+ '--no-color',
35
+ '--no-ext-diff',
36
+ '--no-relative'
37
+ ];
38
+
39
+ module.exports = {
40
+ GIT_DIFF_FLAGS,
41
+ GIT_DIFF_FLAGS_ARRAY,
42
+ GIT_DIFF_SUMMARY_FLAGS_ARRAY
43
+ };
@@ -7,6 +7,7 @@ const { getConfigDir, DEFAULT_CHECKOUT_TIMEOUT_MS } = require('../config');
7
7
  const { WorktreeRepository, generateWorktreeId } = require('../database');
8
8
  const { getGeneratedFilePatterns } = require('./gitattributes');
9
9
  const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('../utils/paths');
10
+ const { GIT_DIFF_FLAGS_ARRAY, GIT_DIFF_SUMMARY_FLAGS_ARRAY } = require('./diff-flags');
10
11
  const { spawn, execSync } = require('child_process');
11
12
 
12
13
  /**
@@ -534,10 +535,12 @@ class GitWorktreeManager {
534
535
 
535
536
  // Generate diff between base SHA and head SHA (not branch names)
536
537
  // This ensures we compare the exact commits from the PR, even if the base branch has moved
538
+ // Defensive flags to normalize output regardless of user's git config
539
+ // (see src/git/diff-flags.js for rationale)
537
540
  const diff = await git.diff([
538
541
  `${prData.base_sha}...${prData.head_sha}`,
539
542
  '--unified=3',
540
- '--no-ext-diff'
543
+ ...GIT_DIFF_FLAGS_ARRAY
541
544
  ]);
542
545
 
543
546
  return diff;
@@ -560,7 +563,10 @@ class GitWorktreeManager {
560
563
 
561
564
  // Get file changes with stats using base SHA and head SHA
562
565
  // This ensures we get the exact files changed in the PR, even if the base branch has moved
563
- const diffSummary = await git.diffSummary([`${prData.base_sha}...${prData.head_sha}`, '--no-ext-diff']);
566
+ const diffSummary = await git.diffSummary([
567
+ `${prData.base_sha}...${prData.head_sha}`,
568
+ ...GIT_DIFF_SUMMARY_FLAGS_ARRAY
569
+ ]);
564
570
 
565
571
  // Parse .gitattributes to identify generated files
566
572
  const gitattributes = await getGeneratedFilePatterns(worktreePath);
@@ -15,7 +15,8 @@ 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 open = (...args) => import('open').then(({ default: open }) => open(...args));
18
+ const { GIT_DIFF_FLAGS } = require('./git/diff-flags');
19
+ const open = (...args) => process.env.PAIR_REVIEW_NO_OPEN ? Promise.resolve() : import('open').then(({ default: open }) => open(...args));
19
20
 
20
21
  // Design note: This module uses execSync for git commands despite async function signatures.
21
22
  // For a local CLI tool, synchronous execution is acceptable and simplifies error handling.
@@ -33,13 +34,7 @@ const MAX_FILE_SIZE = 1024 * 1024;
33
34
  */
34
35
  const GIT_DIFF_HAS_DIFFERENCES = 1;
35
36
 
36
- /**
37
- * Common git diff flags used across all diff operations.
38
- * - --no-color: Disable color output for consistent parsing
39
- * - --no-ext-diff: Disable external diff drivers
40
- * - --src-prefix/--dst-prefix: Ensure consistent a/ b/ prefixes (overrides user's diff.noprefix)
41
- */
42
- const GIT_DIFF_COMMON_FLAGS = '--no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/';
37
+ // GIT_DIFF_FLAGS imported from ./git/diff-flags
43
38
 
44
39
  /**
45
40
  * Find the main git repository root, resolving through worktrees.
@@ -409,7 +404,7 @@ function generateUntrackedDiffs(repoPath, untrackedFiles, wFlag) {
409
404
  const filePath = path.join(repoPath, untracked.file);
410
405
  let fileDiff;
411
406
  try {
412
- fileDiff = execSync(`git diff --no-index ${GIT_DIFF_COMMON_FLAGS}${wFlag} -- /dev/null "${filePath}"`, {
407
+ fileDiff = execSync(`git diff --no-index ${GIT_DIFF_FLAGS}${wFlag} -- /dev/null "${filePath}"`, {
413
408
  cwd: repoPath,
414
409
  encoding: 'utf8',
415
410
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -486,37 +481,37 @@ async function generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch, op
486
481
  try {
487
482
  if (hasBranch && !hasStaged && !hasUnstaged) {
488
483
  // Branch only → committed changes since merge-base
489
- diff = execSync(`git diff ${mergeBaseSha}..HEAD ${GIT_DIFF_COMMON_FLAGS} --unified=25${wFlag}`, {
484
+ diff = execSync(`git diff ${mergeBaseSha}..HEAD ${GIT_DIFF_FLAGS} --unified=25${wFlag}`, {
490
485
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
491
486
  maxBuffer: 50 * 1024 * 1024
492
487
  });
493
488
  } else if (hasBranch && hasStaged && !hasUnstaged) {
494
489
  // Branch–Staged → staged changes relative to merge-base
495
- diff = execSync(`git diff --cached ${mergeBaseSha} ${GIT_DIFF_COMMON_FLAGS} --unified=25${wFlag}`, {
490
+ diff = execSync(`git diff --cached ${mergeBaseSha} ${GIT_DIFF_FLAGS} --unified=25${wFlag}`, {
496
491
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
497
492
  maxBuffer: 50 * 1024 * 1024
498
493
  });
499
494
  } else if (hasBranch && hasUnstaged) {
500
495
  // Branch–Unstaged (or Branch–Untracked) → working tree vs merge-base
501
- diff = execSync(`git diff ${mergeBaseSha} ${GIT_DIFF_COMMON_FLAGS} --unified=25${wFlag}`, {
496
+ diff = execSync(`git diff ${mergeBaseSha} ${GIT_DIFF_FLAGS} --unified=25${wFlag}`, {
502
497
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
503
498
  maxBuffer: 50 * 1024 * 1024
504
499
  });
505
500
  } else if (hasStaged && !hasUnstaged) {
506
501
  // Staged only → cached changes
507
- diff = execSync(`git diff --cached ${GIT_DIFF_COMMON_FLAGS} --unified=25${wFlag}`, {
502
+ diff = execSync(`git diff --cached ${GIT_DIFF_FLAGS} --unified=25${wFlag}`, {
508
503
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
509
504
  maxBuffer: 50 * 1024 * 1024
510
505
  });
511
506
  } else if (hasStaged && hasUnstaged) {
512
507
  // Staged–Unstaged (or Staged–Untracked) → all changes vs HEAD
513
- diff = execSync(`git diff HEAD ${GIT_DIFF_COMMON_FLAGS} --unified=25${wFlag}`, {
508
+ diff = execSync(`git diff HEAD ${GIT_DIFF_FLAGS} --unified=25${wFlag}`, {
514
509
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
515
510
  maxBuffer: 50 * 1024 * 1024
516
511
  });
517
512
  } else if (hasUnstaged) {
518
513
  // Unstaged only or Unstaged–Untracked → working tree changes
519
- diff = execSync(`git diff ${GIT_DIFF_COMMON_FLAGS} --unified=25${wFlag}`, {
514
+ diff = execSync(`git diff ${GIT_DIFF_FLAGS} --unified=25${wFlag}`, {
520
515
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
521
516
  maxBuffer: 50 * 1024 * 1024
522
517
  });
@@ -536,7 +531,7 @@ async function generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch, op
536
531
  // Count staged/unstaged for stats when relevant
537
532
  if (hasStaged) {
538
533
  try {
539
- const stagedDiff = execSync(`git diff --cached --stat --no-color --no-ext-diff`, {
534
+ const stagedDiff = execSync(`git diff --cached --stat ${GIT_DIFF_FLAGS}`, {
540
535
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
541
536
  });
542
537
  if (stagedDiff.trim()) {
@@ -546,7 +541,7 @@ async function generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch, op
546
541
  }
547
542
  if (hasUnstaged) {
548
543
  try {
549
- const unstagedDiff = execSync(`git diff --stat --no-color --no-ext-diff`, {
544
+ const unstagedDiff = execSync(`git diff --stat ${GIT_DIFF_FLAGS}`, {
550
545
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
551
546
  });
552
547
  if (unstagedDiff.trim()) {
@@ -598,7 +593,7 @@ async function computeScopedDigest(repoPath, scopeStart, scopeEnd) {
598
593
  // Staged in scope → cached diff content
599
594
  if (scopeIncludes(scopeStart, scopeEnd, 'staged')) {
600
595
  try {
601
- const result = await execAsync(`git diff --cached ${GIT_DIFF_COMMON_FLAGS}`, {
596
+ const result = await execAsync(`git diff --cached ${GIT_DIFF_FLAGS}`, {
602
597
  cwd: repoPath, encoding: 'utf8', maxBuffer: 50 * 1024 * 1024
603
598
  });
604
599
  parts.push('STAGED:' + result.stdout);
@@ -610,7 +605,7 @@ async function computeScopedDigest(repoPath, scopeStart, scopeEnd) {
610
605
  // Unstaged in scope → working tree diff
611
606
  if (scopeIncludes(scopeStart, scopeEnd, 'unstaged')) {
612
607
  try {
613
- const result = await execAsync(`git diff ${GIT_DIFF_COMMON_FLAGS}`, {
608
+ const result = await execAsync(`git diff ${GIT_DIFF_FLAGS}`, {
614
609
  cwd: repoPath, encoding: 'utf8', maxBuffer: 50 * 1024 * 1024
615
610
  });
616
611
  parts.push('UNSTAGED:' + result.stdout);
@@ -665,7 +660,7 @@ async function generateLocalDiff(repoPath, options = {}) {
665
660
  // Always count staged changes for CLI info message, even when staged is out of scope
666
661
  if (!result.stats.stagedChanges) {
667
662
  try {
668
- const stagedStat = execSync('git diff --cached --stat --no-color --no-ext-diff', {
663
+ const stagedStat = execSync(`git diff --cached --stat ${GIT_DIFF_FLAGS}`, {
669
664
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
670
665
  });
671
666
  if (stagedStat.trim()) {
@@ -881,7 +876,10 @@ async function handleLocalReview(targetPath, flags = {}) {
881
876
  const port = await startServer(db);
882
877
 
883
878
  // Open browser to local review view
884
- const url = `http://localhost:${port}/local/${sessionId}`;
879
+ let url = `http://localhost:${port}/local/${sessionId}`;
880
+ if (flags.ai) {
881
+ url += '?analyze=true';
882
+ }
885
883
  console.log(`\nOpening browser to: ${url}`);
886
884
  await open(url);
887
885
 
package/src/main.js CHANGED
@@ -15,8 +15,9 @@ const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = requi
15
15
  const logger = require('./utils/logger');
16
16
  const simpleGit = require('simple-git');
17
17
  const { getGeneratedFilePatterns } = require('./git/gitattributes');
18
+ const { GIT_DIFF_FLAGS_ARRAY, GIT_DIFF_SUMMARY_FLAGS_ARRAY } = require('./git/diff-flags');
18
19
  const { getEmoji: getCategoryEmoji } = require('./utils/category-emoji');
19
- const open = (...args) => import('open').then(({default: open}) => open(...args));
20
+ const open = (...args) => process.env.PAIR_REVIEW_NO_OPEN ? Promise.resolve() : import('open').then(({default: open}) => open(...args));
20
21
  const { registerProtocolHandler, unregisterProtocolHandler } = require('./protocol-handler');
21
22
 
22
23
  let db = null;
@@ -705,10 +706,17 @@ async function performHeadlessReview(args, config, db, flags, options) {
705
706
  }
706
707
  }
707
708
 
708
- diff = await git.diff([`${prData.base_sha}...${prData.head_sha}`, '--unified=3', '--no-ext-diff']);
709
+ diff = await git.diff([
710
+ `${prData.base_sha}...${prData.head_sha}`,
711
+ '--unified=3',
712
+ ...GIT_DIFF_FLAGS_ARRAY
713
+ ]);
709
714
 
710
715
  // Get changed files
711
- const diffSummary = await git.diffSummary([`${prData.base_sha}...${prData.head_sha}`, '--no-ext-diff']);
716
+ const diffSummary = await git.diffSummary([
717
+ `${prData.base_sha}...${prData.head_sha}`,
718
+ ...GIT_DIFF_SUMMARY_FLAGS_ARRAY
719
+ ]);
712
720
  const gitattributes = await getGeneratedFilePatterns(worktreePath);
713
721
 
714
722
  changedFiles = diffSummary.files.map(file => {
package/src/routes/pr.js CHANGED
@@ -32,6 +32,7 @@ const { broadcastReviewEvent } = require('../events/review-events');
32
32
  const { fireHooks, hasHooks } = require('../hooks/hook-runner');
33
33
  const { buildReviewStartedPayload, buildReviewLoadedPayload, buildAnalysisStartedPayload, buildAnalysisCompletedPayload, getCachedUser } = require('../hooks/payloads');
34
34
  const simpleGit = require('simple-git');
35
+ const { GIT_DIFF_FLAGS_ARRAY, GIT_DIFF_SUMMARY_FLAGS_ARRAY } = require('../git/diff-flags');
35
36
  const {
36
37
  activeAnalyses,
37
38
  reviewToAnalysisId,
@@ -706,10 +707,19 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
706
707
 
707
708
  if (baseSha && headSha) {
708
709
  // Regenerate diff with -w flag to ignore whitespace changes
709
- diffContent = await git.diff([`${baseSha}...${headSha}`, '--unified=3', '-w', '--no-ext-diff']);
710
+ diffContent = await git.diff([
711
+ `${baseSha}...${headSha}`,
712
+ '--unified=3',
713
+ '-w',
714
+ ...GIT_DIFF_FLAGS_ARRAY
715
+ ]);
710
716
 
711
717
  // Regenerate changed files stats with -w flag
712
- const diffSummary = await git.diffSummary([`${baseSha}...${headSha}`, '-w', '--no-ext-diff']);
718
+ const diffSummary = await git.diffSummary([
719
+ `${baseSha}...${headSha}`,
720
+ '-w',
721
+ ...GIT_DIFF_SUMMARY_FLAGS_ARRAY
722
+ ]);
713
723
  const gitattributes = await getGeneratedFilePatterns(worktreePath);
714
724
  changedFiles = diffSummary.files.map(file => {
715
725
  const resolvedFile = resolveRenamedFile(file.file);
package/src/server.js CHANGED
@@ -210,7 +210,7 @@ async function startServer(sharedDb = null) {
210
210
 
211
211
  // Bulk-open — opens multiple local URLs in the OS browser via the `open` package.
212
212
  // Bypasses popup blockers since the server shells out directly.
213
- const openUrl = (...args) => import('open').then(({ default: open }) => open(...args));
213
+ const openUrl = (...args) => process.env.PAIR_REVIEW_NO_OPEN ? Promise.resolve() : import('open').then(({ default: open }) => open(...args));
214
214
  app.post('/api/bulk-open', async (req, res) => {
215
215
  try {
216
216
  const { urls } = req.body || {};
@@ -2,6 +2,7 @@
2
2
  const { promisify } = require('util');
3
3
  const { exec } = require('child_process');
4
4
  const { queryOne } = require('../database');
5
+ const { GIT_DIFF_FLAGS } = require('../git/diff-flags');
5
6
 
6
7
  const execPromise = promisify(exec);
7
8
 
@@ -37,7 +38,7 @@ async function getDiffFileList(db, review) {
37
38
  try {
38
39
  const opts = { cwd: review.local_path };
39
40
  const [{ stdout: unstaged }, { stdout: untracked }] = await Promise.all([
40
- execPromise('git diff --no-ext-diff --name-only', opts),
41
+ execPromise(`git diff ${GIT_DIFF_FLAGS} --name-only`, opts),
41
42
  execPromise('git ls-files --others --exclude-standard', opts),
42
43
  ]);
43
44
  const combined = `${unstaged}\n${untracked}`