@in-the-loop-labs/pair-review 2.0.3 → 2.1.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": "2.0.3",
3
+ "version": "2.1.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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "2.0.3",
3
+ "version": "2.1.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": "2.0.3",
3
+ "version": "2.1.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",
package/src/config.js CHANGED
@@ -5,6 +5,7 @@ const os = require('os');
5
5
  const logger = require('./utils/logger');
6
6
 
7
7
  const CONFIG_DIR = path.join(os.homedir(), '.pair-review');
8
+ const DEFAULT_CHECKOUT_TIMEOUT_MS = 300000;
8
9
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
9
10
  const CONFIG_EXAMPLE_FILE = path.join(CONFIG_DIR, 'config.example.json');
10
11
  const PACKAGE_ROOT = path.join(__dirname, '..');
@@ -316,6 +317,81 @@ function getMonorepoPath(config, repository) {
316
317
  return null;
317
318
  }
318
319
 
320
+ /**
321
+ * Gets the configured checkout script for a monorepo repository
322
+ * @param {Object} config - Configuration object from loadConfig()
323
+ * @param {string} repository - Repository in "owner/repo" format
324
+ * @returns {string|null} - Checkout script path or null if not configured
325
+ */
326
+ function getMonorepoCheckoutScript(config, repository) {
327
+ const monorepoConfig = config.monorepos?.[repository];
328
+ return monorepoConfig?.checkout_script || null;
329
+ }
330
+
331
+ /**
332
+ * Gets the configured worktree directory for a monorepo repository
333
+ * @param {Object} config - Configuration object from loadConfig()
334
+ * @param {string} repository - Repository in "owner/repo" format
335
+ * @returns {string|null} - Expanded worktree directory path or null if not configured
336
+ */
337
+ function getMonorepoWorktreeDirectory(config, repository) {
338
+ const monorepoConfig = config.monorepos?.[repository];
339
+ if (monorepoConfig?.worktree_directory) {
340
+ return expandPath(monorepoConfig.worktree_directory);
341
+ }
342
+ return null;
343
+ }
344
+
345
+ /**
346
+ * Gets the configured worktree name template for a monorepo repository
347
+ * @param {Object} config - Configuration object from loadConfig()
348
+ * @param {string} repository - Repository in "owner/repo" format
349
+ * @returns {string|null} - Template string or null if not configured
350
+ */
351
+ function getMonorepoWorktreeNameTemplate(config, repository) {
352
+ const monorepoConfig = config.monorepos?.[repository];
353
+ return monorepoConfig?.worktree_name_template || null;
354
+ }
355
+
356
+ /**
357
+ * Gets the configured checkout script timeout for a monorepo repository
358
+ * @param {Object} config - Configuration object from loadConfig()
359
+ * @param {string} repository - Repository in "owner/repo" format
360
+ * @returns {number} - Timeout in milliseconds (default: 300000 = 5 minutes)
361
+ */
362
+ function getMonorepoCheckoutTimeout(config, repository) {
363
+ const monorepoConfig = config.monorepos?.[repository];
364
+ if (monorepoConfig?.checkout_timeout_seconds > 0) {
365
+ return monorepoConfig.checkout_timeout_seconds * 1000;
366
+ }
367
+ return DEFAULT_CHECKOUT_TIMEOUT_MS; // 5 minutes default
368
+ }
369
+
370
+ /**
371
+ * Resolves all monorepo worktree options for a repository into a single object.
372
+ * Composite helper that combines the individual getters into the shape expected
373
+ * by GitWorktreeManager and createWorktreeForPR.
374
+ *
375
+ * @param {Object} config - Configuration object from loadConfig()
376
+ * @param {string} repository - Repository in "owner/repo" format
377
+ * @returns {{ checkoutScript: string|null, checkoutTimeout: number, worktreeConfig: Object|null }}
378
+ */
379
+ function resolveMonorepoOptions(config, repository) {
380
+ const checkoutScript = getMonorepoCheckoutScript(config, repository);
381
+ const checkoutTimeout = getMonorepoCheckoutTimeout(config, repository);
382
+ const worktreeDirectory = getMonorepoWorktreeDirectory(config, repository);
383
+ const nameTemplate = getMonorepoWorktreeNameTemplate(config, repository);
384
+
385
+ let worktreeConfig = null;
386
+ if (worktreeDirectory || nameTemplate) {
387
+ worktreeConfig = {};
388
+ if (worktreeDirectory) worktreeConfig.worktreeBaseDir = worktreeDirectory;
389
+ if (nameTemplate) worktreeConfig.nameTemplate = nameTemplate;
390
+ }
391
+
392
+ return { checkoutScript, checkoutTimeout, worktreeConfig };
393
+ }
394
+
319
395
  /**
320
396
  * Resolves the database filename to use.
321
397
  * Priority:
@@ -358,6 +434,12 @@ module.exports = {
358
434
  showWelcomeMessage,
359
435
  expandPath,
360
436
  getMonorepoPath,
437
+ getMonorepoCheckoutScript,
438
+ getMonorepoWorktreeDirectory,
439
+ getMonorepoWorktreeNameTemplate,
440
+ getMonorepoCheckoutTimeout,
441
+ resolveMonorepoOptions,
361
442
  resolveDbName,
362
- warnIfDevModeWithoutDbName
443
+ warnIfDevModeWithoutDbName,
444
+ DEFAULT_CHECKOUT_TIMEOUT_MS
363
445
  };
@@ -3,10 +3,11 @@ const simpleGit = require('simple-git');
3
3
  const path = require('path');
4
4
  const fs = require('fs').promises;
5
5
  const os = require('os');
6
- const { getConfigDir } = require('../config');
6
+ const { getConfigDir, DEFAULT_CHECKOUT_TIMEOUT_MS } = require('../config');
7
7
  const { WorktreeRepository, generateWorktreeId } = require('../database');
8
8
  const { getGeneratedFilePatterns } = require('./gitattributes');
9
9
  const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('../utils/paths');
10
+ const { spawn, execSync } = require('child_process');
10
11
 
11
12
  /**
12
13
  * Git worktree manager for handling PR branch checkouts and diffs
@@ -15,13 +16,43 @@ class GitWorktreeManager {
15
16
  /**
16
17
  * Create a new GitWorktreeManager instance
17
18
  * @param {sqlite3.Database} [db] - Optional database instance for worktree tracking
19
+ * @param {Object} [options] - Optional settings
20
+ * @param {string} [options.worktreeBaseDir] - Custom base directory for worktrees
21
+ * @param {string} [options.nameTemplate] - Template for worktree directory names
22
+ * Supported variables: {id}, {pr_number}, {repo}, {owner}
23
+ * Default: '{id}' (preserves current behavior)
18
24
  */
19
- constructor(db = null) {
20
- this.worktreeBaseDir = path.join(getConfigDir(), 'worktrees');
25
+ constructor(db = null, options = {}) {
26
+ this.worktreeBaseDir = options.worktreeBaseDir || path.join(getConfigDir(), 'worktrees');
27
+ this.nameTemplate = options.nameTemplate || '{id}';
21
28
  this.db = db;
22
29
  this.worktreeRepo = db ? new WorktreeRepository(db) : null;
23
30
  }
24
31
 
32
+ /**
33
+ * Apply the name template to generate a worktree directory name
34
+ * @param {Object} context - Template context variables
35
+ * @param {string} context.id - Random worktree ID
36
+ * @param {number} [context.prNumber] - PR number
37
+ * @param {string} [context.repo] - Repository name
38
+ * @param {string} [context.owner] - Repository owner
39
+ * @returns {string} Resolved directory name
40
+ */
41
+ applyNameTemplate(context) {
42
+ let name = this.nameTemplate;
43
+ name = name.replace(/\{id\}/g, context.id);
44
+ if (context.prNumber !== undefined) {
45
+ name = name.replace(/\{pr_number\}/g, String(context.prNumber));
46
+ }
47
+ if (context.repo) {
48
+ name = name.replace(/\{repo\}/g, context.repo);
49
+ }
50
+ if (context.owner) {
51
+ name = name.replace(/\{owner\}/g, context.owner);
52
+ }
53
+ return name;
54
+ }
55
+
25
56
  /**
26
57
  * Resolve which git remote points to the given repository URLs.
27
58
  * Compares normalized URLs against all configured remotes. If no match is
@@ -129,6 +160,65 @@ class GitWorktreeManager {
129
160
  return this.resolveRemoteForRepo(git, cloneUrl, sshUrl);
130
161
  }
131
162
 
163
+ /**
164
+ * Execute a user-provided checkout script in the worktree.
165
+ * The script receives PR context as environment variables and is responsible
166
+ * for configuring sparse-checkout (or any other worktree setup).
167
+ *
168
+ * @param {string} script - Script path or command to execute
169
+ * @param {string} worktreePath - Path to the worktree (used as cwd)
170
+ * @param {Object} env - Environment variables to pass (BASE_BRANCH, HEAD_BRANCH, etc.)
171
+ * @param {number} [timeout=DEFAULT_CHECKOUT_TIMEOUT_MS] - Timeout in milliseconds (default: 5 minutes)
172
+ * @returns {Promise<{stdout: string, stderr: string}>} Script output
173
+ */
174
+ async executeCheckoutScript(script, worktreePath, env, timeout = DEFAULT_CHECKOUT_TIMEOUT_MS) {
175
+ return new Promise((resolve, reject) => {
176
+ const child = spawn(script, [], {
177
+ cwd: worktreePath,
178
+ shell: true,
179
+ env: { ...process.env, ...env },
180
+ stdio: ['ignore', 'pipe', 'pipe']
181
+ });
182
+
183
+ let stdout = '';
184
+ let stderr = '';
185
+
186
+ child.stdout.on('data', (data) => {
187
+ const chunk = data.toString();
188
+ stdout += chunk;
189
+ process.stdout.write(chunk);
190
+ });
191
+ child.stderr.on('data', (data) => {
192
+ const chunk = data.toString();
193
+ stderr += chunk;
194
+ process.stderr.write(chunk);
195
+ });
196
+
197
+ const timer = setTimeout(() => {
198
+ child.kill('SIGTERM');
199
+ reject(new Error(`Checkout script timed out after ${timeout}ms.\nstdout: ${stdout}\nstderr: ${stderr}`));
200
+ }, timeout);
201
+
202
+ child.on('error', (err) => {
203
+ clearTimeout(timer);
204
+ if (err.code === 'ENOENT') {
205
+ reject(new Error(`Checkout script not found: ${script}`));
206
+ } else {
207
+ reject(new Error(`Checkout script failed to start: ${err.message}`));
208
+ }
209
+ });
210
+
211
+ child.on('close', (code) => {
212
+ clearTimeout(timer);
213
+ if (code === 0) {
214
+ resolve({ stdout, stderr });
215
+ } else {
216
+ reject(new Error(`Checkout script exited with code ${code}.\nstdout: ${stdout}\nstderr: ${stderr}`));
217
+ }
218
+ });
219
+ });
220
+ }
221
+
132
222
  /**
133
223
  * Create a git worktree for a PR and checkout to the PR head commit
134
224
  * @param {Object} prInfo - PR information { owner, repo, number }
@@ -137,10 +227,14 @@ class GitWorktreeManager {
137
227
  * @param {Object} [options] - Optional settings
138
228
  * @param {string} [options.worktreeSourcePath] - Path to use as cwd for git worktree add
139
229
  * (to inherit sparse-checkout from an existing worktree). Falls back to repositoryPath.
230
+ * @param {string} [options.checkoutScript] - Path to a script that configures sparse-checkout in the worktree.
231
+ * When set, worktree is created with --no-checkout from the main git root (no sparse-checkout inheritance),
232
+ * and the script is executed before checkout with PR context as environment variables.
233
+ * @param {number} [options.checkoutTimeout] - Timeout in ms for checkout script (default: 300000 = 5 minutes)
140
234
  * @returns {Promise<string>} Path to created worktree
141
235
  */
142
236
  async createWorktreeForPR(prInfo, prData, repositoryPath, options = {}) {
143
- const { worktreeSourcePath } = options;
237
+ const { worktreeSourcePath, checkoutScript, checkoutTimeout } = options;
144
238
  // Check if worktree already exists in DB
145
239
  const repository = normalizeRepository(prInfo.owner, prInfo.repo);
146
240
  let worktreePath;
@@ -157,7 +251,7 @@ class GitWorktreeManager {
157
251
  // Check if the directory still exists on disk
158
252
  const directoryExists = await this.pathExists(worktreePath);
159
253
 
160
- if (directoryExists) {
254
+ if (directoryExists && await this.isValidGitWorktree(worktreePath)) {
161
255
  // Try to reuse existing worktree by refreshing it
162
256
  console.log(`Found existing worktree for PR #${prInfo.number} at ${worktreePath}`);
163
257
  try {
@@ -170,6 +264,8 @@ class GitWorktreeManager {
170
264
  // For other errors, log and fall through to recreate
171
265
  console.log(`Could not refresh existing worktree, will recreate: ${refreshError.message}`);
172
266
  }
267
+ } else if (directoryExists) {
268
+ console.log(`Worktree directory at ${worktreePath} is not a valid git worktree, will recreate`);
173
269
  } else {
174
270
  console.log(`Worktree directory no longer exists at ${worktreePath}, will recreate`);
175
271
  }
@@ -207,9 +303,15 @@ class GitWorktreeManager {
207
303
  }
208
304
  }
209
305
 
210
- // Generate new random ID for worktree directory
306
+ // Generate new random ID for worktree directory and apply name template
211
307
  const worktreeId = generateWorktreeId();
212
- worktreePath = path.join(this.worktreeBaseDir, worktreeId);
308
+ const worktreeDirName = this.applyNameTemplate({
309
+ id: worktreeId,
310
+ prNumber: prInfo.number,
311
+ repo: prInfo.repo,
312
+ owner: prInfo.owner
313
+ });
314
+ worktreePath = path.join(this.worktreeBaseDir, worktreeDirName);
213
315
  }
214
316
 
215
317
  try {
@@ -243,23 +345,39 @@ class GitWorktreeManager {
243
345
  }
244
346
  }
245
347
 
246
- // Create worktree and checkout to base branch
247
- // Use worktreeSourcePath as cwd if provided (to inherit sparse-checkout from existing worktree)
248
- const worktreeAddGit = worktreeSourcePath ? simpleGit(worktreeSourcePath) : git;
249
- if (worktreeSourcePath) {
250
- console.log(`Creating worktree at ${worktreePath} from ${prData.base_branch} (inheriting sparse-checkout from ${worktreeSourcePath})...`);
348
+ // Create worktree strategy depends on whether a checkout script is configured
349
+ if (checkoutScript) {
350
+ // With checkout_script: create worktree with --no-checkout from main git root.
351
+ // The script will configure sparse-checkout before files are populated.
352
+ console.log(`Creating worktree at ${worktreePath} from ${prData.base_branch} (--no-checkout, script will configure sparse-checkout)...`);
353
+ try {
354
+ await git.raw(['worktree', 'add', '--no-checkout', worktreePath, `${remote}/${prData.base_branch}`]);
355
+ } catch (worktreeError) {
356
+ if (worktreeError.message.includes('already registered')) {
357
+ console.log('Worktree already registered, trying with --force...');
358
+ await git.raw(['worktree', 'add', '--force', '--no-checkout', worktreePath, `${remote}/${prData.base_branch}`]);
359
+ } else {
360
+ throw worktreeError;
361
+ }
362
+ }
251
363
  } else {
252
- console.log(`Creating worktree at ${worktreePath} from ${prData.base_branch}...`);
253
- }
254
- try {
255
- await worktreeAddGit.raw(['worktree', 'add', worktreePath, `${remote}/${prData.base_branch}`]);
256
- } catch (worktreeError) {
257
- // If worktree creation fails due to existing registration, try with --force
258
- if (worktreeError.message.includes('already registered')) {
259
- console.log('Worktree already registered, trying with --force...');
260
- await worktreeAddGit.raw(['worktree', 'add', '--force', worktreePath, `${remote}/${prData.base_branch}`]);
364
+ // Without checkout_script: use worktreeSourcePath as cwd if provided
365
+ // (to inherit sparse-checkout from existing worktree)
366
+ const worktreeAddGit = worktreeSourcePath ? simpleGit(worktreeSourcePath) : git;
367
+ if (worktreeSourcePath) {
368
+ console.log(`Creating worktree at ${worktreePath} from ${prData.base_branch} (inheriting sparse-checkout from ${worktreeSourcePath})...`);
261
369
  } else {
262
- throw worktreeError;
370
+ console.log(`Creating worktree at ${worktreePath} from ${prData.base_branch}...`);
371
+ }
372
+ try {
373
+ await worktreeAddGit.raw(['worktree', 'add', worktreePath, `${remote}/${prData.base_branch}`]);
374
+ } catch (worktreeError) {
375
+ if (worktreeError.message.includes('already registered')) {
376
+ console.log('Worktree already registered, trying with --force...');
377
+ await worktreeAddGit.raw(['worktree', 'add', '--force', worktreePath, `${remote}/${prData.base_branch}`]);
378
+ } else {
379
+ throw worktreeError;
380
+ }
263
381
  }
264
382
  }
265
383
 
@@ -280,6 +398,35 @@ class GitWorktreeManager {
280
398
  console.log(`Fetching PR #${prInfo.number} head...`);
281
399
  await worktreeGit.fetch([remote, `+refs/pull/${prInfo.number}/head:refs/remotes/${remote}/pr-${prInfo.number}`]);
282
400
 
401
+ // Execute checkout script if configured (before checkout so sparse-checkout is set up)
402
+ if (checkoutScript) {
403
+ // Fetch the actual head branch by name (for checkout scripts that expect branch refs)
404
+ // This may fail for fork PRs where the branch is in a different repo - that's okay
405
+ if (prData.head_branch) {
406
+ try {
407
+ console.log(`Fetching head branch ${prData.head_branch}...`);
408
+ await worktreeGit.fetch([remote, `+refs/heads/${prData.head_branch}:refs/remotes/${remote}/${prData.head_branch}`]);
409
+ // Create/update a local branch pointing to the fetched ref so tooling can reference it by name
410
+ await worktreeGit.branch(['-f', prData.head_branch, `${remote}/${prData.head_branch}`]);
411
+ } catch (branchFetchError) {
412
+ // Expected for fork PRs - the branch exists in the fork, not the base repo
413
+ console.log(`Could not fetch head branch (may be from a fork): ${branchFetchError.message}`);
414
+ }
415
+ }
416
+
417
+ console.log(`Executing checkout script: ${checkoutScript}`);
418
+ const scriptEnv = {
419
+ BASE_BRANCH: prData.base_branch,
420
+ HEAD_BRANCH: prData.head_branch,
421
+ BASE_SHA: prData.base_sha,
422
+ HEAD_SHA: prData.head_sha,
423
+ PR_NUMBER: String(prInfo.number),
424
+ WORKTREE_PATH: worktreePath
425
+ };
426
+ await this.executeCheckoutScript(checkoutScript, worktreePath, scriptEnv, checkoutTimeout);
427
+ console.log('Checkout script completed successfully');
428
+ }
429
+
283
430
  // Checkout to PR head commit
284
431
  console.log(`Checking out to PR head commit ${prData.head_sha}...`);
285
432
  await worktreeGit.checkout([`${remote}/pr-${prInfo.number}`]);
@@ -596,7 +743,6 @@ class GitWorktreeManager {
596
743
  await fs.rm(dirPath, { recursive: true, force: true });
597
744
  } catch (error) {
598
745
  // Fallback for older Node.js versions
599
- const { execSync } = require('child_process');
600
746
  if (process.platform === 'win32') {
601
747
  execSync(`rmdir /s /q "${dirPath}"`, { stdio: 'ignore' });
602
748
  } else {
package/src/main.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-or-later
2
2
  const fs = require('fs');
3
- const { loadConfig, getConfigDir, getGitHubToken, showWelcomeMessage, resolveDbName } = require('./config');
3
+ const { loadConfig, getConfigDir, getGitHubToken, showWelcomeMessage, resolveDbName, resolveMonorepoOptions } = require('./config');
4
4
  const { initializeDatabase, run, queryOne, query, migrateExistingWorktrees, WorktreeRepository, ReviewRepository, RepoSettingsRepository, GitHubReviewRepository } = require('./database');
5
5
  const { PRArgumentParser } = require('./github/parser');
6
6
  const { GitHubClient } = require('./github/client');
@@ -495,23 +495,35 @@ async function handlePullRequest(args, config, db, flags = {}) {
495
495
  // Determine repository path: only use cwd if it matches the target repo
496
496
  const currentDir = parser.getCurrentDirectory();
497
497
  const isMatchingRepo = await parser.isMatchingRepository(currentDir, prInfo.owner, prInfo.repo);
498
+ const repository = normalizeRepository(prInfo.owner, prInfo.repo);
498
499
 
499
500
  let repositoryPath;
501
+ let worktreeSourcePath;
502
+ let checkoutScript;
503
+ let checkoutTimeout;
504
+ let worktreeConfig = null;
500
505
  if (isMatchingRepo) {
501
506
  // Current directory is a checkout of the target repository
502
507
  repositoryPath = currentDir;
503
508
  // Register the known repository location for future web UI usage
504
509
  await registerRepositoryLocation(db, currentDir, prInfo.owner, prInfo.repo);
510
+
511
+ // Resolve monorepo config options (checkout_script, worktree_directory, worktree_name_template)
512
+ // even when running from inside the target repo, so they are not silently ignored.
513
+ const resolved = resolveMonorepoOptions(config, repository);
514
+ checkoutScript = resolved.checkoutScript;
515
+ checkoutTimeout = resolved.checkoutTimeout;
516
+ worktreeConfig = resolved.worktreeConfig;
505
517
  } else {
506
518
  // Current directory is not the target repository - find or clone it
507
519
  console.log(`Current directory is not a checkout of ${prInfo.owner}/${prInfo.repo}, locating repository...`);
508
- const repository = normalizeRepository(prInfo.owner, prInfo.repo);
509
520
  const result = await findRepositoryPath({
510
521
  db,
511
522
  owner: prInfo.owner,
512
523
  repo: prInfo.repo,
513
524
  repository,
514
525
  prNumber: prInfo.number,
526
+ config,
515
527
  onProgress: (progress) => {
516
528
  if (progress.message) {
517
529
  console.log(progress.message);
@@ -519,12 +531,20 @@ async function handlePullRequest(args, config, db, flags = {}) {
519
531
  }
520
532
  });
521
533
  repositoryPath = result.repositoryPath;
534
+ worktreeSourcePath = result.worktreeSourcePath;
535
+ checkoutScript = result.checkoutScript;
536
+ checkoutTimeout = result.checkoutTimeout;
537
+ worktreeConfig = result.worktreeConfig;
522
538
  }
523
539
 
524
540
  // Setup git worktree
525
541
  console.log('Setting up git worktree...');
526
- const worktreeManager = new GitWorktreeManager(db);
527
- const worktreePath = await worktreeManager.createWorktreeForPR(prInfo, prData, repositoryPath);
542
+ const worktreeManager = new GitWorktreeManager(db, worktreeConfig || {});
543
+ const worktreePath = await worktreeManager.createWorktreeForPR(prInfo, prData, repositoryPath, {
544
+ worktreeSourcePath,
545
+ checkoutScript,
546
+ checkoutTimeout
547
+ });
528
548
 
529
549
  // Generate unified diff
530
550
  console.log('Generating unified diff...');
@@ -784,10 +804,21 @@ async function performHeadlessReview(args, config, db, flags, options) {
784
804
  const isMatchingRepo = await parser.isMatchingRepository(currentDir, prInfo.owner, prInfo.repo);
785
805
 
786
806
  let repositoryPath;
807
+ let worktreeSourcePath;
808
+ let checkoutScript;
809
+ let checkoutTimeout;
810
+ let worktreeConfig = null;
787
811
  if (isMatchingRepo) {
788
812
  // Current directory is a checkout of the target repository
789
813
  repositoryPath = currentDir;
790
814
  await registerRepositoryLocation(db, currentDir, prInfo.owner, prInfo.repo);
815
+
816
+ // Resolve monorepo config options (checkout_script, worktree_directory, worktree_name_template)
817
+ // even when running from inside the target repo, so they are not silently ignored.
818
+ const resolved = resolveMonorepoOptions(config, repository);
819
+ checkoutScript = resolved.checkoutScript;
820
+ checkoutTimeout = resolved.checkoutTimeout;
821
+ worktreeConfig = resolved.worktreeConfig;
791
822
  } else {
792
823
  // Current directory is not the target repository - find or clone it
793
824
  console.log(`Current directory is not a checkout of ${prInfo.owner}/${prInfo.repo}, locating repository...`);
@@ -797,6 +828,7 @@ async function performHeadlessReview(args, config, db, flags, options) {
797
828
  repo: prInfo.repo,
798
829
  repository,
799
830
  prNumber: prInfo.number,
831
+ config,
800
832
  onProgress: (progress) => {
801
833
  if (progress.message) {
802
834
  console.log(progress.message);
@@ -804,11 +836,19 @@ async function performHeadlessReview(args, config, db, flags, options) {
804
836
  }
805
837
  });
806
838
  repositoryPath = result.repositoryPath;
839
+ worktreeSourcePath = result.worktreeSourcePath;
840
+ checkoutScript = result.checkoutScript;
841
+ checkoutTimeout = result.checkoutTimeout;
842
+ worktreeConfig = result.worktreeConfig;
807
843
  }
808
844
 
809
845
  console.log('Setting up git worktree...');
810
- const worktreeManager = new GitWorktreeManager(db);
811
- worktreePath = await worktreeManager.createWorktreeForPR(prInfo, prData, repositoryPath);
846
+ const worktreeManager = new GitWorktreeManager(db, worktreeConfig || {});
847
+ worktreePath = await worktreeManager.createWorktreeForPR(prInfo, prData, repositoryPath, {
848
+ worktreeSourcePath,
849
+ checkoutScript,
850
+ checkoutTimeout
851
+ });
812
852
 
813
853
  console.log('Generating unified diff...');
814
854
  diff = await worktreeManager.generateUnifiedDiff(worktreePath, prData);
@@ -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, getMonorepoPath } = require('../config');
19
+ const { getConfigDir, getMonorepoPath, resolveMonorepoOptions, DEFAULT_CHECKOUT_TIMEOUT_MS } = require('../config');
20
20
  const logger = require('../utils/logger');
21
21
  const simpleGit = require('simple-git');
22
22
  const fs = require('fs').promises;
@@ -202,10 +202,13 @@ async function registerRepositoryLocation(db, currentDir, owner, repo) {
202
202
  * @param {number} params.prNumber - PR number (used for worktree lookup)
203
203
  * @param {Object} [params.config] - Application config (used for monorepo path lookup)
204
204
  * @param {Function} [params.onProgress] - Optional progress callback
205
- * @returns {Promise<{ repositoryPath: string, knownPath: string|null, worktreeSourcePath: string|null }>}
205
+ * @returns {Promise<{ repositoryPath: string, knownPath: string|null, worktreeSourcePath: string|null, checkoutScript: string|null, checkoutTimeout: number, worktreeConfig: Object|null }>}
206
206
  * - repositoryPath: the main git root (bare repo or .git parent)
207
207
  * - knownPath: the known path from database (if any)
208
208
  * - worktreeSourcePath: path to use as cwd for `git worktree add` (may be a worktree with sparse-checkout)
209
+ * - checkoutScript: path to the checkout script (if configured)
210
+ * - checkoutTimeout: timeout in ms for checkout script (default: 300000 = 5 minutes)
211
+ * - worktreeConfig: { worktreeBaseDir, nameTemplate } if configured, null otherwise
209
212
  */
210
213
  async function findRepositoryPath({ db, owner, repo, repository, prNumber, config, onProgress }) {
211
214
  const worktreeManager = new GitWorktreeManager(db);
@@ -267,6 +270,19 @@ async function findRepositoryPath({ db, owner, repo, repository, prNumber, confi
267
270
  }
268
271
  }
269
272
 
273
+ // ------------------------------------------------------------------
274
+ // Resolve monorepo worktree options (checkout_script, worktree_directory, worktree_name_template)
275
+ // ------------------------------------------------------------------
276
+ const resolved = config ? resolveMonorepoOptions(config, repository) : { checkoutScript: null, checkoutTimeout: DEFAULT_CHECKOUT_TIMEOUT_MS, worktreeConfig: null };
277
+ const { checkoutScript, checkoutTimeout, worktreeConfig } = resolved;
278
+
279
+ // When a checkout script is configured, null out worktreeSourcePath —
280
+ // the script handles all sparse-checkout setup, so we don't want to
281
+ // inherit from an existing worktree.
282
+ if (checkoutScript) {
283
+ worktreeSourcePath = null;
284
+ }
285
+
270
286
  // ------------------------------------------------------------------
271
287
  // Tier 0: Check known local path from repo_settings
272
288
  // ------------------------------------------------------------------
@@ -335,7 +351,7 @@ async function findRepositoryPath({ db, owner, repo, repository, prNumber, confi
335
351
  }
336
352
  }
337
353
 
338
- return { repositoryPath, knownPath, worktreeSourcePath };
354
+ return { repositoryPath, knownPath, worktreeSourcePath, checkoutScript, checkoutTimeout, worktreeConfig };
339
355
  }
340
356
 
341
357
  /**
@@ -381,7 +397,7 @@ async function setupPRReview({ db, owner, repo, prNumber, githubToken, config, o
381
397
  // Step: repo - Find (or clone) a local repository
382
398
  // ------------------------------------------------------------------
383
399
  progress({ step: 'repo', status: 'running', message: 'Locating repository...' });
384
- const { repositoryPath, knownPath, worktreeSourcePath } = await findRepositoryPath({
400
+ const { repositoryPath, knownPath, worktreeSourcePath, checkoutScript, checkoutTimeout, worktreeConfig } = await findRepositoryPath({
385
401
  db,
386
402
  owner,
387
403
  repo,
@@ -396,10 +412,10 @@ async function setupPRReview({ db, owner, repo, prNumber, githubToken, config, o
396
412
  // Step: worktree - Create git worktree for the PR
397
413
  // ------------------------------------------------------------------
398
414
  progress({ step: 'worktree', status: 'running', message: 'Setting up git worktree...' });
399
- const worktreeManager = new GitWorktreeManager(db);
415
+ const worktreeManager = new GitWorktreeManager(db, worktreeConfig || {});
400
416
  const prInfo = { owner, repo, number: prNumber };
401
417
  // Use worktreeSourcePath as cwd for git worktree add (if available) to inherit sparse-checkout
402
- const worktreePath = await worktreeManager.createWorktreeForPR(prInfo, prData, repositoryPath, { worktreeSourcePath });
418
+ const worktreePath = await worktreeManager.createWorktreeForPR(prInfo, prData, repositoryPath, { worktreeSourcePath, checkoutScript, checkoutTimeout });
403
419
  progress({ step: 'worktree', status: 'completed', message: `Worktree created at ${worktreePath}` });
404
420
 
405
421
  // ------------------------------------------------------------------
@@ -415,7 +431,11 @@ async function setupPRReview({ db, owner, repo, prNumber, githubToken, config, o
415
431
  //
416
432
  // NOTE: prData.changed_files is an INTEGER (count) from the GitHub pulls.get
417
433
  // API, not an array. We must fetch the actual file list via pulls.listFiles.
418
- if (prData.changed_files > 0) {
434
+ if (checkoutScript) {
435
+ // checkout_script handles all sparse-checkout setup — skip built-in expansion
436
+ logger.info('Skipping built-in sparse-checkout expansion (checkout_script configured)');
437
+ progress({ step: 'sparse', status: 'completed', message: 'Sparse-checkout managed by checkout_script' });
438
+ } else if (prData.changed_files > 0) {
419
439
  const isSparse = await worktreeManager.isSparseCheckoutEnabled(worktreePath);
420
440
  if (isSparse) {
421
441
  progress({ step: 'sparse', status: 'running', message: 'Expanding sparse-checkout for PR directories...' });