@in-the-loop-labs/pair-review 3.2.3 → 3.3.1
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/README.md +7 -6
- package/package.json +5 -4
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/repo-settings.css +347 -0
- package/public/index.html +46 -9
- package/public/js/components/AIPanel.js +79 -37
- package/public/js/components/DiffOptionsDropdown.js +84 -1
- package/public/js/index.js +31 -6
- package/public/js/pr.js +22 -0
- package/public/js/repo-settings.js +334 -6
- package/public/repo-settings.html +29 -0
- package/src/ai/analyzer.js +28 -19
- package/src/ai/claude-cli.js +2 -0
- package/src/ai/claude-provider.js +4 -1
- package/src/ai/provider.js +7 -6
- package/src/chat/session-manager.js +6 -3
- package/src/config.js +230 -38
- package/src/database.js +766 -38
- package/src/git/worktree-pool-lifecycle.js +679 -0
- package/src/git/worktree-pool-usage.js +216 -0
- package/src/git/worktree.js +157 -32
- package/src/main.js +185 -26
- package/src/routes/analyses.js +48 -26
- package/src/routes/chat.js +27 -3
- package/src/routes/config.js +17 -5
- package/src/routes/executable-analysis.js +38 -19
- package/src/routes/local.js +19 -6
- package/src/routes/mcp.js +13 -2
- package/src/routes/pr.js +72 -29
- package/src/routes/setup.js +41 -4
- package/src/routes/stack-analysis.js +29 -10
- package/src/routes/worktrees.js +294 -9
- package/src/server.js +20 -3
- package/src/setup/pr-setup.js +161 -27
- package/src/ws/server.js +51 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const logger = require('../utils/logger');
|
|
5
|
+
|
|
6
|
+
const GRACE_PERIOD_MS = 30_000; // 30 seconds after last WS disconnect
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* In-memory tracker that determines whether a pool worktree is "in use".
|
|
10
|
+
*
|
|
11
|
+
* A pool worktree is considered in-use when any of:
|
|
12
|
+
* - At least one WebSocket session is subscribed to its review topic
|
|
13
|
+
* - An AI analysis is running against it
|
|
14
|
+
* - The grace period after the last session disconnect hasn't expired
|
|
15
|
+
*
|
|
16
|
+
* When a worktree becomes idle (all of the above are false), the `onIdle`
|
|
17
|
+
* callback fires so the pool manager can mark it available.
|
|
18
|
+
*/
|
|
19
|
+
class WorktreePoolUsageTracker {
|
|
20
|
+
constructor() {
|
|
21
|
+
/** @type {Map<string, Set<string>>} worktreeId -> Set of active session keys */
|
|
22
|
+
this._sessions = new Map();
|
|
23
|
+
/** @type {Map<string, Set<string>>} worktreeId -> Set of active analysis IDs */
|
|
24
|
+
this._analyses = new Map();
|
|
25
|
+
/** @type {Map<string, NodeJS.Timeout>} worktreeId -> grace period timer */
|
|
26
|
+
this._graceTimers = new Map();
|
|
27
|
+
/** @type {Function|null} Callback when a worktree becomes idle: (worktreeId) => void */
|
|
28
|
+
this.onIdle = null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Register an active WebSocket session for a worktree.
|
|
33
|
+
* Clears any pending grace-period timer.
|
|
34
|
+
* @param {string} worktreeId - Pool worktree ID
|
|
35
|
+
* @param {string} sessionKey - Unique key for this WS connection
|
|
36
|
+
*/
|
|
37
|
+
addSession(worktreeId, sessionKey) {
|
|
38
|
+
if (!this._sessions.has(worktreeId)) {
|
|
39
|
+
this._sessions.set(worktreeId, new Set());
|
|
40
|
+
}
|
|
41
|
+
this._sessions.get(worktreeId).add(sessionKey);
|
|
42
|
+
|
|
43
|
+
// Cancel any pending grace timer
|
|
44
|
+
const timer = this._graceTimers.get(worktreeId);
|
|
45
|
+
if (timer) {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
this._graceTimers.delete(worktreeId);
|
|
48
|
+
logger.debug(`Grace period cancelled for pool worktree ${worktreeId} — new session connected`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Remove a WebSocket session. Starts grace period if no sessions remain
|
|
54
|
+
* and no analyses are running.
|
|
55
|
+
* @param {string} worktreeId
|
|
56
|
+
* @param {string} sessionKey
|
|
57
|
+
*/
|
|
58
|
+
removeSession(worktreeId, sessionKey) {
|
|
59
|
+
const sessions = this._sessions.get(worktreeId);
|
|
60
|
+
if (!sessions) return;
|
|
61
|
+
|
|
62
|
+
sessions.delete(sessionKey);
|
|
63
|
+
if (sessions.size === 0) {
|
|
64
|
+
this._sessions.delete(worktreeId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this._checkIdle(worktreeId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Register an active analysis for a worktree.
|
|
72
|
+
* Clears any pending grace-period timer.
|
|
73
|
+
* @param {string} worktreeId - Pool worktree ID
|
|
74
|
+
* @param {string} analysisId - Unique analysis identifier
|
|
75
|
+
*/
|
|
76
|
+
addAnalysis(worktreeId, analysisId) {
|
|
77
|
+
if (!this._analyses.has(worktreeId)) {
|
|
78
|
+
this._analyses.set(worktreeId, new Set());
|
|
79
|
+
}
|
|
80
|
+
this._analyses.get(worktreeId).add(analysisId);
|
|
81
|
+
|
|
82
|
+
// Cancel any pending grace timer
|
|
83
|
+
const timer = this._graceTimers.get(worktreeId);
|
|
84
|
+
if (timer) {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
this._graceTimers.delete(worktreeId);
|
|
87
|
+
logger.debug(`Grace period cancelled for pool worktree ${worktreeId} — new analysis started`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Remove an active analysis hold.
|
|
93
|
+
* @param {string} worktreeId
|
|
94
|
+
* @param {string} analysisId
|
|
95
|
+
*/
|
|
96
|
+
removeAnalysis(worktreeId, analysisId) {
|
|
97
|
+
const analyses = this._analyses.get(worktreeId);
|
|
98
|
+
if (!analyses) return;
|
|
99
|
+
|
|
100
|
+
analyses.delete(analysisId);
|
|
101
|
+
if (analyses.size === 0) {
|
|
102
|
+
this._analyses.delete(worktreeId);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this._checkIdle(worktreeId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Remove an analysis hold by analysisId only (without knowing worktreeId).
|
|
110
|
+
* Searches all worktrees for the analysis.
|
|
111
|
+
* @param {string} analysisId
|
|
112
|
+
*/
|
|
113
|
+
removeAnalysisById(analysisId) {
|
|
114
|
+
for (const [worktreeId, analyses] of this._analyses) {
|
|
115
|
+
if (analyses.has(analysisId)) {
|
|
116
|
+
this.removeAnalysis(worktreeId, analysisId);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Check if a worktree is currently in use.
|
|
124
|
+
* @param {string} worktreeId
|
|
125
|
+
* @returns {boolean}
|
|
126
|
+
*/
|
|
127
|
+
isInUse(worktreeId) {
|
|
128
|
+
const hasSessions = (this._sessions.get(worktreeId)?.size || 0) > 0;
|
|
129
|
+
const hasAnalyses = (this._analyses.get(worktreeId)?.size || 0) > 0;
|
|
130
|
+
const hasGraceTimer = this._graceTimers.has(worktreeId);
|
|
131
|
+
return hasSessions || hasAnalyses || hasGraceTimer;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Return the set of active analysis IDs for a worktree (may be empty).
|
|
136
|
+
* @param {string} worktreeId
|
|
137
|
+
* @returns {Set<string>}
|
|
138
|
+
*/
|
|
139
|
+
getActiveAnalyses(worktreeId) {
|
|
140
|
+
return new Set(this._analyses.get(worktreeId) || []);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Forcefully clear all tracking state for a single worktree.
|
|
145
|
+
*
|
|
146
|
+
* Removes sessions, analyses, and grace timers.
|
|
147
|
+
* Does NOT fire the onIdle callback — the caller is assumed to be
|
|
148
|
+
* handling the worktree lifecycle directly (e.g., deleting or
|
|
149
|
+
* marking it available in the pool).
|
|
150
|
+
*
|
|
151
|
+
* @param {string} worktreeId - Pool worktree ID to purge
|
|
152
|
+
*/
|
|
153
|
+
clearWorktree(worktreeId) {
|
|
154
|
+
this._sessions.delete(worktreeId);
|
|
155
|
+
this._analyses.delete(worktreeId);
|
|
156
|
+
const timer = this._graceTimers.get(worktreeId);
|
|
157
|
+
if (timer) {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
this._graceTimers.delete(worktreeId);
|
|
160
|
+
}
|
|
161
|
+
logger.debug(`Cleared all tracking state for pool worktree ${worktreeId}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Internal: check if a worktree has become idle and start grace period or fire callback.
|
|
166
|
+
* @param {string} worktreeId
|
|
167
|
+
* @private
|
|
168
|
+
*/
|
|
169
|
+
_checkIdle(worktreeId) {
|
|
170
|
+
const hasSessions = (this._sessions.get(worktreeId)?.size || 0) > 0;
|
|
171
|
+
const hasAnalyses = (this._analyses.get(worktreeId)?.size || 0) > 0;
|
|
172
|
+
|
|
173
|
+
if (hasSessions || hasAnalyses) return; // still in use
|
|
174
|
+
|
|
175
|
+
// Already have a grace timer? Let it run
|
|
176
|
+
if (this._graceTimers.has(worktreeId)) return;
|
|
177
|
+
|
|
178
|
+
// Start grace period
|
|
179
|
+
logger.debug(`Starting ${GRACE_PERIOD_MS / 1000}s grace period for pool worktree ${worktreeId}`);
|
|
180
|
+
const timer = setTimeout(async () => {
|
|
181
|
+
this._graceTimers.delete(worktreeId);
|
|
182
|
+
// Double-check still idle (a new session could have connected during grace)
|
|
183
|
+
const stillHasSessions = (this._sessions.get(worktreeId)?.size || 0) > 0;
|
|
184
|
+
const stillHasAnalyses = (this._analyses.get(worktreeId)?.size || 0) > 0;
|
|
185
|
+
if (!stillHasSessions && !stillHasAnalyses) {
|
|
186
|
+
logger.info(`Pool worktree ${worktreeId} idle after grace period`);
|
|
187
|
+
if (this.onIdle) {
|
|
188
|
+
try {
|
|
189
|
+
await this.onIdle(worktreeId);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
logger.error(`onIdle callback failed for ${worktreeId}: ${err.message}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}, GRACE_PERIOD_MS);
|
|
196
|
+
|
|
197
|
+
// Don't hold the process open for grace timers
|
|
198
|
+
if (timer.unref) timer.unref();
|
|
199
|
+
this._graceTimers.set(worktreeId, timer);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Clear all tracking state. Useful for testing.
|
|
204
|
+
*/
|
|
205
|
+
reset() {
|
|
206
|
+
this._sessions.clear();
|
|
207
|
+
this._analyses.clear();
|
|
208
|
+
for (const timer of this._graceTimers.values()) {
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
}
|
|
211
|
+
this._graceTimers.clear();
|
|
212
|
+
this.onIdle = null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = { WorktreePoolUsageTracker, GRACE_PERIOD_MS };
|
package/src/git/worktree.js
CHANGED
|
@@ -170,6 +170,97 @@ class GitWorktreeManager {
|
|
|
170
170
|
return this.resolveRemoteForRepo(git, cloneUrl, sshUrl);
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Extract a PR number from either { number } or { prNumber } shapes.
|
|
175
|
+
* @param {Object|null} prInfo
|
|
176
|
+
* @returns {number|null}
|
|
177
|
+
*/
|
|
178
|
+
getPRNumber(prInfo) {
|
|
179
|
+
if (!prInfo) return null;
|
|
180
|
+
return prInfo.number || prInfo.prNumber || null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Extract the PR head branch name from either REST or stored PR metadata.
|
|
185
|
+
* @param {Object|null} prData
|
|
186
|
+
* @returns {string}
|
|
187
|
+
*/
|
|
188
|
+
getPRHeadBranch(prData) {
|
|
189
|
+
return prData?.head?.ref || prData?.head_branch || '';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Extract the PR head SHA from either REST or stored PR metadata.
|
|
194
|
+
* @param {Object|null} prData
|
|
195
|
+
* @returns {string}
|
|
196
|
+
*/
|
|
197
|
+
getPRHeadSha(prData) {
|
|
198
|
+
return prData?.head?.sha || prData?.head_sha || '';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Detect whether a fetch failed because the remote does not expose a PR ref.
|
|
203
|
+
* @param {Error} error
|
|
204
|
+
* @returns {boolean}
|
|
205
|
+
*/
|
|
206
|
+
isMissingRemoteRefError(error) {
|
|
207
|
+
const message = String(error?.message || '').toLowerCase();
|
|
208
|
+
return message.includes('couldn\'t find remote ref') ||
|
|
209
|
+
message.includes('could not find remote ref') ||
|
|
210
|
+
message.includes('remote ref does not exist') ||
|
|
211
|
+
message.includes('fatal: invalid refspec');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Fetch a PR head into a stable tracking ref, falling back from GitHub PR
|
|
216
|
+
* refs to a direct SHA fetch when the git transport does not expose
|
|
217
|
+
* refs/pull/* (for example, alternate internal fetch backends).
|
|
218
|
+
*
|
|
219
|
+
* @param {Object} git - simple-git instance
|
|
220
|
+
* @param {Object} prInfo - PR info { owner, repo, number } or { prNumber }
|
|
221
|
+
* @param {Object} prData - PR data from GitHub API or stored metadata
|
|
222
|
+
* @param {Object} [options={}]
|
|
223
|
+
* @param {string|null} [options.remote] - Base repository remote to use for PR-ref fetch
|
|
224
|
+
* @returns {Promise<{remote: string, trackingRef: string|null, checkoutTarget: string}>}
|
|
225
|
+
*/
|
|
226
|
+
async fetchPRHead(git, prInfo, prData, options = {}) {
|
|
227
|
+
const prNumber = this.getPRNumber(prInfo);
|
|
228
|
+
const headSha = this.getPRHeadSha(prData);
|
|
229
|
+
const baseRemote = options.remote || await this.resolveRemoteForPR(git, prData, prInfo);
|
|
230
|
+
|
|
231
|
+
if (!prNumber) {
|
|
232
|
+
throw new Error('Cannot fetch PR head without a PR number');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const prTrackingRef = `refs/remotes/${baseRemote}/pr-${prNumber}`;
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
await git.fetch([baseRemote, `+refs/pull/${prNumber}/head:${prTrackingRef}`]);
|
|
239
|
+
return {
|
|
240
|
+
remote: baseRemote,
|
|
241
|
+
trackingRef: prTrackingRef,
|
|
242
|
+
checkoutTarget: prTrackingRef
|
|
243
|
+
};
|
|
244
|
+
} catch (prRefError) {
|
|
245
|
+
if (!this.isMissingRemoteRefError(prRefError)) {
|
|
246
|
+
throw prRefError;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
console.warn(`PR ref fetch unavailable for PR #${prNumber} on remote ${baseRemote}, falling back to SHA fetch: ${prRefError.message}`);
|
|
250
|
+
|
|
251
|
+
if (!headSha) {
|
|
252
|
+
throw prRefError;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
await git.raw(['fetch', baseRemote, headSha]);
|
|
256
|
+
return {
|
|
257
|
+
remote: baseRemote,
|
|
258
|
+
trackingRef: null,
|
|
259
|
+
checkoutTarget: headSha
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
173
264
|
/**
|
|
174
265
|
* Execute a user-provided checkout script in the worktree.
|
|
175
266
|
* The script receives PR context as environment variables and is responsible
|
|
@@ -241,10 +332,12 @@ class GitWorktreeManager {
|
|
|
241
332
|
* When set, worktree is created with --no-checkout from the main git root (no sparse-checkout inheritance),
|
|
242
333
|
* and the script is executed before checkout with PR context as environment variables.
|
|
243
334
|
* @param {number} [options.checkoutTimeout] - Timeout in ms for checkout script (default: 300000 = 5 minutes)
|
|
244
|
-
* @
|
|
335
|
+
* @param {string} [options.explicitId] - When provided, use this ID for the worktrees-table record
|
|
336
|
+
* instead of generating one. Used by the worktree pool to align the worktrees-table ID with the pool ID.
|
|
337
|
+
* @returns {Promise<{ path: string, id: string }>} Path and database ID of created worktree
|
|
245
338
|
*/
|
|
246
339
|
async createWorktreeForPR(prInfo, prData, repositoryPath, options = {}) {
|
|
247
|
-
const { worktreeSourcePath, checkoutScript, checkoutTimeout } = options;
|
|
340
|
+
const { worktreeSourcePath, checkoutScript, checkoutTimeout, explicitId } = options;
|
|
248
341
|
// Check if worktree already exists in DB
|
|
249
342
|
const repository = normalizeRepository(prInfo.owner, prInfo.repo);
|
|
250
343
|
let worktreePath;
|
|
@@ -265,7 +358,25 @@ class GitWorktreeManager {
|
|
|
265
358
|
// Try to reuse existing worktree by refreshing it
|
|
266
359
|
console.log(`Found existing worktree for PR #${prInfo.number} at ${worktreePath}`);
|
|
267
360
|
try {
|
|
268
|
-
|
|
361
|
+
const refreshedPath = await this.refreshWorktree(worktreeRecord, prInfo.number, prData, prInfo);
|
|
362
|
+
let returnId = worktreeRecord.id;
|
|
363
|
+
|
|
364
|
+
// If explicitId is provided and differs from the existing record's ID,
|
|
365
|
+
// migrate the worktrees-table row to use the pool ID. This happens when
|
|
366
|
+
// pool mode is enabled for a repo that already has legacy worktree records.
|
|
367
|
+
if (explicitId && worktreeRecord.id !== explicitId && this.worktreeRepo) {
|
|
368
|
+
const migrated = await this.worktreeRepo.getOrCreate({
|
|
369
|
+
prNumber: prInfo.number,
|
|
370
|
+
repository,
|
|
371
|
+
branch: prData.head_branch || prData.base_branch,
|
|
372
|
+
path: refreshedPath,
|
|
373
|
+
explicitId,
|
|
374
|
+
});
|
|
375
|
+
returnId = migrated.id;
|
|
376
|
+
console.log(`Migrated worktree record from ${worktreeRecord.id} to ${explicitId}`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return { path: refreshedPath, id: returnId };
|
|
269
380
|
} catch (refreshError) {
|
|
270
381
|
// If refresh fails due to uncommitted changes, propagate that error
|
|
271
382
|
if (refreshError.message.includes('uncommitted changes')) {
|
|
@@ -289,20 +400,23 @@ class GitWorktreeManager {
|
|
|
289
400
|
if (legacyExists && await this.isValidGitWorktree(legacyPath)) {
|
|
290
401
|
console.log(`Found legacy worktree for PR #${prInfo.number} at ${legacyPath}, adopting it`);
|
|
291
402
|
|
|
292
|
-
// Create DB record for the legacy worktree
|
|
403
|
+
// Create DB record for the legacy worktree — pass explicitId so the record
|
|
404
|
+
// is created with the pool ID when pool mode is active
|
|
293
405
|
if (this.worktreeRepo) {
|
|
294
406
|
worktreeRecord = await this.worktreeRepo.getOrCreate({
|
|
295
407
|
prNumber: prInfo.number,
|
|
296
408
|
repository,
|
|
297
409
|
branch: prData.head_branch || prData.base_branch,
|
|
298
|
-
path: legacyPath
|
|
410
|
+
path: legacyPath,
|
|
411
|
+
explicitId,
|
|
299
412
|
});
|
|
300
413
|
console.log(`Created database record for legacy worktree`);
|
|
301
414
|
}
|
|
302
415
|
|
|
303
416
|
// Try to refresh and reuse the legacy worktree
|
|
304
417
|
try {
|
|
305
|
-
|
|
418
|
+
const refreshedPath = await this.refreshWorktree({ path: legacyPath, id: worktreeRecord?.id }, prInfo.number, prData, prInfo);
|
|
419
|
+
return { path: refreshedPath, id: worktreeRecord?.id };
|
|
306
420
|
} catch (refreshError) {
|
|
307
421
|
// If refresh fails due to uncommitted changes, propagate that error
|
|
308
422
|
if (refreshError.message.includes('uncommitted changes')) {
|
|
@@ -404,20 +518,21 @@ class GitWorktreeManager {
|
|
|
404
518
|
console.log(`Base SHA fetch not needed or already available: ${fetchError.message}`);
|
|
405
519
|
}
|
|
406
520
|
|
|
407
|
-
// Fetch the PR head using
|
|
521
|
+
// Fetch the PR head using PR refs when available, with a branch/SHA fallback
|
|
408
522
|
console.log(`Fetching PR #${prInfo.number} head...`);
|
|
409
|
-
await
|
|
523
|
+
const fetchedHead = await this.fetchPRHead(worktreeGit, prInfo, prData, { remote });
|
|
410
524
|
|
|
411
525
|
// Execute checkout script if configured (before checkout so sparse-checkout is set up)
|
|
412
526
|
if (checkoutScript) {
|
|
413
527
|
// Fetch the actual head branch by name (for checkout scripts that expect branch refs)
|
|
414
528
|
// This may fail for fork PRs where the branch is in a different repo - that's okay
|
|
415
|
-
|
|
529
|
+
const headBranch = this.getPRHeadBranch(prData);
|
|
530
|
+
if (headBranch) {
|
|
416
531
|
try {
|
|
417
|
-
console.log(`Fetching head branch ${
|
|
418
|
-
await worktreeGit.fetch([remote, `+refs/heads/${
|
|
532
|
+
console.log(`Fetching head branch ${headBranch}...`);
|
|
533
|
+
await worktreeGit.fetch([remote, `+refs/heads/${headBranch}:refs/remotes/${remote}/${headBranch}`]);
|
|
419
534
|
// Create/update a local branch pointing to the fetched ref so tooling can reference it by name
|
|
420
|
-
await worktreeGit.branch(['-f',
|
|
535
|
+
await worktreeGit.branch(['-f', headBranch, `${remote}/${headBranch}`]);
|
|
421
536
|
} catch (branchFetchError) {
|
|
422
537
|
// Expected for fork PRs - the branch exists in the fork, not the base repo
|
|
423
538
|
console.log(`Could not fetch head branch (may be from a fork): ${branchFetchError.message}`);
|
|
@@ -427,9 +542,9 @@ class GitWorktreeManager {
|
|
|
427
542
|
console.log(`Executing checkout script: ${checkoutScript}`);
|
|
428
543
|
const scriptEnv = {
|
|
429
544
|
BASE_BRANCH: prData.base_branch,
|
|
430
|
-
HEAD_BRANCH:
|
|
545
|
+
HEAD_BRANCH: headBranch,
|
|
431
546
|
BASE_SHA: prData.base_sha,
|
|
432
|
-
HEAD_SHA: prData
|
|
547
|
+
HEAD_SHA: this.getPRHeadSha(prData),
|
|
433
548
|
PR_NUMBER: String(prInfo.number),
|
|
434
549
|
WORKTREE_PATH: worktreePath
|
|
435
550
|
};
|
|
@@ -438,8 +553,14 @@ class GitWorktreeManager {
|
|
|
438
553
|
}
|
|
439
554
|
|
|
440
555
|
// Checkout to PR head commit
|
|
441
|
-
|
|
442
|
-
|
|
556
|
+
const targetSha = this.getPRHeadSha(prData);
|
|
557
|
+
if (targetSha) {
|
|
558
|
+
console.log(`Checking out to PR head commit ${targetSha}...`);
|
|
559
|
+
await worktreeGit.checkout([targetSha]);
|
|
560
|
+
} else {
|
|
561
|
+
console.log(`Checking out to PR head ref ${fetchedHead.checkoutTarget}...`);
|
|
562
|
+
await worktreeGit.checkout([fetchedHead.checkoutTarget]);
|
|
563
|
+
}
|
|
443
564
|
|
|
444
565
|
// Verify we're at the correct commit
|
|
445
566
|
const currentCommit = await worktreeGit.revparse(['HEAD']);
|
|
@@ -448,18 +569,21 @@ class GitWorktreeManager {
|
|
|
448
569
|
}
|
|
449
570
|
|
|
450
571
|
// Store/update worktree record in database
|
|
572
|
+
let worktreeDbId;
|
|
451
573
|
if (this.worktreeRepo) {
|
|
452
|
-
await this.worktreeRepo.getOrCreate({
|
|
574
|
+
const record = await this.worktreeRepo.getOrCreate({
|
|
453
575
|
prNumber: prInfo.number,
|
|
454
576
|
repository,
|
|
455
577
|
branch: prData.head_branch || prData.base_branch,
|
|
456
|
-
path: worktreePath
|
|
578
|
+
path: worktreePath,
|
|
579
|
+
explicitId,
|
|
457
580
|
});
|
|
581
|
+
worktreeDbId = record.id;
|
|
458
582
|
console.log(`Worktree record stored in database`);
|
|
459
583
|
}
|
|
460
584
|
|
|
461
585
|
console.log(`Worktree created successfully at ${worktreePath}`);
|
|
462
|
-
return worktreePath;
|
|
586
|
+
return { path: worktreePath, id: worktreeDbId };
|
|
463
587
|
|
|
464
588
|
} catch (error) {
|
|
465
589
|
console.error('Error creating worktree:', error);
|
|
@@ -503,17 +627,18 @@ class GitWorktreeManager {
|
|
|
503
627
|
// Resolve which remote points to the PR's base repository (handles forks)
|
|
504
628
|
const remote = await this.resolveRemoteForPR(worktreeGit, prData, prInfo);
|
|
505
629
|
|
|
506
|
-
// Fetch the latest from the resolved remote
|
|
630
|
+
// Fetch the latest from the resolved remote (--prune removes stale
|
|
631
|
+
// tracking refs that would otherwise block fetch on ref hierarchy conflicts)
|
|
507
632
|
console.log(`Fetching latest changes from ${remote}...`);
|
|
508
|
-
await worktreeGit.fetch([remote]);
|
|
633
|
+
await worktreeGit.fetch([remote, '--prune']);
|
|
509
634
|
|
|
510
|
-
// Fetch the PR head using
|
|
635
|
+
// Fetch the PR head using PR refs when available, with a branch/SHA fallback
|
|
511
636
|
console.log(`Fetching PR #${number} head...`);
|
|
512
|
-
await
|
|
637
|
+
const fetchedHead = await this.fetchPRHead(worktreeGit, prInfo, prData, { remote });
|
|
513
638
|
|
|
514
639
|
// Checkout to PR head commit
|
|
515
640
|
console.log(`Checking out to PR head commit ${headSha}...`);
|
|
516
|
-
await worktreeGit.checkout([
|
|
641
|
+
await worktreeGit.checkout([fetchedHead.checkoutTarget]);
|
|
517
642
|
|
|
518
643
|
// Verify we're at the correct commit
|
|
519
644
|
const currentCommit = await worktreeGit.revparse(['HEAD']);
|
|
@@ -886,11 +1011,11 @@ class GitWorktreeManager {
|
|
|
886
1011
|
|
|
887
1012
|
// Fetch the latest PR head from remote
|
|
888
1013
|
console.log(`Fetching PR #${prNumber} head from ${remote}...`);
|
|
889
|
-
await
|
|
1014
|
+
const fetchedHead = await this.fetchPRHead(git, prInfo || { number: prNumber }, prData, { remote });
|
|
890
1015
|
|
|
891
1016
|
// Reset to the fetched PR head
|
|
892
1017
|
console.log(`Resetting worktree to PR head...`);
|
|
893
|
-
await git.raw(['reset', '--hard',
|
|
1018
|
+
await git.raw(['reset', '--hard', fetchedHead.checkoutTarget]);
|
|
894
1019
|
|
|
895
1020
|
// Update last_accessed_at in database
|
|
896
1021
|
if (this.worktreeRepo) {
|
|
@@ -944,13 +1069,13 @@ class GitWorktreeManager {
|
|
|
944
1069
|
? await this.resolveRemoteForPR(git, prData, prInfo)
|
|
945
1070
|
: defaultRemote;
|
|
946
1071
|
|
|
947
|
-
// 3. Fetch PR head into a persistent ref
|
|
948
|
-
console.log(`Fetching PR #${prNumber} head from ${remote}
|
|
949
|
-
await
|
|
1072
|
+
// 3. Fetch PR head into a persistent ref (or by SHA when refs are unavailable)
|
|
1073
|
+
console.log(`Fetching PR #${prNumber} head from ${remote}...`);
|
|
1074
|
+
const fetchedHead = await this.fetchPRHead(git, prInfo || { number: prNumber }, prData, { remote });
|
|
950
1075
|
|
|
951
1076
|
// 4. Reset worktree to the fetched ref
|
|
952
|
-
console.log(`Resetting worktree to
|
|
953
|
-
await git.raw(['reset', '--hard',
|
|
1077
|
+
console.log(`Resetting worktree to ${fetchedHead.checkoutTarget}...`);
|
|
1078
|
+
await git.raw(['reset', '--hard', fetchedHead.checkoutTarget]);
|
|
954
1079
|
|
|
955
1080
|
// 5. Return the new HEAD SHA
|
|
956
1081
|
const headSha = (await git.revparse(['HEAD'])).trim();
|
|
@@ -1186,4 +1311,4 @@ class GitWorktreeManager {
|
|
|
1186
1311
|
}
|
|
1187
1312
|
}
|
|
1188
1313
|
|
|
1189
|
-
module.exports = { GitWorktreeManager };
|
|
1314
|
+
module.exports = { GitWorktreeManager };
|