@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 +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/src/config.js +83 -1
- package/src/git/worktree.js +169 -23
- package/src/main.js +46 -6
- package/src/setup/pr-setup.js +27 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pair-review",
|
|
3
|
-
"version": "2.0
|
|
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
|
+
"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
|
};
|
package/src/git/worktree.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
console.log(`Creating worktree at ${worktreePath} from ${prData.base_branch} (
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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);
|
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, 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 (
|
|
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...' });
|