@in-the-loop-labs/pair-review 1.2.1 → 1.3.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/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/references/level3-balanced.md +14 -0
- package/plugin-code-critic/skills/analyze/references/level3-fast.md +14 -0
- package/plugin-code-critic/skills/analyze/references/level3-thorough.md +14 -0
- package/src/ai/analyzer.js +34 -2
- package/src/ai/claude-provider.js +5 -0
- package/src/ai/codex-provider.js +24 -2
- package/src/ai/copilot-provider.js +5 -0
- package/src/ai/cursor-agent-provider.js +4 -0
- package/src/ai/gemini-provider.js +5 -0
- package/src/ai/opencode-provider.js +4 -0
- package/src/ai/prompts/baseline/level3/balanced.js +20 -14
- package/src/ai/prompts/baseline/level3/fast.js +6 -0
- package/src/ai/prompts/baseline/level3/thorough.js +10 -4
- package/src/ai/prompts/render-for-skill.js +7 -0
- package/src/ai/prompts/sparse-checkout-guidance.js +76 -0
- package/src/config.js +39 -2
- package/src/git/worktree.js +107 -5
- package/src/github/client.js +35 -0
- package/src/github/parser.js +56 -0
- package/src/local-review.js +12 -3
- package/src/main.js +65 -9
- package/src/routes/setup.js +1 -0
- package/src/routes/worktrees.js +1 -0
- package/src/setup/pr-setup.js +102 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@in-the-loop-labs/pair-review",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
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": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"test:e2e:debug": "playwright test --debug",
|
|
31
31
|
"generate:skill-prompts": "node scripts/generate-skill-prompts.js",
|
|
32
32
|
"changeset": "changeset",
|
|
33
|
-
"version": "changeset version && node scripts/sync-plugin-versions.js && git add package.json package-lock.json CHANGELOG.md .changeset .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json plugin-code-critic/.claude-plugin/plugin.json && git commit -m 'RELEASING: Bump versions'",
|
|
33
|
+
"version": "changeset version && npm install --package-lock-only && node scripts/sync-plugin-versions.js && git add package.json package-lock.json CHANGELOG.md .changeset .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json plugin-code-critic/.claude-plugin/plugin.json && git commit -m 'RELEASING: Bump versions'",
|
|
34
34
|
"release": "npm whoami > /dev/null || { echo 'Error: Not logged in to npm. Run: npm login'; exit 1; } && npm run version && changeset tag && npm publish && git push && git push --tags"
|
|
35
35
|
},
|
|
36
36
|
"keywords": [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pair-review",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
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": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
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",
|
|
@@ -96,6 +96,20 @@ You have READ-ONLY access to the codebase:
|
|
|
96
96
|
|
|
97
97
|
You may use parallel read-only Tasks to explore different areas of the codebase if helpful.
|
|
98
98
|
|
|
99
|
+
## Monorepo / Sparse Checkout Considerations
|
|
100
|
+
|
|
101
|
+
If this repository uses sparse-checkout, only a subset of directories may be checked out. You can check by running:
|
|
102
|
+
```
|
|
103
|
+
git sparse-checkout list
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
If sparse-checkout is active and you need to examine code outside the checked-out directories to understand dependencies, patterns, or impacts, you can expand the checkout:
|
|
107
|
+
```
|
|
108
|
+
git sparse-checkout add <directory>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
This is non-destructive and only adds to what's visible in the worktree.
|
|
112
|
+
|
|
99
113
|
## Output Format
|
|
100
114
|
|
|
101
115
|
**>>> CRITICAL: Output ONLY valid JSON. No markdown, no ```json blocks. Start with { end with }. <<<**
|
|
@@ -68,6 +68,20 @@ Focus on relationships between changed code and existing patterns.
|
|
|
68
68
|
|
|
69
69
|
Do NOT modify files.
|
|
70
70
|
|
|
71
|
+
## Monorepo / Sparse Checkout Considerations
|
|
72
|
+
|
|
73
|
+
If this repository uses sparse-checkout, only a subset of directories may be checked out. You can check by running:
|
|
74
|
+
```
|
|
75
|
+
git sparse-checkout list
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
If sparse-checkout is active and you need to examine code outside the checked-out directories to understand dependencies, patterns, or impacts, you can expand the checkout:
|
|
79
|
+
```
|
|
80
|
+
git sparse-checkout add <directory>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
This is non-destructive and only adds to what's visible in the worktree.
|
|
84
|
+
|
|
71
85
|
## Output Format
|
|
72
86
|
|
|
73
87
|
**>>> CRITICAL: Output ONLY valid JSON. No markdown, no ```json blocks. Start with { end with }. <<<**
|
|
@@ -207,6 +207,20 @@ Note: You may optionally use parallel read-only Tasks to explore different areas
|
|
|
207
207
|
- Tracing dependencies across multiple files
|
|
208
208
|
- Analyzing test coverage in parallel with main code analysis
|
|
209
209
|
|
|
210
|
+
## Monorepo / Sparse Checkout Considerations
|
|
211
|
+
|
|
212
|
+
If this repository uses sparse-checkout, only a subset of directories may be checked out. You can check by running:
|
|
213
|
+
```
|
|
214
|
+
git sparse-checkout list
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
If sparse-checkout is active and you need to examine code outside the checked-out directories to understand dependencies, patterns, or impacts, you can expand the checkout:
|
|
218
|
+
```
|
|
219
|
+
git sparse-checkout add <directory>
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
This is non-destructive and only adds to what's visible in the worktree.
|
|
223
|
+
|
|
210
224
|
## Output Format
|
|
211
225
|
|
|
212
226
|
**>>> CRITICAL: Output ONLY valid JSON. No markdown, no ```json blocks. Start with { end with }. <<<**
|
package/src/ai/analyzer.js
CHANGED
|
@@ -20,6 +20,8 @@ const {
|
|
|
20
20
|
const { registerProcess, isAnalysisCancelled, CancellationError } = require('../routes/shared');
|
|
21
21
|
const { AnalysisRunRepository } = require('../database');
|
|
22
22
|
const { mergeInstructions } = require('../utils/instructions');
|
|
23
|
+
const { GitWorktreeManager } = require('../git/worktree');
|
|
24
|
+
const { buildSparseCheckoutGuidance } = require('./prompts/sparse-checkout-guidance');
|
|
23
25
|
|
|
24
26
|
class Analyzer {
|
|
25
27
|
/**
|
|
@@ -33,6 +35,18 @@ class Analyzer {
|
|
|
33
35
|
this.provider = provider;
|
|
34
36
|
this.db = database;
|
|
35
37
|
this.testContextCache = new Map(); // Cache test detection results per worktree
|
|
38
|
+
this._worktreeManager = null; // Lazy-initialized for sparse-checkout queries
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get or create worktree manager instance (lazy initialization)
|
|
43
|
+
* @returns {GitWorktreeManager}
|
|
44
|
+
*/
|
|
45
|
+
_getWorktreeManager() {
|
|
46
|
+
if (!this._worktreeManager) {
|
|
47
|
+
this._worktreeManager = new GitWorktreeManager();
|
|
48
|
+
}
|
|
49
|
+
return this._worktreeManager;
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
/**
|
|
@@ -462,6 +476,23 @@ class Analyzer {
|
|
|
462
476
|
return `${scriptCommand}${cwdOption}`;
|
|
463
477
|
}
|
|
464
478
|
|
|
479
|
+
/**
|
|
480
|
+
* Build sparse-checkout guidance if applicable
|
|
481
|
+
* @param {string} worktreePath - Path to the worktree
|
|
482
|
+
* @returns {Promise<string>} Guidance text or empty string
|
|
483
|
+
*/
|
|
484
|
+
async buildSparseCheckoutGuidanceSection(worktreePath) {
|
|
485
|
+
const worktreeManager = this._getWorktreeManager();
|
|
486
|
+
const isEnabled = await worktreeManager.isSparseCheckoutEnabled(worktreePath);
|
|
487
|
+
|
|
488
|
+
if (!isEnabled) {
|
|
489
|
+
return '';
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const patterns = await worktreeManager.getSparseCheckoutPatterns(worktreePath);
|
|
493
|
+
return buildSparseCheckoutGuidance({ patterns });
|
|
494
|
+
}
|
|
495
|
+
|
|
465
496
|
/**
|
|
466
497
|
* Build the section of the prompt that includes custom review instructions
|
|
467
498
|
* @param {string} customInstructions - Custom instructions text
|
|
@@ -1796,7 +1827,7 @@ If you are unsure, use "NEW" - it is correct for the vast majority of suggestion
|
|
|
1796
1827
|
|
|
1797
1828
|
// Build the Level 3 prompt with test context
|
|
1798
1829
|
updateProgress('Building prompt for AI to analyze codebase impact');
|
|
1799
|
-
const prompt = this.buildLevel3Prompt(prId, worktreePath, prMetadata, testingContext, generatedPatterns, customInstructions, validFiles, tier);
|
|
1830
|
+
const prompt = await this.buildLevel3Prompt(prId, worktreePath, prMetadata, testingContext, generatedPatterns, customInstructions, validFiles, tier);
|
|
1800
1831
|
|
|
1801
1832
|
// Execute Claude CLI for Level 3 analysis
|
|
1802
1833
|
updateProgress('Running AI to analyze codebase-wide implications');
|
|
@@ -2152,7 +2183,7 @@ If you are unsure, use "NEW" - it is correct for the vast majority of suggestion
|
|
|
2152
2183
|
}
|
|
2153
2184
|
}
|
|
2154
2185
|
|
|
2155
|
-
buildLevel3Prompt(prId, worktreePath, prMetadata, testingContext = null, generatedPatterns = [], customInstructions = null, changedFiles = [], tier = 'balanced') {
|
|
2186
|
+
async buildLevel3Prompt(prId, worktreePath, prMetadata, testingContext = null, generatedPatterns = [], customInstructions = null, changedFiles = [], tier = 'balanced') {
|
|
2156
2187
|
logger.debug(`[Level 3] Building prompt with tier: ${tier}`);
|
|
2157
2188
|
// Try new prompt architecture first
|
|
2158
2189
|
const promptBuilder = getPromptBuilder('level3', tier, this.provider);
|
|
@@ -2175,6 +2206,7 @@ If you are unsure, use "NEW" - it is correct for the vast majority of suggestion
|
|
|
2175
2206
|
prContext: this.buildPRContextSection(prMetadata, criticalNote),
|
|
2176
2207
|
customInstructions: this.buildCustomInstructionsSection(customInstructions),
|
|
2177
2208
|
lineNumberGuidance: this.buildLineNumberGuidance(worktreePath),
|
|
2209
|
+
sparseCheckoutGuidance: await this.buildSparseCheckoutGuidanceSection(worktreePath),
|
|
2178
2210
|
generatedFiles: this.buildGeneratedFilesExclusionSection(generatedPatterns),
|
|
2179
2211
|
changedFiles: formatValidFiles(changedFiles),
|
|
2180
2212
|
testingGuidance: this.buildTestAnalysisSection(testingContext)
|
|
@@ -104,6 +104,7 @@ class ClaudeProvider extends AIProvider {
|
|
|
104
104
|
'Bash(git status*)',
|
|
105
105
|
'Bash(git branch*)',
|
|
106
106
|
'Bash(git rev-parse*)',
|
|
107
|
+
'Bash(git sparse-checkout*)',
|
|
107
108
|
'Bash(*git-diff-lines*)',
|
|
108
109
|
'Bash(cat *)',
|
|
109
110
|
'Bash(ls *)',
|
|
@@ -658,6 +659,10 @@ class ClaudeProvider extends AIProvider {
|
|
|
658
659
|
const command = useShell ? `${this.claudeCmd} --version` : this.claudeCmd;
|
|
659
660
|
const args = useShell ? [] : ['--version'];
|
|
660
661
|
|
|
662
|
+
// Log the actual command for debugging config/override issues
|
|
663
|
+
const fullCmd = useShell ? command : `${command} ${args.join(' ')}`;
|
|
664
|
+
logger.debug(`Claude availability check: ${fullCmd}`);
|
|
665
|
+
|
|
661
666
|
const claude = spawn(command, args, {
|
|
662
667
|
env: {
|
|
663
668
|
...process.env,
|
package/src/ai/codex-provider.js
CHANGED
|
@@ -586,6 +586,7 @@ class CodexProvider extends AIProvider {
|
|
|
586
586
|
|
|
587
587
|
/**
|
|
588
588
|
* Test if Codex CLI is available
|
|
589
|
+
* Uses fast `--version` check instead of running a prompt.
|
|
589
590
|
* Uses the command configured in the instance (respects ENV > config > default precedence)
|
|
590
591
|
* @returns {Promise<boolean>}
|
|
591
592
|
*/
|
|
@@ -598,6 +599,10 @@ class CodexProvider extends AIProvider {
|
|
|
598
599
|
const command = useShell ? `${this.codexCmd} --version` : this.codexCmd;
|
|
599
600
|
const args = useShell ? [] : ['--version'];
|
|
600
601
|
|
|
602
|
+
// Log the actual command for debugging config/override issues
|
|
603
|
+
const fullCmd = useShell ? command : `${command} ${args.join(' ')}`;
|
|
604
|
+
logger.debug(`Codex availability check: ${fullCmd}`);
|
|
605
|
+
|
|
601
606
|
const codex = spawn(command, args, {
|
|
602
607
|
env: {
|
|
603
608
|
...process.env,
|
|
@@ -607,20 +612,36 @@ class CodexProvider extends AIProvider {
|
|
|
607
612
|
});
|
|
608
613
|
|
|
609
614
|
let stdout = '';
|
|
615
|
+
let stderr = '';
|
|
610
616
|
let settled = false;
|
|
611
617
|
|
|
618
|
+
// Timeout guard: if the CLI hangs, resolve false
|
|
619
|
+
const availabilityTimeout = setTimeout(() => {
|
|
620
|
+
if (settled) return;
|
|
621
|
+
settled = true;
|
|
622
|
+
logger.warn('Codex CLI availability check timed out after 10s');
|
|
623
|
+
try { codex.kill(); } catch { /* ignore */ }
|
|
624
|
+
resolve(false);
|
|
625
|
+
}, 10000);
|
|
626
|
+
|
|
612
627
|
codex.stdout.on('data', (data) => {
|
|
613
628
|
stdout += data.toString();
|
|
614
629
|
});
|
|
615
630
|
|
|
631
|
+
codex.stderr.on('data', (data) => {
|
|
632
|
+
stderr += data.toString();
|
|
633
|
+
});
|
|
634
|
+
|
|
616
635
|
codex.on('close', (code) => {
|
|
617
636
|
if (settled) return;
|
|
618
637
|
settled = true;
|
|
619
|
-
|
|
638
|
+
clearTimeout(availabilityTimeout);
|
|
639
|
+
if (code === 0) {
|
|
620
640
|
logger.info(`Codex CLI available: ${stdout.trim()}`);
|
|
621
641
|
resolve(true);
|
|
622
642
|
} else {
|
|
623
|
-
|
|
643
|
+
const stderrMsg = stderr.trim() ? `: ${stderr.trim()}` : '';
|
|
644
|
+
logger.warn(`Codex CLI not available or returned unexpected output (exit code ${code})${stderrMsg}`);
|
|
624
645
|
resolve(false);
|
|
625
646
|
}
|
|
626
647
|
});
|
|
@@ -628,6 +649,7 @@ class CodexProvider extends AIProvider {
|
|
|
628
649
|
codex.on('error', (error) => {
|
|
629
650
|
if (settled) return;
|
|
630
651
|
settled = true;
|
|
652
|
+
clearTimeout(availabilityTimeout);
|
|
631
653
|
logger.warn(`Codex CLI not available: ${error.message}`);
|
|
632
654
|
resolve(false);
|
|
633
655
|
});
|
|
@@ -113,6 +113,7 @@ class CopilotProvider extends AIProvider {
|
|
|
113
113
|
'--allow-tool', 'shell(git status)',
|
|
114
114
|
'--allow-tool', 'shell(git branch)',
|
|
115
115
|
'--allow-tool', 'shell(git rev-parse)',
|
|
116
|
+
'--allow-tool', 'shell(git sparse-checkout)',
|
|
116
117
|
// Custom tool for annotated diff line mapping (matches both direct and path invocations)
|
|
117
118
|
'--allow-tool', 'shell(git-diff-lines)',
|
|
118
119
|
'--allow-tool', 'shell(*/git-diff-lines)', // Absolute path invocation
|
|
@@ -386,6 +387,10 @@ class CopilotProvider extends AIProvider {
|
|
|
386
387
|
const command = useShell ? `${this.copilotCmd} --version` : this.copilotCmd;
|
|
387
388
|
const args = useShell ? [] : ['--version'];
|
|
388
389
|
|
|
390
|
+
// Log the actual command for debugging config/override issues
|
|
391
|
+
const fullCmd = useShell ? command : `${command} ${args.join(' ')}`;
|
|
392
|
+
logger.debug(`Copilot availability check: ${fullCmd}`);
|
|
393
|
+
|
|
389
394
|
const copilot = spawn(command, args, {
|
|
390
395
|
env: {
|
|
391
396
|
...process.env,
|
|
@@ -682,6 +682,10 @@ class CursorAgentProvider extends AIProvider {
|
|
|
682
682
|
const command = useShell ? `${this.agentCmd} --version` : this.agentCmd;
|
|
683
683
|
const args = useShell ? [] : ['--version'];
|
|
684
684
|
|
|
685
|
+
// Log the actual command for debugging config/override issues
|
|
686
|
+
const fullCmd = useShell ? command : `${command} ${args.join(' ')}`;
|
|
687
|
+
logger.debug(`Cursor Agent availability check: ${fullCmd}`);
|
|
688
|
+
|
|
685
689
|
const agent = spawn(command, args, {
|
|
686
690
|
env: {
|
|
687
691
|
...process.env,
|
|
@@ -128,6 +128,7 @@ class GeminiProvider extends AIProvider {
|
|
|
128
128
|
'run_shell_command(git status)',
|
|
129
129
|
'run_shell_command(git branch)',
|
|
130
130
|
'run_shell_command(git rev-parse)',
|
|
131
|
+
'run_shell_command(git sparse-checkout)',
|
|
131
132
|
// Read-only shell commands
|
|
132
133
|
'run_shell_command(ls)', // Directory listing
|
|
133
134
|
'run_shell_command(cat)', // File content viewing
|
|
@@ -636,6 +637,10 @@ class GeminiProvider extends AIProvider {
|
|
|
636
637
|
const command = useShell ? `${this.geminiCmd} --version` : this.geminiCmd;
|
|
637
638
|
const args = useShell ? [] : ['--version'];
|
|
638
639
|
|
|
640
|
+
// Log the actual command for debugging config/override issues
|
|
641
|
+
const fullCmd = useShell ? command : `${command} ${args.join(' ')}`;
|
|
642
|
+
logger.debug(`Gemini availability check: ${fullCmd}`);
|
|
643
|
+
|
|
639
644
|
const gemini = spawn(command, args, {
|
|
640
645
|
env: {
|
|
641
646
|
...process.env,
|
|
@@ -575,6 +575,10 @@ class OpenCodeProvider extends AIProvider {
|
|
|
575
575
|
const command = useShell ? `${this.opencodeCmd} --version` : this.opencodeCmd;
|
|
576
576
|
const args = useShell ? [] : ['--version'];
|
|
577
577
|
|
|
578
|
+
// Log the actual command for debugging config/override issues
|
|
579
|
+
const fullCmd = useShell ? command : `${command} ${args.join(' ')}`;
|
|
580
|
+
logger.debug(`OpenCode availability check: ${fullCmd}`);
|
|
581
|
+
|
|
578
582
|
const opencode = spawn(command, args, {
|
|
579
583
|
env: {
|
|
580
584
|
...process.env,
|
|
@@ -35,7 +35,7 @@ const taggedPrompt = `<section name="role" required="true">
|
|
|
35
35
|
{{prContext}}
|
|
36
36
|
</section>
|
|
37
37
|
|
|
38
|
-
<section name="custom-instructions" optional="true">
|
|
38
|
+
<section name="custom-instructions" optional="true" tier="fast,balanced,thorough">
|
|
39
39
|
{{customInstructions}}
|
|
40
40
|
</section>
|
|
41
41
|
|
|
@@ -47,7 +47,7 @@ const taggedPrompt = `<section name="role" required="true">
|
|
|
47
47
|
{{lineNumberGuidance}}
|
|
48
48
|
</section>
|
|
49
49
|
|
|
50
|
-
<section name="generated-files" optional="true">
|
|
50
|
+
<section name="generated-files" optional="true" tier="fast,balanced,thorough">
|
|
51
51
|
{{generatedFiles}}
|
|
52
52
|
</section>
|
|
53
53
|
|
|
@@ -116,6 +116,10 @@ You have READ-ONLY access to the codebase:
|
|
|
116
116
|
You may use parallel read-only Tasks to explore different areas of the codebase if helpful.
|
|
117
117
|
</section>
|
|
118
118
|
|
|
119
|
+
<section name="sparse-checkout" optional="true" tier="fast,balanced,thorough">
|
|
120
|
+
{{sparseCheckoutGuidance}}
|
|
121
|
+
</section>
|
|
122
|
+
|
|
119
123
|
<section name="output-schema" locked="true">
|
|
120
124
|
## Output Format
|
|
121
125
|
|
|
@@ -203,21 +207,22 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
203
207
|
* Used for parsing and validation
|
|
204
208
|
*/
|
|
205
209
|
const sections = [
|
|
206
|
-
{ name: 'role', required: true },
|
|
210
|
+
{ name: 'role', required: true, tier: ['balanced'] },
|
|
207
211
|
{ name: 'pr-context', locked: true },
|
|
208
|
-
{ name: 'custom-instructions', optional: true },
|
|
209
|
-
{ name: 'level-header', required: true },
|
|
210
|
-
{ name: 'line-number-guidance', required: true },
|
|
211
|
-
{ name: 'generated-files', optional: true },
|
|
212
|
+
{ name: 'custom-instructions', optional: true, tier: ['fast', 'balanced', 'thorough'] },
|
|
213
|
+
{ name: 'level-header', required: true, tier: ['balanced'] },
|
|
214
|
+
{ name: 'line-number-guidance', required: true, tier: ['balanced'] },
|
|
215
|
+
{ name: 'generated-files', optional: true, tier: ['fast', 'balanced', 'thorough'] },
|
|
212
216
|
{ name: 'changed-files', locked: true },
|
|
213
|
-
{ name: 'purpose', required: true },
|
|
214
|
-
{ name: 'analysis-process', required: true },
|
|
215
|
-
{ name: 'focus-areas', required: true },
|
|
216
|
-
{ name: 'available-commands', required: true },
|
|
217
|
+
{ name: 'purpose', required: true, tier: ['balanced'] },
|
|
218
|
+
{ name: 'analysis-process', required: true, tier: ['balanced'] },
|
|
219
|
+
{ name: 'focus-areas', required: true, tier: ['balanced'] },
|
|
220
|
+
{ name: 'available-commands', required: true, tier: ['balanced'] },
|
|
221
|
+
{ name: 'sparse-checkout', optional: true, tier: ['fast', 'balanced', 'thorough'] },
|
|
217
222
|
{ name: 'output-schema', locked: true },
|
|
218
|
-
{ name: 'diff-instructions', required: true },
|
|
219
|
-
{ name: 'file-level-guidance', optional: true },
|
|
220
|
-
{ name: 'guidelines', required: true }
|
|
223
|
+
{ name: 'diff-instructions', required: true, tier: ['balanced'] },
|
|
224
|
+
{ name: 'file-level-guidance', optional: true, tier: ['balanced', 'thorough'] },
|
|
225
|
+
{ name: 'guidelines', required: true, tier: ['balanced'] }
|
|
221
226
|
];
|
|
222
227
|
|
|
223
228
|
/**
|
|
@@ -235,6 +240,7 @@ const defaultOrder = [
|
|
|
235
240
|
'analysis-process',
|
|
236
241
|
'focus-areas',
|
|
237
242
|
'available-commands',
|
|
243
|
+
'sparse-checkout',
|
|
238
244
|
'output-schema',
|
|
239
245
|
'diff-instructions',
|
|
240
246
|
'file-level-guidance',
|
|
@@ -92,6 +92,10 @@ Focus on relationships between changed code and existing patterns.
|
|
|
92
92
|
Do NOT modify files.
|
|
93
93
|
</section>
|
|
94
94
|
|
|
95
|
+
<section name="sparse-checkout" optional="true" tier="fast,balanced,thorough">
|
|
96
|
+
{{sparseCheckoutGuidance}}
|
|
97
|
+
</section>
|
|
98
|
+
|
|
95
99
|
<section name="output-schema" locked="true">
|
|
96
100
|
## Output Format
|
|
97
101
|
|
|
@@ -155,6 +159,7 @@ const sections = [
|
|
|
155
159
|
{ name: 'analysis-process', required: true, tier: ['fast'] },
|
|
156
160
|
{ name: 'focus-areas', required: true, tier: ['fast'] },
|
|
157
161
|
{ name: 'available-commands', required: true, tier: ['fast'] },
|
|
162
|
+
{ name: 'sparse-checkout', optional: true, tier: ['fast', 'balanced', 'thorough'] },
|
|
158
163
|
{ name: 'output-schema', locked: true },
|
|
159
164
|
{ name: 'diff-instructions', required: true, tier: ['fast'] },
|
|
160
165
|
{ name: 'guidelines', required: true, tier: ['fast'] }
|
|
@@ -176,6 +181,7 @@ const defaultOrder = [
|
|
|
176
181
|
'analysis-process',
|
|
177
182
|
'focus-areas',
|
|
178
183
|
'available-commands',
|
|
184
|
+
'sparse-checkout',
|
|
179
185
|
'output-schema',
|
|
180
186
|
'diff-instructions',
|
|
181
187
|
'guidelines'
|
|
@@ -40,7 +40,7 @@ const taggedPrompt = `<section name="role" required="true" tier="thorough">
|
|
|
40
40
|
{{prContext}}
|
|
41
41
|
</section>
|
|
42
42
|
|
|
43
|
-
<section name="custom-instructions" optional="true" tier="balanced,thorough">
|
|
43
|
+
<section name="custom-instructions" optional="true" tier="fast,balanced,thorough">
|
|
44
44
|
{{customInstructions}}
|
|
45
45
|
</section>
|
|
46
46
|
|
|
@@ -75,7 +75,7 @@ Approach this analysis systematically, building understanding from specific chan
|
|
|
75
75
|
**Calibrate your output**: Quality matters more than speed. Surface fewer high-confidence findings that genuinely require codebase-wide understanding.
|
|
76
76
|
</section>
|
|
77
77
|
|
|
78
|
-
<section name="generated-files" optional="true" tier="balanced,thorough">
|
|
78
|
+
<section name="generated-files" optional="true" tier="fast,balanced,thorough">
|
|
79
79
|
{{generatedFiles}}
|
|
80
80
|
</section>
|
|
81
81
|
|
|
@@ -234,6 +234,10 @@ Note: You may optionally use parallel read-only Tasks to explore different areas
|
|
|
234
234
|
- Analyzing test coverage in parallel with main code analysis
|
|
235
235
|
</section>
|
|
236
236
|
|
|
237
|
+
<section name="sparse-checkout" optional="true" tier="fast,balanced,thorough">
|
|
238
|
+
{{sparseCheckoutGuidance}}
|
|
239
|
+
</section>
|
|
240
|
+
|
|
237
241
|
<section name="output-schema" locked="true">
|
|
238
242
|
## Output Format
|
|
239
243
|
|
|
@@ -377,16 +381,17 @@ Level 3 findings must require codebase context. If an issue could be identified
|
|
|
377
381
|
const sections = [
|
|
378
382
|
{ name: 'role', required: true, tier: ['thorough'] },
|
|
379
383
|
{ name: 'pr-context', locked: true },
|
|
380
|
-
{ name: 'custom-instructions', optional: true, tier: ['balanced', 'thorough'] },
|
|
384
|
+
{ name: 'custom-instructions', optional: true, tier: ['fast', 'balanced', 'thorough'] },
|
|
381
385
|
{ name: 'level-header', required: true, tier: ['thorough'] },
|
|
382
386
|
{ name: 'line-number-guidance', required: true, tier: ['thorough'] },
|
|
383
387
|
{ name: 'reasoning-encouragement', required: true, tier: ['thorough'] },
|
|
384
|
-
{ name: 'generated-files', optional: true, tier: ['balanced', 'thorough'] },
|
|
388
|
+
{ name: 'generated-files', optional: true, tier: ['fast', 'balanced', 'thorough'] },
|
|
385
389
|
{ name: 'changed-files', locked: true },
|
|
386
390
|
{ name: 'purpose', required: true, tier: ['thorough'] },
|
|
387
391
|
{ name: 'analysis-process', required: true, tier: ['thorough'] },
|
|
388
392
|
{ name: 'focus-areas', required: true, tier: ['thorough'] },
|
|
389
393
|
{ name: 'available-commands', required: true, tier: ['thorough'] },
|
|
394
|
+
{ name: 'sparse-checkout', optional: true, tier: ['fast', 'balanced', 'thorough'] },
|
|
390
395
|
{ name: 'output-schema', locked: true },
|
|
391
396
|
{ name: 'diff-instructions', required: true, tier: ['thorough'] },
|
|
392
397
|
{ name: 'confidence-guidance', required: true, tier: ['thorough'] },
|
|
@@ -412,6 +417,7 @@ const defaultOrder = [
|
|
|
412
417
|
'analysis-process',
|
|
413
418
|
'focus-areas',
|
|
414
419
|
'available-commands',
|
|
420
|
+
'sparse-checkout',
|
|
415
421
|
'output-schema',
|
|
416
422
|
'diff-instructions',
|
|
417
423
|
'confidence-guidance',
|
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
buildAnalysisLineNumberGuidance,
|
|
14
14
|
buildOrchestrationLineNumberGuidance,
|
|
15
15
|
} = require('./line-number-guidance');
|
|
16
|
+
const { buildSparseCheckoutGuidance } = require('./sparse-checkout-guidance');
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Skill-appropriate default values for prompt placeholders.
|
|
@@ -49,6 +50,12 @@ const SKILL_DEFAULTS = {
|
|
|
49
50
|
// Collapse when empty
|
|
50
51
|
generatedFiles: '',
|
|
51
52
|
customInstructions: '',
|
|
53
|
+
|
|
54
|
+
// For standalone skill usage, include conditional sparse-checkout guidance
|
|
55
|
+
// since we don't know if the review context uses sparse-checkout.
|
|
56
|
+
// The conditional flag produces softer language that doesn't assert
|
|
57
|
+
// sparse-checkout is active — only advises what to do if it is.
|
|
58
|
+
sparseCheckoutGuidance: buildSparseCheckoutGuidance({ conditional: true }),
|
|
52
59
|
};
|
|
53
60
|
|
|
54
61
|
/**
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Sparse-checkout guidance for analysis prompts.
|
|
4
|
+
* Injected when the worktree uses sparse-checkout.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Build sparse-checkout guidance for analysis prompts.
|
|
9
|
+
*
|
|
10
|
+
* When called with `conditional: true` (the skill/standalone path),
|
|
11
|
+
* produces softer language that doesn't assert sparse-checkout is active
|
|
12
|
+
* — only advises what to do *if* it is. The live Analyzer path omits
|
|
13
|
+
* this flag because it has already verified sparse-checkout is enabled.
|
|
14
|
+
*
|
|
15
|
+
* @param {Object} options
|
|
16
|
+
* @param {string[]} options.patterns - Current sparse-checkout patterns
|
|
17
|
+
* @param {boolean} [options.conditional=false] - Use conditional language
|
|
18
|
+
* (for contexts where we don't know whether sparse-checkout is active)
|
|
19
|
+
* @returns {string} Markdown guidance
|
|
20
|
+
*/
|
|
21
|
+
function buildSparseCheckoutGuidance(options = {}) {
|
|
22
|
+
const { patterns = [], conditional = false } = options;
|
|
23
|
+
|
|
24
|
+
if (conditional) {
|
|
25
|
+
return buildConditionalGuidance();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const patternList = patterns.length > 0
|
|
29
|
+
? patterns.map(p => ` - ${p}`).join('\n')
|
|
30
|
+
: ' (run `git sparse-checkout list` to see current patterns)';
|
|
31
|
+
|
|
32
|
+
return `
|
|
33
|
+
## Sparse Checkout Active
|
|
34
|
+
|
|
35
|
+
This repository uses sparse-checkout. Only a subset of directories are checked out:
|
|
36
|
+
${patternList}
|
|
37
|
+
|
|
38
|
+
**Exploring related code**: If you need to examine code outside the checked-out directories to understand dependencies, patterns, or impacts, you can expand the checkout:
|
|
39
|
+
\`\`\`
|
|
40
|
+
git sparse-checkout add <directory>
|
|
41
|
+
\`\`\`
|
|
42
|
+
|
|
43
|
+
For example, if you see an import from \`packages/shared-utils\` but that directory isn't checked out, run:
|
|
44
|
+
\`\`\`
|
|
45
|
+
git sparse-checkout add packages/shared-utils
|
|
46
|
+
\`\`\`
|
|
47
|
+
|
|
48
|
+
This is non-destructive and only adds to what's visible in this review worktree.
|
|
49
|
+
`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build conditional sparse-checkout guidance for contexts where we cannot
|
|
54
|
+
* determine whether sparse-checkout is active (e.g., standalone skills
|
|
55
|
+
* that lack worktree access).
|
|
56
|
+
* @returns {string} Markdown guidance with conditional framing
|
|
57
|
+
*/
|
|
58
|
+
function buildConditionalGuidance() {
|
|
59
|
+
return `
|
|
60
|
+
## Monorepo / Sparse Checkout Considerations
|
|
61
|
+
|
|
62
|
+
If this repository uses sparse-checkout, only a subset of directories may be checked out. You can check by running:
|
|
63
|
+
\`\`\`
|
|
64
|
+
git sparse-checkout list
|
|
65
|
+
\`\`\`
|
|
66
|
+
|
|
67
|
+
If sparse-checkout is active and you need to examine code outside the checked-out directories to understand dependencies, patterns, or impacts, you can expand the checkout:
|
|
68
|
+
\`\`\`
|
|
69
|
+
git sparse-checkout add <directory>
|
|
70
|
+
\`\`\`
|
|
71
|
+
|
|
72
|
+
This is non-destructive and only adds to what's visible in the worktree.
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { buildSparseCheckoutGuidance };
|
package/src/config.js
CHANGED
|
@@ -19,7 +19,8 @@ const DEFAULT_CONFIG = {
|
|
|
19
19
|
dev_mode: false, // When true, disables static file caching for development
|
|
20
20
|
debug_stream: false, // When true, logs AI provider streaming events (equivalent to --debug-stream CLI flag)
|
|
21
21
|
yolo: false, // When true, skips fine-grained AI provider permission setup (equivalent to --yolo CLI flag)
|
|
22
|
-
providers: {} // Custom provider configurations (overrides built-in defaults)
|
|
22
|
+
providers: {}, // Custom provider configurations (overrides built-in defaults)
|
|
23
|
+
monorepos: {} // Monorepo configurations: { "owner/repo": { path: "~/path/to/clone" } }
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -250,6 +251,40 @@ function showWelcomeMessage() {
|
|
|
250
251
|
`);
|
|
251
252
|
}
|
|
252
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Expands paths that start with ~/ to use the user's home directory.
|
|
256
|
+
*
|
|
257
|
+
* Note: Node.js does not have built-in tilde expansion. The os.homedir()
|
|
258
|
+
* function returns the home directory path but doesn't expand tildes in
|
|
259
|
+
* strings. This manual approach is the standard pattern; external packages
|
|
260
|
+
* like 'expand-tilde' exist but add unnecessary dependencies for this
|
|
261
|
+
* simple use case.
|
|
262
|
+
*
|
|
263
|
+
* @param {string} p - Path to expand
|
|
264
|
+
* @returns {string} - Expanded path
|
|
265
|
+
*/
|
|
266
|
+
function expandPath(p) {
|
|
267
|
+
if (!p) return p;
|
|
268
|
+
if (p.startsWith('~/')) {
|
|
269
|
+
return path.join(os.homedir(), p.slice(2));
|
|
270
|
+
}
|
|
271
|
+
return p;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Gets the configured monorepo path for a repository
|
|
276
|
+
* @param {Object} config - Configuration object from loadConfig()
|
|
277
|
+
* @param {string} repository - Repository in "owner/repo" format
|
|
278
|
+
* @returns {string|null} - Expanded path or null if not configured
|
|
279
|
+
*/
|
|
280
|
+
function getMonorepoPath(config, repository) {
|
|
281
|
+
const monorepoConfig = config.monorepos?.[repository];
|
|
282
|
+
if (monorepoConfig?.path) {
|
|
283
|
+
return expandPath(monorepoConfig.path);
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
253
288
|
module.exports = {
|
|
254
289
|
loadConfig,
|
|
255
290
|
saveConfig,
|
|
@@ -259,5 +294,7 @@ module.exports = {
|
|
|
259
294
|
getDefaultProvider,
|
|
260
295
|
getDefaultModel,
|
|
261
296
|
isRunningViaNpx,
|
|
262
|
-
showWelcomeMessage
|
|
297
|
+
showWelcomeMessage,
|
|
298
|
+
expandPath,
|
|
299
|
+
getMonorepoPath
|
|
263
300
|
};
|
package/src/git/worktree.js
CHANGED
|
@@ -26,10 +26,14 @@ class GitWorktreeManager {
|
|
|
26
26
|
* Create a git worktree for a PR and checkout to the PR head commit
|
|
27
27
|
* @param {Object} prInfo - PR information { owner, repo, number }
|
|
28
28
|
* @param {Object} prData - PR data from GitHub API
|
|
29
|
-
* @param {string} repositoryPath - Local repository path
|
|
29
|
+
* @param {string} repositoryPath - Local repository path (main git root)
|
|
30
|
+
* @param {Object} [options] - Optional settings
|
|
31
|
+
* @param {string} [options.worktreeSourcePath] - Path to use as cwd for git worktree add
|
|
32
|
+
* (to inherit sparse-checkout from an existing worktree). Falls back to repositoryPath.
|
|
30
33
|
* @returns {Promise<string>} Path to created worktree
|
|
31
34
|
*/
|
|
32
|
-
async createWorktreeForPR(prInfo, prData, repositoryPath) {
|
|
35
|
+
async createWorktreeForPR(prInfo, prData, repositoryPath, options = {}) {
|
|
36
|
+
const { worktreeSourcePath } = options;
|
|
33
37
|
// Check if worktree already exists in DB
|
|
34
38
|
const repository = normalizeRepository(prInfo.owner, prInfo.repo);
|
|
35
39
|
let worktreePath;
|
|
@@ -130,14 +134,20 @@ class GitWorktreeManager {
|
|
|
130
134
|
}
|
|
131
135
|
|
|
132
136
|
// Create worktree and checkout to base branch
|
|
133
|
-
|
|
137
|
+
// Use worktreeSourcePath as cwd if provided (to inherit sparse-checkout from existing worktree)
|
|
138
|
+
const worktreeAddGit = worktreeSourcePath ? simpleGit(worktreeSourcePath) : git;
|
|
139
|
+
if (worktreeSourcePath) {
|
|
140
|
+
console.log(`Creating worktree at ${worktreePath} from ${prData.base_branch} (inheriting sparse-checkout from ${worktreeSourcePath})...`);
|
|
141
|
+
} else {
|
|
142
|
+
console.log(`Creating worktree at ${worktreePath} from ${prData.base_branch}...`);
|
|
143
|
+
}
|
|
134
144
|
try {
|
|
135
|
-
await
|
|
145
|
+
await worktreeAddGit.raw(['worktree', 'add', worktreePath, `origin/${prData.base_branch}`]);
|
|
136
146
|
} catch (worktreeError) {
|
|
137
147
|
// If worktree creation fails due to existing registration, try with --force
|
|
138
148
|
if (worktreeError.message.includes('already registered')) {
|
|
139
149
|
console.log('Worktree already registered, trying with --force...');
|
|
140
|
-
await
|
|
150
|
+
await worktreeAddGit.raw(['worktree', 'add', '--force', worktreePath, `origin/${prData.base_branch}`]);
|
|
141
151
|
} else {
|
|
142
152
|
throw worktreeError;
|
|
143
153
|
}
|
|
@@ -692,6 +702,98 @@ class GitWorktreeManager {
|
|
|
692
702
|
};
|
|
693
703
|
}
|
|
694
704
|
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Check if sparse-checkout is enabled for a git repository
|
|
708
|
+
* @param {string} repoPath - Path to the git repository or worktree
|
|
709
|
+
* @returns {Promise<boolean>} Whether sparse-checkout is enabled
|
|
710
|
+
*/
|
|
711
|
+
async isSparseCheckoutEnabled(repoPath) {
|
|
712
|
+
try {
|
|
713
|
+
const git = simpleGit(repoPath);
|
|
714
|
+
const config = await git.raw(['config', 'core.sparseCheckout']);
|
|
715
|
+
return config.trim() === 'true';
|
|
716
|
+
} catch {
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Get current sparse-checkout patterns
|
|
723
|
+
* @param {string} repoPath - Path to the git repository or worktree
|
|
724
|
+
* @returns {Promise<string[]>} Array of sparse-checkout patterns
|
|
725
|
+
*/
|
|
726
|
+
async getSparseCheckoutPatterns(repoPath) {
|
|
727
|
+
try {
|
|
728
|
+
const git = simpleGit(repoPath);
|
|
729
|
+
const output = await git.raw(['sparse-checkout', 'list']);
|
|
730
|
+
return output.trim().split('\n').filter(Boolean);
|
|
731
|
+
} catch {
|
|
732
|
+
return [];
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Ensure all directories containing changed files are in sparse-checkout.
|
|
738
|
+
* Finds the minimal set of directories to add.
|
|
739
|
+
*
|
|
740
|
+
* @param {string} worktreePath - Path to the worktree
|
|
741
|
+
* @param {Array} changedFiles - Array of changed file objects with filename or file property
|
|
742
|
+
* @returns {Promise<string[]>} Directories that were added
|
|
743
|
+
*/
|
|
744
|
+
async ensurePRDirectoriesInSparseCheckout(worktreePath, changedFiles) {
|
|
745
|
+
if (!await this.isSparseCheckoutEnabled(worktreePath)) {
|
|
746
|
+
return [];
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const currentPatterns = await this.getSparseCheckoutPatterns(worktreePath);
|
|
750
|
+
|
|
751
|
+
// Extract unique directory paths from changed files
|
|
752
|
+
// Support both {filename} and {file} properties
|
|
753
|
+
const neededDirs = new Set();
|
|
754
|
+
for (const file of changedFiles) {
|
|
755
|
+
const filename = file.filename || file.file;
|
|
756
|
+
if (!filename) continue;
|
|
757
|
+
// Add only the immediate parent directory of the file.
|
|
758
|
+
// Root-level files (no '/') are skipped — cone mode always includes the repo root.
|
|
759
|
+
const lastSlash = filename.lastIndexOf('/');
|
|
760
|
+
if (lastSlash > 0) {
|
|
761
|
+
neededDirs.add(filename.substring(0, lastSlash));
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Find directories not covered by current patterns.
|
|
766
|
+
// NOTE: This uses startsWith() for directory-based comparison, which only
|
|
767
|
+
// supports cone mode (directory path patterns). Glob-based sparse-checkout
|
|
768
|
+
// patterns (e.g., '*.js', '**/test/') would not be matched correctly.
|
|
769
|
+
// This is acceptable for now since we only support cone mode throughout
|
|
770
|
+
// the worktree implementation. See tech debt tracking for glob support.
|
|
771
|
+
const missingDirs = [...neededDirs].filter(dir => {
|
|
772
|
+
// Check if dir is already covered by an existing pattern
|
|
773
|
+
return !currentPatterns.some(pattern => {
|
|
774
|
+
// Covered if: exact match or dir is inside pattern (pattern is parent).
|
|
775
|
+
// Note: we do NOT check pattern.startsWith(dir + '/') because a child
|
|
776
|
+
// pattern (e.g., 'packages/core') does not cover files directly under
|
|
777
|
+
// the parent directory (e.g., 'packages/package.json').
|
|
778
|
+
return dir === pattern ||
|
|
779
|
+
dir.startsWith(pattern + '/');
|
|
780
|
+
});
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
// Find minimal set (remove dirs whose parents are also in missingDirs)
|
|
784
|
+
const minimalDirs = missingDirs.filter(dir => {
|
|
785
|
+
return !missingDirs.some(other =>
|
|
786
|
+
other !== dir && dir.startsWith(other + '/')
|
|
787
|
+
);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
if (minimalDirs.length > 0) {
|
|
791
|
+
const git = simpleGit(worktreePath);
|
|
792
|
+
await git.raw(['sparse-checkout', 'add', ...minimalDirs]);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
return minimalDirs;
|
|
796
|
+
}
|
|
695
797
|
}
|
|
696
798
|
|
|
697
799
|
module.exports = { GitWorktreeManager };
|
package/src/github/client.js
CHANGED
|
@@ -83,6 +83,41 @@ class GitHubClient {
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Fetch the list of files changed in a pull request.
|
|
88
|
+
*
|
|
89
|
+
* Uses the GitHub REST API `pulls.listFiles` endpoint, which returns an
|
|
90
|
+
* array of file objects (with `filename`, `status`, `additions`, etc.).
|
|
91
|
+
* This is distinct from the `changed_files` integer returned by
|
|
92
|
+
* `pulls.get`, which is only a count.
|
|
93
|
+
*
|
|
94
|
+
* Paginates automatically to handle PRs with more than 100 changed files.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} owner - Repository owner
|
|
97
|
+
* @param {string} repo - Repository name
|
|
98
|
+
* @param {number} pullNumber - Pull request number
|
|
99
|
+
* @returns {Promise<Array<{filename: string, status: string, additions: number, deletions: number, changes: number}>>}
|
|
100
|
+
*/
|
|
101
|
+
async fetchPullRequestFiles(owner, repo, pullNumber) {
|
|
102
|
+
try {
|
|
103
|
+
const files = await this.octokit.paginate(this.octokit.rest.pulls.listFiles, {
|
|
104
|
+
owner,
|
|
105
|
+
repo,
|
|
106
|
+
pull_number: pullNumber,
|
|
107
|
+
per_page: 100
|
|
108
|
+
});
|
|
109
|
+
return files.map(f => ({
|
|
110
|
+
filename: f.filename,
|
|
111
|
+
status: f.status,
|
|
112
|
+
additions: f.additions,
|
|
113
|
+
deletions: f.deletions,
|
|
114
|
+
changes: f.changes
|
|
115
|
+
}));
|
|
116
|
+
} catch (error) {
|
|
117
|
+
await this.handleApiError(error, owner, repo, pullNumber);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
86
121
|
/**
|
|
87
122
|
* Validate GitHub token by making a test API call
|
|
88
123
|
* @returns {Promise<boolean>} Whether the token is valid
|
package/src/github/parser.js
CHANGED
|
@@ -218,6 +218,62 @@ class PRArgumentParser {
|
|
|
218
218
|
return process.cwd();
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Check if a directory is a git repository that matches the specified owner/repo.
|
|
223
|
+
* Compares the git remote origin URL against the expected owner/repo.
|
|
224
|
+
*
|
|
225
|
+
* @param {string} directory - Directory path to check
|
|
226
|
+
* @param {string} expectedOwner - Expected repository owner
|
|
227
|
+
* @param {string} expectedRepo - Expected repository name
|
|
228
|
+
* @returns {Promise<boolean>} True if the directory is a matching git repository
|
|
229
|
+
*/
|
|
230
|
+
async isMatchingRepository(directory, expectedOwner, expectedRepo) {
|
|
231
|
+
try {
|
|
232
|
+
// Use _createGitForDirectory for testability (can be overridden in tests)
|
|
233
|
+
const git = this._createGitForDirectory(directory);
|
|
234
|
+
|
|
235
|
+
// Check if it's a git repository
|
|
236
|
+
const isRepo = await git.checkIsRepo();
|
|
237
|
+
if (!isRepo) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Get remote origin URL
|
|
242
|
+
const remotes = await git.getRemotes(true);
|
|
243
|
+
const origin = remotes.find(remote => remote.name === 'origin');
|
|
244
|
+
|
|
245
|
+
if (!origin) {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const remoteUrl = origin.refs.fetch || origin.refs.push;
|
|
250
|
+
if (!remoteUrl) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Parse the owner/repo from the remote URL
|
|
255
|
+
const { owner, repo } = this.parseRepositoryFromURL(remoteUrl);
|
|
256
|
+
|
|
257
|
+
// Compare case-insensitively (GitHub repos are case-insensitive)
|
|
258
|
+
return owner.toLowerCase() === expectedOwner.toLowerCase() &&
|
|
259
|
+
repo.toLowerCase() === expectedRepo.toLowerCase();
|
|
260
|
+
} catch (error) {
|
|
261
|
+
// Any error means the directory doesn't match
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Create a git instance for a given directory.
|
|
268
|
+
* This method exists for testability - tests can override it.
|
|
269
|
+
* @param {string} directory - Directory path
|
|
270
|
+
* @returns {Object} simpleGit instance
|
|
271
|
+
* @private
|
|
272
|
+
*/
|
|
273
|
+
_createGitForDirectory(directory) {
|
|
274
|
+
return simpleGit(directory);
|
|
275
|
+
}
|
|
276
|
+
|
|
221
277
|
/**
|
|
222
278
|
* Check if current directory is a git repository
|
|
223
279
|
* @returns {Promise<boolean>} Whether current directory is a git repo
|
package/src/local-review.js
CHANGED
|
@@ -57,11 +57,20 @@ async function findMainGitRoot(repoPath) {
|
|
|
57
57
|
|
|
58
58
|
// For worktrees, commonDir is an absolute path like "/path/to/main/.git"
|
|
59
59
|
// or a relative path like "../main/.git"
|
|
60
|
-
// Resolve it and go up one level to get the main repo root
|
|
61
60
|
const resolvedCommonDir = path.resolve(repoPath, commonDir);
|
|
62
|
-
const mainRepoRoot = path.dirname(resolvedCommonDir);
|
|
63
61
|
|
|
64
|
-
|
|
62
|
+
// Determine if this is a .git directory (regular repo) or a bare repo
|
|
63
|
+
// Regular repos: commonDir ends with ".git" (e.g., /path/to/repo/.git)
|
|
64
|
+
// Bare repos: commonDir is the repo itself (e.g., /path/to/repo.git or /path/to/git)
|
|
65
|
+
// The key difference: for regular repos, basename is exactly ".git"
|
|
66
|
+
const basename = path.basename(resolvedCommonDir);
|
|
67
|
+
if (basename === '.git') {
|
|
68
|
+
// Regular repo - go up one level to get the repo root
|
|
69
|
+
return path.dirname(resolvedCommonDir);
|
|
70
|
+
} else {
|
|
71
|
+
// Bare repo - the commonDir IS the repo
|
|
72
|
+
return resolvedCommonDir;
|
|
73
|
+
}
|
|
65
74
|
} catch (error) {
|
|
66
75
|
throw new Error(`Failed to find main git root: ${error.message}`);
|
|
67
76
|
}
|
package/src/main.js
CHANGED
|
@@ -9,7 +9,7 @@ const { startServer } = require('./server');
|
|
|
9
9
|
const Analyzer = require('./ai/analyzer');
|
|
10
10
|
const { applyConfigOverrides } = require('./ai');
|
|
11
11
|
const { handleLocalReview, findMainGitRoot } = require('./local-review');
|
|
12
|
-
const { storePRData, registerRepositoryLocation } = require('./setup/pr-setup');
|
|
12
|
+
const { storePRData, registerRepositoryLocation, findRepositoryPath } = require('./setup/pr-setup');
|
|
13
13
|
const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('./utils/paths');
|
|
14
14
|
const logger = require('./utils/logger');
|
|
15
15
|
const simpleGit = require('simple-git');
|
|
@@ -484,16 +484,39 @@ async function handlePullRequest(args, config, db, flags = {}) {
|
|
|
484
484
|
console.log('Fetching pull request data from GitHub...');
|
|
485
485
|
const prData = await githubClient.fetchPullRequest(prInfo.owner, prInfo.repo, prInfo.number);
|
|
486
486
|
|
|
487
|
-
//
|
|
487
|
+
// Determine repository path: only use cwd if it matches the target repo
|
|
488
488
|
const currentDir = parser.getCurrentDirectory();
|
|
489
|
+
const isMatchingRepo = await parser.isMatchingRepository(currentDir, prInfo.owner, prInfo.repo);
|
|
489
490
|
|
|
490
|
-
|
|
491
|
-
|
|
491
|
+
let repositoryPath;
|
|
492
|
+
if (isMatchingRepo) {
|
|
493
|
+
// Current directory is a checkout of the target repository
|
|
494
|
+
repositoryPath = currentDir;
|
|
495
|
+
// Register the known repository location for future web UI usage
|
|
496
|
+
await registerRepositoryLocation(db, currentDir, prInfo.owner, prInfo.repo);
|
|
497
|
+
} else {
|
|
498
|
+
// Current directory is not the target repository - find or clone it
|
|
499
|
+
console.log(`Current directory is not a checkout of ${prInfo.owner}/${prInfo.repo}, locating repository...`);
|
|
500
|
+
const repository = normalizeRepository(prInfo.owner, prInfo.repo);
|
|
501
|
+
const result = await findRepositoryPath({
|
|
502
|
+
db,
|
|
503
|
+
owner: prInfo.owner,
|
|
504
|
+
repo: prInfo.repo,
|
|
505
|
+
repository,
|
|
506
|
+
prNumber: prInfo.number,
|
|
507
|
+
onProgress: (progress) => {
|
|
508
|
+
if (progress.message) {
|
|
509
|
+
console.log(progress.message);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
repositoryPath = result.repositoryPath;
|
|
514
|
+
}
|
|
492
515
|
|
|
493
516
|
// Setup git worktree
|
|
494
517
|
console.log('Setting up git worktree...');
|
|
495
518
|
const worktreeManager = new GitWorktreeManager(db);
|
|
496
|
-
const worktreePath = await worktreeManager.createWorktreeForPR(prInfo, prData,
|
|
519
|
+
const worktreePath = await worktreeManager.createWorktreeForPR(prInfo, prData, repositoryPath);
|
|
497
520
|
|
|
498
521
|
// Generate unified diff
|
|
499
522
|
console.log('Generating unified diff...');
|
|
@@ -701,7 +724,6 @@ async function performHeadlessReview(args, config, db, flags, options) {
|
|
|
701
724
|
console.log('Fetching pull request data from GitHub...');
|
|
702
725
|
const prData = await githubClient.fetchPullRequest(prInfo.owner, prInfo.repo, prInfo.number);
|
|
703
726
|
|
|
704
|
-
const repository = normalizeRepository(prInfo.owner, prInfo.repo);
|
|
705
727
|
let worktreePath;
|
|
706
728
|
let diff;
|
|
707
729
|
let changedFiles;
|
|
@@ -709,6 +731,16 @@ async function performHeadlessReview(args, config, db, flags, options) {
|
|
|
709
731
|
// Determine working directory: --use-checkout uses current directory
|
|
710
732
|
if (flags.useCheckout) {
|
|
711
733
|
worktreePath = process.cwd();
|
|
734
|
+
|
|
735
|
+
// Verify cwd matches the target repository when using --use-checkout
|
|
736
|
+
const isMatchingRepo = await parser.isMatchingRepository(worktreePath, prInfo.owner, prInfo.repo);
|
|
737
|
+
if (!isMatchingRepo) {
|
|
738
|
+
throw new Error(
|
|
739
|
+
`--use-checkout requires running from a checkout of ${prInfo.owner}/${prInfo.repo}, ` +
|
|
740
|
+
`but current directory does not match. Either cd to the correct repository or remove --use-checkout.`
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
|
|
712
744
|
await registerRepositoryLocation(db, worktreePath, prInfo.owner, prInfo.repo);
|
|
713
745
|
console.log(`Using current checkout at ${worktreePath}`);
|
|
714
746
|
|
|
@@ -752,13 +784,37 @@ async function performHeadlessReview(args, config, db, flags, options) {
|
|
|
752
784
|
return result;
|
|
753
785
|
});
|
|
754
786
|
} else {
|
|
755
|
-
// Use worktree approach
|
|
787
|
+
// Use worktree approach - only use cwd if it matches the target repo
|
|
756
788
|
const currentDir = parser.getCurrentDirectory();
|
|
757
|
-
await
|
|
789
|
+
const isMatchingRepo = await parser.isMatchingRepository(currentDir, prInfo.owner, prInfo.repo);
|
|
790
|
+
|
|
791
|
+
let repositoryPath;
|
|
792
|
+
if (isMatchingRepo) {
|
|
793
|
+
// Current directory is a checkout of the target repository
|
|
794
|
+
repositoryPath = currentDir;
|
|
795
|
+
await registerRepositoryLocation(db, currentDir, prInfo.owner, prInfo.repo);
|
|
796
|
+
} else {
|
|
797
|
+
// Current directory is not the target repository - find or clone it
|
|
798
|
+
console.log(`Current directory is not a checkout of ${prInfo.owner}/${prInfo.repo}, locating repository...`);
|
|
799
|
+
const repository = normalizeRepository(prInfo.owner, prInfo.repo);
|
|
800
|
+
const result = await findRepositoryPath({
|
|
801
|
+
db,
|
|
802
|
+
owner: prInfo.owner,
|
|
803
|
+
repo: prInfo.repo,
|
|
804
|
+
repository,
|
|
805
|
+
prNumber: prInfo.number,
|
|
806
|
+
onProgress: (progress) => {
|
|
807
|
+
if (progress.message) {
|
|
808
|
+
console.log(progress.message);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
repositoryPath = result.repositoryPath;
|
|
813
|
+
}
|
|
758
814
|
|
|
759
815
|
console.log('Setting up git worktree...');
|
|
760
816
|
const worktreeManager = new GitWorktreeManager(db);
|
|
761
|
-
worktreePath = await worktreeManager.createWorktreeForPR(prInfo, prData,
|
|
817
|
+
worktreePath = await worktreeManager.createWorktreeForPR(prInfo, prData, repositoryPath);
|
|
762
818
|
|
|
763
819
|
console.log('Generating unified diff...');
|
|
764
820
|
diff = await worktreeManager.generateUnifiedDiff(worktreePath, prData);
|
package/src/routes/setup.js
CHANGED
package/src/routes/worktrees.js
CHANGED
package/src/setup/pr-setup.js
CHANGED
|
@@ -16,7 +16,7 @@ const { GitWorktreeManager } = require('../git/worktree');
|
|
|
16
16
|
const { GitHubClient } = require('../github/client');
|
|
17
17
|
const { normalizeRepository } = require('../utils/paths');
|
|
18
18
|
const { findMainGitRoot } = require('../local-review');
|
|
19
|
-
const { getConfigDir } = require('../config');
|
|
19
|
+
const { getConfigDir, getMonorepoPath } = require('../config');
|
|
20
20
|
const logger = require('../utils/logger');
|
|
21
21
|
const simpleGit = require('simple-git');
|
|
22
22
|
const fs = require('fs').promises;
|
|
@@ -188,6 +188,7 @@ async function registerRepositoryLocation(db, currentDir, owner, repo) {
|
|
|
188
188
|
* given owner/repo so that worktrees can be created from it.
|
|
189
189
|
*
|
|
190
190
|
* Tiers (in order of preference):
|
|
191
|
+
* -1. Explicit monorepo configuration (highest priority)
|
|
191
192
|
* 0. Known local path from repo_settings (registered by CLI or previous web UI)
|
|
192
193
|
* 1. Existing worktree for this repo (derive parent git root from it)
|
|
193
194
|
* 2. Cached clone at <configDir>/repos/<owner>/<repo>
|
|
@@ -199,25 +200,83 @@ async function registerRepositoryLocation(db, currentDir, owner, repo) {
|
|
|
199
200
|
* @param {string} params.repo - Repository name
|
|
200
201
|
* @param {string} params.repository - Normalized "owner/repo" string
|
|
201
202
|
* @param {number} params.prNumber - PR number (used for worktree lookup)
|
|
203
|
+
* @param {Object} [params.config] - Application config (used for monorepo path lookup)
|
|
202
204
|
* @param {Function} [params.onProgress] - Optional progress callback
|
|
203
|
-
* @returns {Promise<{ repositoryPath: string, knownPath: string|null }>}
|
|
205
|
+
* @returns {Promise<{ repositoryPath: string, knownPath: string|null, worktreeSourcePath: string|null }>}
|
|
206
|
+
* - repositoryPath: the main git root (bare repo or .git parent)
|
|
207
|
+
* - knownPath: the known path from database (if any)
|
|
208
|
+
* - worktreeSourcePath: path to use as cwd for `git worktree add` (may be a worktree with sparse-checkout)
|
|
204
209
|
*/
|
|
205
|
-
async function findRepositoryPath({ db, owner, repo, repository, prNumber, onProgress }) {
|
|
210
|
+
async function findRepositoryPath({ db, owner, repo, repository, prNumber, config, onProgress }) {
|
|
206
211
|
const worktreeManager = new GitWorktreeManager(db);
|
|
207
212
|
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
208
213
|
const worktreeRepo = new WorktreeRepository(db);
|
|
209
214
|
|
|
210
215
|
let repositoryPath = null;
|
|
216
|
+
let worktreeSourcePath = null; // Path to use as cwd for `git worktree add` (may differ from repositoryPath)
|
|
217
|
+
|
|
218
|
+
// ------------------------------------------------------------------
|
|
219
|
+
// Tier -1: Explicit monorepo configuration (highest priority)
|
|
220
|
+
// ------------------------------------------------------------------
|
|
221
|
+
const monorepoPath = config ? getMonorepoPath(config, repository) : null;
|
|
222
|
+
|
|
223
|
+
if (monorepoPath) {
|
|
224
|
+
// The configured path might be a worktree or a regular/bare repo.
|
|
225
|
+
// We need the main git root for creating new worktrees, but we also want to
|
|
226
|
+
// preserve the original path if it's a worktree so sparse-checkout is inherited.
|
|
227
|
+
// Wrap in try-catch since findMainGitRoot throws if path doesn't exist or isn't a git repo
|
|
228
|
+
try {
|
|
229
|
+
const resolvedPath = await findMainGitRoot(monorepoPath);
|
|
230
|
+
logger.debug(`Monorepo path ${monorepoPath} resolved to ${resolvedPath}`);
|
|
231
|
+
|
|
232
|
+
// Check if this is a valid git directory we can create worktrees from.
|
|
233
|
+
// It could be:
|
|
234
|
+
// 1. A regular repo (has .git directory)
|
|
235
|
+
// 2. A bare repo (is itself a git directory with HEAD, objects, refs)
|
|
236
|
+
// 3. A worktree (has .git file pointing to actual git dir)
|
|
237
|
+
const gitDirPath = path.join(resolvedPath, '.git');
|
|
238
|
+
const headPath = path.join(resolvedPath, 'HEAD');
|
|
239
|
+
|
|
240
|
+
const hasGitDir = await worktreeManager.pathExists(gitDirPath);
|
|
241
|
+
const hasHead = await worktreeManager.pathExists(headPath);
|
|
242
|
+
|
|
243
|
+
if (hasGitDir || hasHead) {
|
|
244
|
+
// Verify we can actually run git commands here
|
|
245
|
+
try {
|
|
246
|
+
const git = simpleGit(resolvedPath);
|
|
247
|
+
await git.revparse(['HEAD']);
|
|
248
|
+
repositoryPath = resolvedPath;
|
|
249
|
+
|
|
250
|
+
// If the configured path differs from the resolved path, it's likely a worktree.
|
|
251
|
+
// Use the original configured path as the source for worktree creation so
|
|
252
|
+
// sparse-checkout configuration is inherited.
|
|
253
|
+
if (monorepoPath !== resolvedPath) {
|
|
254
|
+
worktreeSourcePath = monorepoPath;
|
|
255
|
+
logger.info(`Using configured monorepo path at ${repositoryPath} (worktree source: ${worktreeSourcePath})`);
|
|
256
|
+
} else {
|
|
257
|
+
logger.info(`Using configured monorepo path at ${repositoryPath}`);
|
|
258
|
+
}
|
|
259
|
+
} catch (gitError) {
|
|
260
|
+
logger.warn(`Configured monorepo path ${monorepoPath} resolved to ${resolvedPath} but git commands fail: ${gitError.message}`);
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
logger.warn(`Configured monorepo path ${monorepoPath} resolved to ${resolvedPath} which has no .git directory or HEAD file`);
|
|
264
|
+
}
|
|
265
|
+
} catch (resolveError) {
|
|
266
|
+
logger.warn(`Configured monorepo path ${monorepoPath} does not exist or is not a git repository: ${resolveError.message}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
211
269
|
|
|
212
270
|
// ------------------------------------------------------------------
|
|
213
271
|
// Tier 0: Check known local path from repo_settings
|
|
214
272
|
// ------------------------------------------------------------------
|
|
215
273
|
const knownPath = await repoSettingsRepo.getLocalPath(repository);
|
|
216
274
|
|
|
217
|
-
if (knownPath && await worktreeManager.pathExists(knownPath)) {
|
|
275
|
+
if (!repositoryPath && knownPath && await worktreeManager.pathExists(knownPath)) {
|
|
218
276
|
try {
|
|
219
277
|
const git = simpleGit(knownPath);
|
|
220
|
-
|
|
278
|
+
// Use --git-dir instead of --is-inside-work-tree to support bare repos
|
|
279
|
+
await git.revparse(['--git-dir']);
|
|
221
280
|
repositoryPath = knownPath;
|
|
222
281
|
logger.info(`Using known repository location at ${repositoryPath}`);
|
|
223
282
|
} catch {
|
|
@@ -276,7 +335,7 @@ async function findRepositoryPath({ db, owner, repo, repository, prNumber, onPro
|
|
|
276
335
|
}
|
|
277
336
|
}
|
|
278
337
|
|
|
279
|
-
return { repositoryPath, knownPath };
|
|
338
|
+
return { repositoryPath, knownPath, worktreeSourcePath };
|
|
280
339
|
}
|
|
281
340
|
|
|
282
341
|
/**
|
|
@@ -292,10 +351,11 @@ async function findRepositoryPath({ db, owner, repo, repository, prNumber, onPro
|
|
|
292
351
|
* @param {string} params.repo - Repository name
|
|
293
352
|
* @param {number} params.prNumber - Pull request number
|
|
294
353
|
* @param {string} params.githubToken - GitHub PAT
|
|
354
|
+
* @param {Object} [params.config] - Application config (for monorepo path lookup)
|
|
295
355
|
* @param {Function} [params.onProgress] - Optional progress callback
|
|
296
356
|
* @returns {Promise<{ reviewUrl: string, title: string }>}
|
|
297
357
|
*/
|
|
298
|
-
async function setupPRReview({ db, owner, repo, prNumber, githubToken, onProgress }) {
|
|
358
|
+
async function setupPRReview({ db, owner, repo, prNumber, githubToken, config, onProgress }) {
|
|
299
359
|
const repository = normalizeRepository(owner, repo);
|
|
300
360
|
const progress = onProgress || (() => {});
|
|
301
361
|
|
|
@@ -321,12 +381,13 @@ async function setupPRReview({ db, owner, repo, prNumber, githubToken, onProgres
|
|
|
321
381
|
// Step: repo - Find (or clone) a local repository
|
|
322
382
|
// ------------------------------------------------------------------
|
|
323
383
|
progress({ step: 'repo', status: 'running', message: 'Locating repository...' });
|
|
324
|
-
const { repositoryPath, knownPath } = await findRepositoryPath({
|
|
384
|
+
const { repositoryPath, knownPath, worktreeSourcePath } = await findRepositoryPath({
|
|
325
385
|
db,
|
|
326
386
|
owner,
|
|
327
387
|
repo,
|
|
328
388
|
repository,
|
|
329
389
|
prNumber,
|
|
390
|
+
config,
|
|
330
391
|
onProgress: progress
|
|
331
392
|
});
|
|
332
393
|
progress({ step: 'repo', status: 'completed', message: `Repository located at ${repositoryPath}` });
|
|
@@ -337,9 +398,41 @@ async function setupPRReview({ db, owner, repo, prNumber, githubToken, onProgres
|
|
|
337
398
|
progress({ step: 'worktree', status: 'running', message: 'Setting up git worktree...' });
|
|
338
399
|
const worktreeManager = new GitWorktreeManager(db);
|
|
339
400
|
const prInfo = { owner, repo, number: prNumber };
|
|
340
|
-
|
|
401
|
+
// Use worktreeSourcePath as cwd for git worktree add (if available) to inherit sparse-checkout
|
|
402
|
+
const worktreePath = await worktreeManager.createWorktreeForPR(prInfo, prData, repositoryPath, { worktreeSourcePath });
|
|
341
403
|
progress({ step: 'worktree', status: 'completed', message: `Worktree created at ${worktreePath}` });
|
|
342
404
|
|
|
405
|
+
// ------------------------------------------------------------------
|
|
406
|
+
// Step: sparse - Expand sparse-checkout before generating diff
|
|
407
|
+
// ------------------------------------------------------------------
|
|
408
|
+
// IMPORTANT: Sparse-checkout expansion MUST happen before diff generation.
|
|
409
|
+
// In monorepo worktrees that inherit a sparse-checkout from the source
|
|
410
|
+
// worktree, the checkout may not include all directories touched by the PR.
|
|
411
|
+
// If we generate the diff first, files outside the sparse cone will be missing
|
|
412
|
+
// from the worktree, producing an incomplete or empty diff. Expanding the
|
|
413
|
+
// sparse-checkout ensures every PR-changed directory is present on disk so
|
|
414
|
+
// that `git diff` can read the actual file contents.
|
|
415
|
+
//
|
|
416
|
+
// NOTE: prData.changed_files is an INTEGER (count) from the GitHub pulls.get
|
|
417
|
+
// API, not an array. We must fetch the actual file list via pulls.listFiles.
|
|
418
|
+
if (prData.changed_files > 0) {
|
|
419
|
+
const isSparse = await worktreeManager.isSparseCheckoutEnabled(worktreePath);
|
|
420
|
+
if (isSparse) {
|
|
421
|
+
progress({ step: 'sparse', status: 'running', message: 'Expanding sparse-checkout for PR directories...' });
|
|
422
|
+
try {
|
|
423
|
+
const prFiles = await githubClient.fetchPullRequestFiles(owner, repo, prNumber);
|
|
424
|
+
const addedDirs = await worktreeManager.ensurePRDirectoriesInSparseCheckout(worktreePath, prFiles);
|
|
425
|
+
if (addedDirs.length > 0) {
|
|
426
|
+
logger.info(`Expanded sparse-checkout for PR directories: ${addedDirs.join(', ')}`);
|
|
427
|
+
}
|
|
428
|
+
progress({ step: 'sparse', status: 'completed', message: addedDirs.length > 0 ? `Expanded: ${addedDirs.join(', ')}` : 'No expansion needed' });
|
|
429
|
+
} catch (sparseError) {
|
|
430
|
+
logger.warn(`Sparse-checkout expansion failed (non-fatal): ${sparseError.message}`);
|
|
431
|
+
progress({ step: 'sparse', status: 'completed', message: `Sparse-checkout expansion skipped: ${sparseError.message}` });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
343
436
|
// ------------------------------------------------------------------
|
|
344
437
|
// Step: diff - Generate unified diff and changed file list
|
|
345
438
|
// ------------------------------------------------------------------
|