@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.
- package/bin/pair-review.js +6 -3
- 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/css/pr.css +6 -1
- package/public/local.html +23 -1
- package/src/ai/analyzer.js +7 -12
- package/src/chat/chat-providers.js +2 -0
- package/src/chat/pi-bridge.js +15 -5
- package/src/chat/session-manager.js +12 -0
- package/src/config.js +39 -1
- package/src/git/diff-flags.js +43 -0
- package/src/git/worktree.js +8 -2
- package/src/local-review.js +19 -21
- package/src/main.js +11 -3
- package/src/routes/pr.js +12 -2
- package/src/server.js +1 -1
- package/src/utils/diff-file-list.js +2 -1
package/bin/pair-review.js
CHANGED
|
@@ -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
|
|
11
|
+
// Check for updates and notify user (skip in MCP mode and when config suppresses it)
|
|
12
12
|
if (!isMCP) {
|
|
13
|
-
const
|
|
14
|
-
|
|
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": "pair-review",
|
|
3
|
-
"version": "3.0.
|
|
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.
|
|
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:
|
|
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 {
|
package/src/ai/analyzer.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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 ${
|
|
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;
|
package/src/chat/pi-bridge.js
CHANGED
|
@@ -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
|
|
77
|
+
const useShell = this.useShell;
|
|
73
78
|
|
|
74
|
-
|
|
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(
|
|
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
|
+
};
|
package/src/git/worktree.js
CHANGED
|
@@ -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
|
-
|
|
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([
|
|
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);
|
package/src/local-review.js
CHANGED
|
@@ -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
|
|
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 ${
|
|
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 ${
|
|
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} ${
|
|
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} ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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
|
|
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
|
|
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 ${
|
|
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 ${
|
|
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(
|
|
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
|
-
|
|
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([
|
|
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([
|
|
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([
|
|
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([
|
|
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(
|
|
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}`
|