@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "1.2.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.2.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.2.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 }. <<<**
@@ -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,
@@ -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
- if (code === 0 && stdout.includes('codex')) {
638
+ clearTimeout(availabilityTimeout);
639
+ if (code === 0) {
620
640
  logger.info(`Codex CLI available: ${stdout.trim()}`);
621
641
  resolve(true);
622
642
  } else {
623
- logger.warn('Codex CLI not available or returned unexpected output');
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
  };
@@ -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
- console.log(`Creating worktree at ${worktreePath} from ${prData.base_branch}...`);
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 git.raw(['worktree', 'add', worktreePath, `origin/${prData.base_branch}`]);
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 git.raw(['worktree', 'add', '--force', worktreePath, `origin/${prData.base_branch}`]);
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 };
@@ -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
@@ -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
@@ -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
- return mainRepoRoot;
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
- // Get current repository path
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
- // Register the known repository location for future web UI usage
491
- await registerRepositoryLocation(db, currentDir, prInfo.owner, prInfo.repo);
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, currentDir);
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 registerRepositoryLocation(db, currentDir, prInfo.owner, prInfo.repo);
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, currentDir);
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);
@@ -113,6 +113,7 @@ router.post('/api/setup/pr/:owner/:repo/:number', async (req, res) => {
113
113
  repo,
114
114
  prNumber,
115
115
  githubToken,
116
+ config,
116
117
  onProgress: (progress) => {
117
118
  sendSetupSSE(setupId, 'step', progress);
118
119
  }
@@ -62,6 +62,7 @@ router.post('/api/worktrees/create', async (req, res) => {
62
62
  repo,
63
63
  prNumber: parsedPrNumber,
64
64
  githubToken,
65
+ config,
65
66
  onProgress: (progress) => {
66
67
  logger.info(`[Setup] ${progress.step}: ${progress.message}`);
67
68
  }
@@ -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
- await git.revparse(['--is-inside-work-tree']);
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
- const worktreePath = await worktreeManager.createWorktreeForPR(prInfo, prData, repositoryPath);
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
  // ------------------------------------------------------------------