@in-the-loop-labs/pair-review 3.3.3 → 3.3.4

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.
@@ -8,12 +8,13 @@
8
8
  * - --no-ext-diff: Disable external diff drivers (overrides diff.external)
9
9
  * - --src-prefix=a/ --dst-prefix=b/: Ensure consistent a/ b/ prefixes (overrides diff.noprefix / diff.mnemonicPrefix)
10
10
  * - --no-relative: Ensure paths are repo-root-relative (overrides diff.relative)
11
+ * - --full-index: Persist full blob IDs so diff snapshots remain durable over time
11
12
  */
12
13
 
13
14
  /**
14
15
  * String form for execSync / exec shell calls (e.g. `git diff ${GIT_DIFF_FLAGS} ...`).
15
16
  */
16
- const GIT_DIFF_FLAGS = '--no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/ --no-relative';
17
+ const GIT_DIFF_FLAGS = '--no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/ --no-relative --full-index';
17
18
 
18
19
  /**
19
20
  * Array form for simple-git .diff() calls (full diff output including file content).
@@ -23,7 +24,8 @@ const GIT_DIFF_FLAGS_ARRAY = [
23
24
  '--no-ext-diff',
24
25
  '--src-prefix=a/',
25
26
  '--dst-prefix=b/',
26
- '--no-relative'
27
+ '--no-relative',
28
+ '--full-index'
27
29
  ];
28
30
 
29
31
  /**
@@ -248,6 +248,8 @@ class WorktreePoolLifecycle {
248
248
  number: prInfo.prNumber,
249
249
  }, prData, { remote: remoteName });
250
250
 
251
+ await worktreeManager.ensureBaseShaAvailable(git, prData, remoteName);
252
+
251
253
  // Clean the working tree before switching PRs. Without this, untracked
252
254
  // files (build artifacts, generated code) from the previous PR leak into
253
255
  // the new checkout, and modified tracked files can cause checkout to fail.
@@ -257,7 +259,7 @@ class WorktreePoolLifecycle {
257
259
  await git.clean('f', ['-d']);
258
260
 
259
261
  // Checkout specific head SHA (stored SHA in restore mode, latest in fresh mode)
260
- const targetSha = prData.head?.sha || prData.head_sha;
262
+ const targetSha = worktreeManager.getPRHeadSha(prData);
261
263
  if (targetSha) {
262
264
  await git.checkout([targetSha]);
263
265
  } else {
@@ -269,8 +271,8 @@ class WorktreePoolLifecycle {
269
271
  logger.info(`Executing reset script: ${options.resetScript}`);
270
272
  const headRef = prData.head?.ref || prData.head_branch || '';
271
273
  const baseRef = prData.base?.ref || prData.base_branch || '';
272
- const headSha = prData.head?.sha || prData.head_sha || '';
273
- const baseSha = prData.base?.sha || prData.base_sha || '';
274
+ const headSha = worktreeManager.getPRHeadSha(prData);
275
+ const baseSha = worktreeManager.getPRBaseSha(prData);
274
276
 
275
277
  const scriptEnv = {
276
278
  BASE_BRANCH: baseRef,
@@ -341,6 +343,13 @@ class WorktreePoolLifecycle {
341
343
  const git = this._simpleGit(poolEntry.path);
342
344
  const currentHead = (await git.revparse(['HEAD'])).trim();
343
345
  if (currentHead === targetSha) {
346
+ const worktreeManager = new this._GitWorktreeManager(this.db);
347
+ const remote = await worktreeManager.resolveRemoteForPR(git, prData, {
348
+ owner: prInfo.owner,
349
+ repo: prInfo.repo,
350
+ number: prInfo.prNumber,
351
+ });
352
+ await worktreeManager.ensureBaseShaAvailable(git, prData, remote);
344
353
  logger.info(`Pool worktree ${poolEntry.id} already at target SHA ${targetSha.slice(0, 8)}, skipping refresh`);
345
354
  await this._poolRepo.markInUse(poolEntry.id, prInfo.prNumber);
346
355
  return { worktreePath: poolEntry.path, worktreeId: poolEntry.id };
@@ -10,6 +10,8 @@ const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = requi
10
10
  const { GIT_DIFF_FLAGS_ARRAY, GIT_DIFF_SUMMARY_FLAGS_ARRAY } = require('./diff-flags');
11
11
  const { spawn, execSync } = require('child_process');
12
12
 
13
+ const MISSING_COMMIT_ERROR_CODE = 'PAIR_REVIEW_MISSING_COMMIT';
14
+
13
15
  /**
14
16
  * Git worktree manager for handling PR branch checkouts and diffs
15
17
  */
@@ -201,6 +203,136 @@ class GitWorktreeManager {
201
203
  return prData?.head?.sha || prData?.head_sha || '';
202
204
  }
203
205
 
206
+ /**
207
+ * Extract the PR base SHA from either REST or stored PR metadata.
208
+ * @param {Object|null} prData
209
+ * @returns {string}
210
+ */
211
+ getPRBaseSha(prData) {
212
+ return prData?.base?.sha || prData?.base_sha || '';
213
+ }
214
+
215
+ /**
216
+ * Check whether the given commit object is already available locally.
217
+ * @param {Object} git - simple-git instance
218
+ * @param {string} sha
219
+ * @returns {Promise<boolean>}
220
+ */
221
+ async hasCommitLocally(git, sha) {
222
+ if (!sha) {
223
+ return false;
224
+ }
225
+
226
+ try {
227
+ const objectType = (await git.raw(['cat-file', '-t', sha])).trim();
228
+ return objectType === 'commit';
229
+ } catch {
230
+ return false;
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Ensure a specific commit object exists locally, fetching it directly when needed.
236
+ * @param {Object} git - simple-git instance
237
+ * @param {string} sha
238
+ * @param {string} remote
239
+ * @param {string} label
240
+ * @returns {Promise<void>}
241
+ */
242
+ async ensureCommitAvailable(git, sha, remote, label = 'Commit') {
243
+ if (!sha) {
244
+ return;
245
+ }
246
+
247
+ if (await this.hasCommitLocally(git, sha)) {
248
+ return;
249
+ }
250
+
251
+ let fetchError = null;
252
+ try {
253
+ await git.raw(['fetch', remote, sha]);
254
+ } catch (error) {
255
+ fetchError = error;
256
+ }
257
+
258
+ if (await this.hasCommitLocally(git, sha)) {
259
+ return;
260
+ }
261
+
262
+ if (fetchError) {
263
+ const error = new Error(`${label} ${sha} is not available locally and fetch from ${remote} failed: ${fetchError.message}`);
264
+ error.code = MISSING_COMMIT_ERROR_CODE;
265
+ error.commitSha = sha;
266
+ error.commitLabel = label;
267
+ error.remote = remote;
268
+ error.cause = fetchError;
269
+ throw error;
270
+ }
271
+
272
+ const error = new Error(`${label} ${sha} is not available locally after fetch from ${remote}`);
273
+ error.code = MISSING_COMMIT_ERROR_CODE;
274
+ error.commitSha = sha;
275
+ error.commitLabel = label;
276
+ error.remote = remote;
277
+ throw error;
278
+ }
279
+
280
+ /**
281
+ * Ensure the PR base commit is available in the local repository before diffing.
282
+ * @param {Object} git - simple-git instance
283
+ * @param {Object|null} prData
284
+ * @param {string} remote
285
+ * @returns {Promise<void>}
286
+ */
287
+ async ensureBaseShaAvailable(git, prData, remote) {
288
+ const baseSha = this.getPRBaseSha(prData);
289
+ if (!baseSha) {
290
+ return;
291
+ }
292
+
293
+ console.log(`Ensuring base commit ${baseSha} is available...`);
294
+ await this.ensureCommitAvailable(git, baseSha, remote, 'Base SHA');
295
+ }
296
+
297
+ /**
298
+ * Fail with a targeted message when a required diff commit is missing locally.
299
+ * @param {Object} git - simple-git instance
300
+ * @param {string} sha
301
+ * @param {string} label
302
+ * @returns {Promise<void>}
303
+ */
304
+ async assertCommitAvailableLocally(git, sha, label = 'Commit') {
305
+ if (!sha) {
306
+ throw new Error(`${label} is required but missing from PR data`);
307
+ }
308
+
309
+ if (await this.hasCommitLocally(git, sha)) {
310
+ return;
311
+ }
312
+
313
+ const error = new Error(`${label} ${sha} is not available locally. Refresh the worktree to fetch the missing commit before generating the diff.`);
314
+ error.code = MISSING_COMMIT_ERROR_CODE;
315
+ error.commitSha = sha;
316
+ error.commitLabel = label;
317
+ throw error;
318
+ }
319
+
320
+ /**
321
+ * Preserve machine-checkable error metadata when wrapping lower-level git failures.
322
+ * @param {string} prefix
323
+ * @param {Error} error
324
+ * @returns {Error}
325
+ */
326
+ wrapError(prefix, error) {
327
+ const wrapped = new Error(`${prefix}: ${error.message}`);
328
+ if (error?.code) wrapped.code = error.code;
329
+ if (error?.commitSha) wrapped.commitSha = error.commitSha;
330
+ if (error?.commitLabel) wrapped.commitLabel = error.commitLabel;
331
+ if (error?.remote) wrapped.remote = error.remote;
332
+ if (error) wrapped.cause = error;
333
+ return wrapped;
334
+ }
335
+
204
336
  /**
205
337
  * Detect whether a fetch failed because the remote does not expose a PR ref.
206
338
  * @param {Error} error
@@ -451,7 +583,7 @@ class GitWorktreeManager {
451
583
  await this.cleanupWorktree(worktreePath);
452
584
 
453
585
  // Create git instance for the source repository
454
- const git = simpleGit(repositoryPath);
586
+ const git = this._gitFor(repositoryPath);
455
587
 
456
588
  // Resolve which remote points to the PR's base repository (handles forks)
457
589
  const remote = await this.resolveRemoteForPR(git, prData, prInfo);
@@ -490,7 +622,7 @@ class GitWorktreeManager {
490
622
  } else {
491
623
  // Without checkout_script: use worktreeSourcePath as cwd if provided
492
624
  // (to inherit sparse-checkout from existing worktree)
493
- const worktreeAddGit = worktreeSourcePath ? simpleGit(worktreeSourcePath) : git;
625
+ const worktreeAddGit = worktreeSourcePath ? this._gitFor(worktreeSourcePath) : git;
494
626
  if (worktreeSourcePath) {
495
627
  console.log(`Creating worktree at ${worktreePath} from ${prData.base_branch} (inheriting sparse-checkout from ${worktreeSourcePath})...`);
496
628
  } else {
@@ -509,17 +641,10 @@ class GitWorktreeManager {
509
641
  }
510
642
 
511
643
  // Create git instance for the worktree
512
- const worktreeGit = simpleGit(worktreePath);
513
-
644
+ const worktreeGit = this._gitFor(worktreePath);
645
+
514
646
  // Ensure base SHA is available (in case base branch was force-pushed or rebased)
515
- console.log(`Ensuring base commit ${prData.base_sha} is available...`);
516
- try {
517
- // Try to fetch the specific base SHA if it's not already available
518
- await worktreeGit.raw(['fetch', remote, prData.base_sha]);
519
- } catch (fetchError) {
520
- // If fetch fails, the SHA might already be available locally
521
- console.log(`Base SHA fetch not needed or already available: ${fetchError.message}`);
522
- }
647
+ await this.ensureBaseShaAvailable(worktreeGit, prData, remote);
523
648
 
524
649
  // Fetch the PR head using PR refs when available, with a branch/SHA fallback
525
650
  console.log(`Fetching PR #${prInfo.number} head...`);
@@ -546,7 +671,7 @@ class GitWorktreeManager {
546
671
  const scriptEnv = {
547
672
  BASE_BRANCH: prData.base_branch,
548
673
  HEAD_BRANCH: headBranch,
549
- BASE_SHA: prData.base_sha,
674
+ BASE_SHA: this.getPRBaseSha(prData),
550
675
  HEAD_SHA: this.getPRHeadSha(prData),
551
676
  PR_NUMBER: String(prInfo.number),
552
677
  WORKTREE_PATH: worktreePath
@@ -567,8 +692,8 @@ class GitWorktreeManager {
567
692
 
568
693
  // Verify we're at the correct commit
569
694
  const currentCommit = await worktreeGit.revparse(['HEAD']);
570
- if (currentCommit.trim() !== prData.head_sha) {
571
- console.warn(`Warning: Expected commit ${prData.head_sha}, but got ${currentCommit.trim()}`);
695
+ if (targetSha && currentCommit.trim() !== targetSha) {
696
+ console.warn(`Warning: Expected commit ${targetSha}, but got ${currentCommit.trim()}`);
572
697
  }
573
698
 
574
699
  // Store/update worktree record in database
@@ -598,7 +723,7 @@ class GitWorktreeManager {
598
723
  console.error('Error during cleanup:', cleanupError);
599
724
  }
600
725
 
601
- throw new Error(`Failed to create git worktree: ${error.message}`);
726
+ throw this.wrapError('Failed to create git worktree', error);
602
727
  }
603
728
  }
604
729
 
@@ -612,7 +737,7 @@ class GitWorktreeManager {
612
737
  */
613
738
  async updateWorktree(owner, repo, number, prData) {
614
739
  const prInfo = { owner, repo, number };
615
- const headSha = prData.head_sha;
740
+ const headSha = this.getPRHeadSha(prData);
616
741
  const worktreePath = await this.getWorktreePath(prInfo);
617
742
 
618
743
  try {
@@ -625,7 +750,7 @@ class GitWorktreeManager {
625
750
  console.log(`Updating worktree for PR #${number} at ${worktreePath}`);
626
751
 
627
752
  // Create git instance for the worktree
628
- const worktreeGit = simpleGit(worktreePath);
753
+ const worktreeGit = this._gitFor(worktreePath);
629
754
 
630
755
  // Resolve which remote points to the PR's base repository (handles forks)
631
756
  const remote = await this.resolveRemoteForPR(worktreeGit, prData, prInfo);
@@ -635,17 +760,19 @@ class GitWorktreeManager {
635
760
  console.log(`Fetching latest changes from ${remote}...`);
636
761
  await worktreeGit.fetch([remote, '--prune']);
637
762
 
763
+ await this.ensureBaseShaAvailable(worktreeGit, prData, remote);
764
+
638
765
  // Fetch the PR head using PR refs when available, with a branch/SHA fallback
639
766
  console.log(`Fetching PR #${number} head...`);
640
767
  const fetchedHead = await this.fetchPRHead(worktreeGit, prInfo, prData, { remote });
641
768
 
642
769
  // Checkout to PR head commit
643
- console.log(`Checking out to PR head commit ${headSha}...`);
770
+ console.log(`Checking out to PR head ${headSha || fetchedHead.checkoutTarget}...`);
644
771
  await worktreeGit.checkout([fetchedHead.checkoutTarget]);
645
772
 
646
773
  // Verify we're at the correct commit
647
774
  const currentCommit = await worktreeGit.revparse(['HEAD']);
648
- if (currentCommit.trim() !== headSha) {
775
+ if (headSha && currentCommit.trim() !== headSha) {
649
776
  console.warn(`Warning: Expected commit ${headSha}, but got ${currentCommit.trim()}`);
650
777
  }
651
778
 
@@ -654,7 +781,7 @@ class GitWorktreeManager {
654
781
 
655
782
  } catch (error) {
656
783
  console.error('Error updating worktree:', error);
657
- throw new Error(`Failed to update git worktree: ${error.message}`);
784
+ throw this.wrapError('Failed to update git worktree', error);
658
785
  }
659
786
  }
660
787
 
@@ -666,16 +793,21 @@ class GitWorktreeManager {
666
793
  */
667
794
  async generateUnifiedDiff(worktreePath, prData) {
668
795
  try {
669
- console.log(`Generating diff between ${prData.base_sha} and ${prData.head_sha}...`);
796
+ const git = this._gitFor(worktreePath);
797
+ const baseSha = this.getPRBaseSha(prData);
798
+ const headSha = this.getPRHeadSha(prData);
670
799
 
671
- const git = simpleGit(worktreePath);
800
+ console.log(`Generating diff between ${baseSha} and ${headSha}...`);
801
+
802
+ await this.assertCommitAvailableLocally(git, baseSha, 'Base SHA');
803
+ await this.assertCommitAvailableLocally(git, headSha, 'Head SHA');
672
804
 
673
805
  // Generate diff between base SHA and head SHA (not branch names)
674
806
  // This ensures we compare the exact commits from the PR, even if the base branch has moved
675
807
  // Defensive flags to normalize output regardless of user's git config
676
808
  // (see src/git/diff-flags.js for rationale)
677
809
  const diff = await git.diff([
678
- `${prData.base_sha}...${prData.head_sha}`,
810
+ `${baseSha}...${headSha}`,
679
811
  '--unified=3',
680
812
  ...GIT_DIFF_FLAGS_ARRAY
681
813
  ]);
@@ -684,7 +816,7 @@ class GitWorktreeManager {
684
816
 
685
817
  } catch (error) {
686
818
  console.error('Error generating diff:', error);
687
- throw new Error(`Failed to generate diff: ${error.message}`);
819
+ throw this.wrapError('Failed to generate diff', error);
688
820
  }
689
821
  }
690
822
 
@@ -696,12 +828,17 @@ class GitWorktreeManager {
696
828
  */
697
829
  async getChangedFiles(worktreePath, prData) {
698
830
  try {
699
- const git = simpleGit(worktreePath);
831
+ const git = this._gitFor(worktreePath);
832
+ const baseSha = this.getPRBaseSha(prData);
833
+ const headSha = this.getPRHeadSha(prData);
834
+
835
+ await this.assertCommitAvailableLocally(git, baseSha, 'Base SHA');
836
+ await this.assertCommitAvailableLocally(git, headSha, 'Head SHA');
700
837
 
701
838
  // Get file changes with stats using base SHA and head SHA
702
839
  // This ensures we get the exact files changed in the PR, even if the base branch has moved
703
840
  const diffSummary = await git.diffSummary([
704
- `${prData.base_sha}...${prData.head_sha}`,
841
+ `${baseSha}...${headSha}`,
705
842
  ...GIT_DIFF_SUMMARY_FLAGS_ARRAY
706
843
  ]);
707
844
 
@@ -728,7 +865,7 @@ class GitWorktreeManager {
728
865
 
729
866
  } catch (error) {
730
867
  console.error('Error getting changed files:', error);
731
- throw new Error(`Failed to get changed files: ${error.message}`);
868
+ throw this.wrapError('Failed to get changed files', error);
732
869
  }
733
870
  }
734
871
 
@@ -779,7 +916,7 @@ class GitWorktreeManager {
779
916
  */
780
917
  async resolveOwningRepo(worktreePath) {
781
918
  try {
782
- const git = simpleGit(worktreePath);
919
+ const git = this._gitFor(worktreePath);
783
920
  const commonDir = (await git.raw(['rev-parse', '--git-common-dir'])).trim();
784
921
  // commonDir is either a .git subdirectory (regular repos) or the bare repo root itself.
785
922
  // Only strip the last component when it's actually a .git directory.
@@ -977,7 +1114,7 @@ class GitWorktreeManager {
977
1114
  */
978
1115
  async hasLocalChanges(worktreePath) {
979
1116
  try {
980
- const git = simpleGit(worktreePath);
1117
+ const git = this._gitFor(worktreePath);
981
1118
  const status = await git.raw(['status', '--porcelain']);
982
1119
  return status.trim().length > 0;
983
1120
  } catch (error) {
@@ -1007,11 +1144,13 @@ class GitWorktreeManager {
1007
1144
  throw new Error(`Worktree has uncommitted changes. Please resolve manually at: ${worktreePath}`);
1008
1145
  }
1009
1146
 
1010
- const git = simpleGit(worktreePath);
1147
+ const git = this._gitFor(worktreePath);
1011
1148
 
1012
1149
  // Resolve which remote points to the PR's base repository (handles forks)
1013
1150
  const remote = await this.resolveRemoteForPR(git, prData, prInfo);
1014
1151
 
1152
+ await this.ensureBaseShaAvailable(git, prData, remote);
1153
+
1015
1154
  // Fetch the latest PR head from remote
1016
1155
  console.log(`Fetching PR #${prNumber} head from ${remote}...`);
1017
1156
  const fetchedHead = await this.fetchPRHead(git, prInfo || { number: prNumber }, prData, { remote });
@@ -1035,7 +1174,7 @@ class GitWorktreeManager {
1035
1174
  throw error;
1036
1175
  }
1037
1176
  console.error('Error refreshing worktree:', error);
1038
- throw new Error(`Failed to refresh worktree: ${error.message}`);
1177
+ throw this.wrapError('Failed to refresh worktree', error);
1039
1178
  }
1040
1179
  }
1041
1180
 
@@ -1314,4 +1453,4 @@ class GitWorktreeManager {
1314
1453
  }
1315
1454
  }
1316
1455
 
1317
- module.exports = { GitWorktreeManager };
1456
+ module.exports = { GitWorktreeManager, MISSING_COMMIT_ERROR_CODE };
package/src/main.js CHANGED
@@ -115,7 +115,8 @@ OPTIONS:
115
115
  The web UI also starts for the human reviewer.
116
116
  --model <name> Override the AI model. Claude Code is the default provider.
117
117
  Available models: opus, sonnet, haiku (Claude Code);
118
- also: opus-4.5, opus-4.6-low, opus-4.6-medium, opus-4.6-1m
118
+ also: opus-4.5, opus-4.6-low, opus-4.6-medium, opus-4.6-1m,
119
+ opus-4.7-xhigh
119
120
  or use provider-specific models with Gemini/Codex
120
121
  --use-checkout Use current directory instead of creating worktree
121
122
  (automatic in GitHub Actions)
@@ -42,13 +42,14 @@ const router = express.Router();
42
42
 
43
43
  /**
44
44
  * Enrich a raw analysis run record for API responses.
45
- * Applies backward-compatible tier fallback and parses levels_config JSON.
45
+ * Applies backward-compatible tier fallback and parses JSON columns.
46
46
  */
47
47
  function enrichRun(run) {
48
48
  if (!run) return null;
49
49
  return {
50
50
  ...run,
51
51
  levels_config: run.levels_config ? JSON.parse(run.levels_config) : null,
52
+ level_outcomes: run.level_outcomes ? JSON.parse(run.level_outcomes) : null,
52
53
  tier: run.tier ?? (run.provider && run.model ? getTierForModel(run.provider, run.model) : null)
53
54
  };
54
55
  }
@@ -625,6 +626,7 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
625
626
  status: 'completed',
626
627
  summary: result.summary,
627
628
  totalSuggestions: result.suggestions.length,
629
+ ...(result.levelOutcomes ? { levelOutcomes: result.levelOutcomes } : {}),
628
630
  ...runUpdateExtra
629
631
  });
630
632
  } catch (updateError) {
package/src/routes/pr.js CHANGED
@@ -45,6 +45,7 @@ const {
45
45
  registerProcess: registerProcessForCancellation
46
46
  } = require('./shared');
47
47
  const { safeParseJson } = require('../utils/safe-parse-json');
48
+ const { resolveOriginalFileContentSpecs } = require('../utils/diff-file-content');
48
49
  const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
49
50
  const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
50
51
  const { getProviderClass, createProvider } = require('../ai/provider');
@@ -961,44 +962,41 @@ router.get('/api/file-content-original/:fileName(*)', async (req, res) => {
961
962
  });
962
963
  }
963
964
 
964
- // Get base_sha from the stored PR data
965
- // Context expansion needs content from the BASE version (old lines), not HEAD
965
+ // Prefer the exact blob from the cached diff snapshot. If that is unavailable,
966
+ // fall back to repo-wide base_sha and finally the worktree filesystem.
966
967
  const repository = normalizeRepository(owner, repo);
967
968
  const prRecord = await queryOne(db, `
968
969
  SELECT pr_data FROM pr_metadata
969
970
  WHERE pr_number = ? AND repository = ? COLLATE NOCASE
970
971
  `, [prNumber, repository]);
971
972
 
972
- let baseSha = null;
973
- if (prRecord?.pr_data) {
974
- try {
975
- const prData = JSON.parse(prRecord.pr_data);
976
- baseSha = prData.base_sha;
977
- } catch (parseError) {
978
- console.warn('Could not parse pr_data for base_sha:', parseError.message);
979
- }
973
+ const prData = safeParseJson(prRecord?.pr_data, null);
974
+ if (prRecord?.pr_data && !prData) {
975
+ logger.warn('Could not parse pr_data for file-content route');
980
976
  }
981
977
 
982
- // If we have base_sha, use git show to get the BASE version of the file
983
- // This is critical for correct line number mapping during context expansion
984
- if (baseSha) {
978
+ const contentSpecs = resolveOriginalFileContentSpecs(prData, fileName);
979
+
980
+ if (contentSpecs.length > 0) {
985
981
  try {
986
982
  const git = simpleGit(worktreePath);
987
- // git show base_sha:path/to/file returns the file content at that commit
988
- const content = await git.show([`${baseSha}:${fileName}`]);
989
- const lines = content.split('\n');
990
-
991
- return res.json({
992
- fileName,
993
- lines,
994
- totalLines: lines.length
995
- });
983
+ for (const contentSpec of contentSpecs) {
984
+ try {
985
+ const content = await git.show([contentSpec.gitSpec]);
986
+ const lines = content.split('\n');
987
+
988
+ return res.json({
989
+ fileName,
990
+ lines,
991
+ totalLines: lines.length
992
+ });
993
+ } catch (gitError) {
994
+ // Fall through to the next git-based source before reading HEAD.
995
+ logger.debug(`Could not read file ${fileName} from ${contentSpec.source}: ${gitError.message}`);
996
+ }
997
+ }
996
998
  } catch (gitError) {
997
- // Fall through to filesystem read if git show fails for any reason:
998
- // - File might not exist at base_sha (new file)
999
- // - Worktree might not be a valid git repo (test environment)
1000
- // - Git command might fail for other reasons
1001
- logger.debug(`Could not read file ${fileName} from base commit: ${gitError.message}, falling back to HEAD`);
999
+ logger.debug(`Could not initialize git for ${worktreePath}: ${gitError.message}`);
1002
1000
  }
1003
1001
  }
1004
1002
 
@@ -21,6 +21,7 @@ const { GitWorktreeManager } = require('../git/worktree');
21
21
  const { normalizeRepository } = require('../utils/paths');
22
22
  const { resolveFormat, formatAdoptedComment: formatComment } = require('../utils/comment-formatter');
23
23
  const { safeParseJson } = require('../utils/safe-parse-json');
24
+ const { resolveOriginalFileContentSpecs } = require('../utils/diff-file-content');
24
25
 
25
26
  const router = express.Router();
26
27
 
@@ -1012,32 +1013,35 @@ router.get('/api/reviews/:reviewId/file-content/:fileName(*)', validateReviewId,
1012
1013
  return res.status(404).json({ error: 'Worktree not found for this PR. The PR may need to be reloaded.' });
1013
1014
  }
1014
1015
 
1015
- // Get base_sha from stored PR data
1016
+ // Prefer the exact blob from the cached diff snapshot. If that is unavailable,
1017
+ // fall back to repo-wide base_sha and finally the worktree filesystem.
1016
1018
  const normalizedRepo = normalizeRepository(owner, repo);
1017
1019
  const prRecord = await queryOne(db, `
1018
1020
  SELECT pr_data FROM pr_metadata
1019
1021
  WHERE pr_number = ? AND repository = ? COLLATE NOCASE
1020
1022
  `, [prNumber, normalizedRepo]);
1021
1023
 
1022
- let baseSha = null;
1023
- if (prRecord?.pr_data) {
1024
- try {
1025
- const prData = JSON.parse(prRecord.pr_data);
1026
- baseSha = prData.base_sha;
1027
- } catch (parseError) {
1028
- logger.warn('Could not parse pr_data for base_sha:', parseError.message);
1029
- }
1024
+ const prData = safeParseJson(prRecord?.pr_data, null);
1025
+ if (prRecord?.pr_data && !prData) {
1026
+ logger.warn('Could not parse pr_data for file-content route');
1030
1027
  }
1031
1028
 
1032
- // Try git show for BASE version (correct line numbers for diff)
1033
- if (baseSha) {
1029
+ const contentSpecs = resolveOriginalFileContentSpecs(prData, fileName);
1030
+
1031
+ if (contentSpecs.length > 0) {
1034
1032
  try {
1035
1033
  const git = simpleGit(worktreePath);
1036
- const content = await git.show([`${baseSha}:${fileName}`]);
1037
- const lines = content.split('\n');
1038
- return res.json({ fileName, lines, totalLines: lines.length });
1034
+ for (const contentSpec of contentSpecs) {
1035
+ try {
1036
+ const content = await git.show([contentSpec.gitSpec]);
1037
+ const lines = content.split('\n');
1038
+ return res.json({ fileName, lines, totalLines: lines.length });
1039
+ } catch (gitError) {
1040
+ logger.debug(`Could not read file ${fileName} from ${contentSpec.source}: ${gitError.message}`);
1041
+ }
1042
+ }
1039
1043
  } catch (gitError) {
1040
- logger.debug(`Could not read file ${fileName} from base commit: ${gitError.message}, falling back to HEAD`);
1044
+ logger.debug(`Could not initialize git for ${worktreePath}: ${gitError.message}`);
1041
1045
  }
1042
1046
  }
1043
1047
 
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  const { run, queryOne, WorktreeRepository, RepoSettingsRepository, ReviewRepository } = require('../database');
15
- const { GitWorktreeManager } = require('../git/worktree');
15
+ const { GitWorktreeManager, MISSING_COMMIT_ERROR_CODE } = require('../git/worktree');
16
16
  const { WorktreePoolLifecycle } = require('../git/worktree-pool-lifecycle');
17
17
  const { GitHubClient } = require('../github/client');
18
18
  const { normalizeRepository } = require('../utils/paths');
@@ -374,11 +374,24 @@ async function findRepositoryPath({ db, owner, repo, repository, prNumber, confi
374
374
  * Used to trigger fallback from restore mode to fresh setup.
375
375
  */
376
376
  function isShaNotFoundError(err) {
377
- const msg = (err.message || '').toLowerCase();
378
- return msg.includes('did not match any') ||
379
- msg.includes('not a valid object') ||
380
- msg.includes('reference is not a tree') ||
381
- msg.includes('bad object');
377
+ let current = err;
378
+ while (current) {
379
+ if (current.code === MISSING_COMMIT_ERROR_CODE) {
380
+ return true;
381
+ }
382
+
383
+ const msg = (current.message || '').toLowerCase();
384
+ if (msg.includes('did not match any') ||
385
+ msg.includes('not a valid object') ||
386
+ msg.includes('reference is not a tree') ||
387
+ msg.includes('bad object')) {
388
+ return true;
389
+ }
390
+
391
+ current = current.cause;
392
+ }
393
+
394
+ return false;
382
395
  }
383
396
 
384
397
  /**