@in-the-loop-labs/pair-review 3.0.0 → 3.0.2

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": "3.0.0",
3
+ "version": "3.0.2",
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": "3.0.0",
3
+ "version": "3.0.2",
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": "3.0.0",
3
+ "version": "3.0.2",
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",
@@ -674,6 +674,10 @@ class LocalManager {
674
674
  // User cancelled — keep old diff, early return
675
675
  return;
676
676
  }
677
+ // resolved is the response object — merge branchAvailable into result
678
+ if (resolved.branchAvailable !== undefined) {
679
+ result.branchAvailable = resolved.branchAvailable;
680
+ }
677
681
  }
678
682
  // Branch scope: backend already updated SHA and persisted diff — fall through
679
683
  }
@@ -702,7 +706,7 @@ class LocalManager {
702
706
  /**
703
707
  * Handle a non-branch-scope HEAD SHA change.
704
708
  * Shows a 3-option dialog (or auto-updates in silent mode).
705
- * @returns {boolean} true if the session was updated in-place (caller should apply diff),
709
+ * @returns {Object|false} The response data object if the session was updated in-place (caller should apply diff),
706
710
  * false if cancelled or redirecting away (caller should skip _applyRefreshedDiff)
707
711
  */
708
712
  async _resolveHeadChange(result, opts) {
@@ -771,8 +775,9 @@ class LocalManager {
771
775
  return false; // navigating away — caller must not fire _applyRefreshedDiff
772
776
  }
773
777
 
774
- // action === 'updated' — session SHA + diff updated, continue to reload
775
- return true;
778
+ // action === 'updated' — session SHA + diff updated, continue to reload.
779
+ // Return the response data so the caller can extract branchAvailable, etc.
780
+ return data;
776
781
  }
777
782
 
778
783
  /**
@@ -806,6 +811,11 @@ class LocalManager {
806
811
  // refresh must call unconditionally since the manager won't re-fire its callback.
807
812
  await manager.loadAISuggestions(null, manager.selectedRunId);
808
813
 
814
+ // Update branchAvailable on the scope selector if the backend sent an updated value
815
+ if (result.branchAvailable !== undefined && manager.diffOptionsDropdown) {
816
+ manager.diffOptionsDropdown.branchAvailable = result.branchAvailable;
817
+ }
818
+
809
819
  // Clear stale state after successful refresh
810
820
  manager._hideStaleBadge();
811
821
  manager._stalenessPromise = null;
@@ -27,6 +27,14 @@ const { buildSparseCheckoutGuidance } = require('./prompts/sparse-checkout-guida
27
27
  /** Minimum total suggestion count across all voices before consolidation is applied */
28
28
  const COUNCIL_CONSOLIDATION_THRESHOLD = 8;
29
29
 
30
+ /**
31
+ * Common git diff flags used across all diff operations.
32
+ * - --no-color: Disable color output (guards against color.diff=always in user config)
33
+ * - --no-ext-diff: Disable external diff drivers
34
+ * - --src-prefix/--dst-prefix: Ensure consistent a/ b/ prefixes (overrides user's diff.noprefix)
35
+ */
36
+ const GIT_DIFF_COMMON_FLAGS = '--no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/';
37
+
30
38
  /**
31
39
  * Build a human-readable display label for a council voice/reviewer.
32
40
  * Uses 1-based index so logs read "Reviewer 1", "Reviewer 2", etc.
@@ -989,10 +997,10 @@ ${prMetadata.description || '(No description provided)'}
989
997
  const isLocal = prMetadata.reviewType === 'local';
990
998
  if (isLocal) {
991
999
  // For local mode, diff against HEAD to see working directory changes
992
- return suffix ? `git diff --no-ext-diff HEAD ${suffix}` : 'git diff --no-ext-diff HEAD';
1000
+ return suffix ? `git diff ${GIT_DIFF_COMMON_FLAGS} HEAD ${suffix}` : `git diff ${GIT_DIFF_COMMON_FLAGS} HEAD`;
993
1001
  }
994
1002
  // For PR mode, diff between base and head commits
995
- const baseCmd = `git diff --no-ext-diff ${prMetadata.base_sha}...${prMetadata.head_sha}`;
1003
+ const baseCmd = `git diff ${GIT_DIFF_COMMON_FLAGS} ${prMetadata.base_sha}...${prMetadata.head_sha}`;
996
1004
  return suffix ? `${baseCmd} ${suffix}` : baseCmd;
997
1005
  }
998
1006
 
package/src/database.js CHANGED
@@ -2717,6 +2717,7 @@ class ReviewRepository {
2717
2717
  * @param {Object} [updates.reviewData] - Additional review data (will be JSON stringified)
2718
2718
  * @param {string} [updates.customInstructions] - Custom instructions used for AI analysis
2719
2719
  * @param {string} [updates.summary] - AI analysis summary
2720
+ * @param {string} [updates.local_head_sha] - Local HEAD SHA
2720
2721
  * @param {Date|string} [updates.submittedAt] - Submission timestamp
2721
2722
  * @returns {Promise<boolean>} True if record was updated
2722
2723
  */
@@ -2759,6 +2760,11 @@ class ReviewRepository {
2759
2760
  params.push(updates.local_head_branch);
2760
2761
  }
2761
2762
 
2763
+ if (updates.local_head_sha !== undefined) {
2764
+ setClauses.push('local_head_sha = ?');
2765
+ params.push(updates.local_head_sha);
2766
+ }
2767
+
2762
2768
  if (updates.submittedAt !== undefined) {
2763
2769
  setClauses.push('submitted_at = ?');
2764
2770
  const submittedAt = updates.submittedAt instanceof Date
@@ -33,6 +33,14 @@ const MAX_FILE_SIZE = 1024 * 1024;
33
33
  */
34
34
  const GIT_DIFF_HAS_DIFFERENCES = 1;
35
35
 
36
+ /**
37
+ * Common git diff flags used across all diff operations.
38
+ * - --no-color: Disable color output for consistent parsing
39
+ * - --no-ext-diff: Disable external diff drivers
40
+ * - --src-prefix/--dst-prefix: Ensure consistent a/ b/ prefixes (overrides user's diff.noprefix)
41
+ */
42
+ const GIT_DIFF_COMMON_FLAGS = '--no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/';
43
+
36
44
  /**
37
45
  * Find the main git repository root, resolving through worktrees.
38
46
  * For regular repos, returns the repo root.
@@ -401,7 +409,7 @@ function generateUntrackedDiffs(repoPath, untrackedFiles, wFlag) {
401
409
  const filePath = path.join(repoPath, untracked.file);
402
410
  let fileDiff;
403
411
  try {
404
- fileDiff = execSync(`git diff --no-index --no-color --no-ext-diff${wFlag} -- /dev/null "${filePath}"`, {
412
+ fileDiff = execSync(`git diff --no-index ${GIT_DIFF_COMMON_FLAGS}${wFlag} -- /dev/null "${filePath}"`, {
405
413
  cwd: repoPath,
406
414
  encoding: 'utf8',
407
415
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -478,37 +486,37 @@ async function generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch, op
478
486
  try {
479
487
  if (hasBranch && !hasStaged && !hasUnstaged) {
480
488
  // Branch only → committed changes since merge-base
481
- diff = execSync(`git diff ${mergeBaseSha}..HEAD --no-color --no-ext-diff --unified=25${wFlag}`, {
489
+ diff = execSync(`git diff ${mergeBaseSha}..HEAD ${GIT_DIFF_COMMON_FLAGS} --unified=25${wFlag}`, {
482
490
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
483
491
  maxBuffer: 50 * 1024 * 1024
484
492
  });
485
493
  } else if (hasBranch && hasStaged && !hasUnstaged) {
486
494
  // Branch–Staged → staged changes relative to merge-base
487
- diff = execSync(`git diff --cached ${mergeBaseSha} --no-color --no-ext-diff --unified=25${wFlag}`, {
495
+ diff = execSync(`git diff --cached ${mergeBaseSha} ${GIT_DIFF_COMMON_FLAGS} --unified=25${wFlag}`, {
488
496
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
489
497
  maxBuffer: 50 * 1024 * 1024
490
498
  });
491
499
  } else if (hasBranch && hasUnstaged) {
492
500
  // Branch–Unstaged (or Branch–Untracked) → working tree vs merge-base
493
- diff = execSync(`git diff ${mergeBaseSha} --no-color --no-ext-diff --unified=25${wFlag}`, {
501
+ diff = execSync(`git diff ${mergeBaseSha} ${GIT_DIFF_COMMON_FLAGS} --unified=25${wFlag}`, {
494
502
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
495
503
  maxBuffer: 50 * 1024 * 1024
496
504
  });
497
505
  } else if (hasStaged && !hasUnstaged) {
498
506
  // Staged only → cached changes
499
- diff = execSync(`git diff --cached --no-color --no-ext-diff --unified=25${wFlag}`, {
507
+ diff = execSync(`git diff --cached ${GIT_DIFF_COMMON_FLAGS} --unified=25${wFlag}`, {
500
508
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
501
509
  maxBuffer: 50 * 1024 * 1024
502
510
  });
503
511
  } else if (hasStaged && hasUnstaged) {
504
512
  // Staged–Unstaged (or Staged–Untracked) → all changes vs HEAD
505
- diff = execSync(`git diff HEAD --no-color --no-ext-diff --unified=25${wFlag}`, {
513
+ diff = execSync(`git diff HEAD ${GIT_DIFF_COMMON_FLAGS} --unified=25${wFlag}`, {
506
514
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
507
515
  maxBuffer: 50 * 1024 * 1024
508
516
  });
509
517
  } else if (hasUnstaged) {
510
518
  // Unstaged only or Unstaged–Untracked → working tree changes
511
- diff = execSync(`git diff --no-color --no-ext-diff --unified=25${wFlag}`, {
519
+ diff = execSync(`git diff ${GIT_DIFF_COMMON_FLAGS} --unified=25${wFlag}`, {
512
520
  cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
513
521
  maxBuffer: 50 * 1024 * 1024
514
522
  });
@@ -590,7 +598,7 @@ async function computeScopedDigest(repoPath, scopeStart, scopeEnd) {
590
598
  // Staged in scope → cached diff content
591
599
  if (scopeIncludes(scopeStart, scopeEnd, 'staged')) {
592
600
  try {
593
- const result = await execAsync('git diff --cached --no-ext-diff', {
601
+ const result = await execAsync(`git diff --cached ${GIT_DIFF_COMMON_FLAGS}`, {
594
602
  cwd: repoPath, encoding: 'utf8', maxBuffer: 50 * 1024 * 1024
595
603
  });
596
604
  parts.push('STAGED:' + result.stdout);
@@ -602,7 +610,7 @@ async function computeScopedDigest(repoPath, scopeStart, scopeEnd) {
602
610
  // Unstaged in scope → working tree diff
603
611
  if (scopeIncludes(scopeStart, scopeEnd, 'unstaged')) {
604
612
  try {
605
- const result = await execAsync('git diff --no-ext-diff', {
613
+ const result = await execAsync(`git diff ${GIT_DIFF_COMMON_FLAGS}`, {
606
614
  cwd: repoPath, encoding: 'utf8', maxBuffer: 50 * 1024 * 1024
607
615
  });
608
616
  parts.push('UNSTAGED:' + result.stdout);
@@ -66,6 +66,34 @@ function deleteLocalReviewDiff(reviewId) {
66
66
  localReviewDiffs.delete(toIntKey(reviewId));
67
67
  }
68
68
 
69
+ /**
70
+ * Check whether branch scope should be selectable in the scope range selector.
71
+ * Returns true when the branch has commits ahead of the base branch.
72
+ * Non-fatal: returns false on any error.
73
+ */
74
+ async function checkBranchAvailable(localPath, branchName, scopeStart, config, repositoryName) {
75
+ if (includesBranch(scopeStart)) return true;
76
+ if (!branchName || branchName === 'HEAD' || branchName === 'unknown' || !localPath) return false;
77
+ try {
78
+ const baseBranch = require('../git/base-branch');
79
+ const depsOverride = getGitHubToken(config) ? { getGitHubToken: () => getGitHubToken(config) } : undefined;
80
+ const detection = await baseBranch.detectBaseBranch(localPath, branchName, {
81
+ repository: repositoryName,
82
+ enableGraphite: config.enable_graphite === true,
83
+ _deps: depsOverride
84
+ });
85
+ if (detection) {
86
+ // Lazy require to ensure testability via vi.spyOn on the module exports
87
+ const localReview = require('../local-review');
88
+ const commitCount = await localReview.getBranchCommitCount(localPath, detection.baseBranch);
89
+ return commitCount > 0;
90
+ }
91
+ } catch {
92
+ // Non-fatal — branch stop stays disabled
93
+ }
94
+ return false;
95
+ }
96
+
69
97
  /**
70
98
  * Delete a local review session and its in-memory diff cache.
71
99
  * Shared by both single-delete and bulk-delete routes.
@@ -575,26 +603,9 @@ router.get('/api/local/:reviewId', async (req, res) => {
575
603
  // Determine if Branch stop should be selectable in the scope range selector.
576
604
  // This is independent of branchInfo (which guards on no uncommitted changes).
577
605
  // Branch is available when: not detached HEAD, not on default branch, and has commits ahead.
578
- let branchAvailable = includesBranch(scopeStart) || Boolean(branchInfo);
579
- if (!branchAvailable && branchName && branchName !== 'HEAD' && branchName !== 'unknown' && review.local_path) {
580
- try {
581
- const { getBranchCommitCount } = require('../local-review');
582
- const { detectBaseBranch } = require('../git/base-branch');
583
- const config = req.app.get('config') || {};
584
- const depsOverride = getGitHubToken(config) ? { getGitHubToken: () => getGitHubToken(config) } : undefined;
585
- const detection = await detectBaseBranch(review.local_path, branchName, {
586
- repository: repositoryName,
587
- enableGraphite: config.enable_graphite === true,
588
- _deps: depsOverride
589
- });
590
- if (detection) {
591
- const commitCount = await getBranchCommitCount(review.local_path, detection.baseBranch);
592
- branchAvailable = commitCount > 0;
593
- }
594
- } catch {
595
- // Non-fatal — branch stop stays disabled
596
- }
597
- }
606
+ const branchAvailable = Boolean(branchInfo) || await checkBranchAvailable(
607
+ review.local_path, branchName, scopeStart, req.app.get('config') || {}, repositoryName
608
+ );
598
609
 
599
610
  // Compute SHA abbreviation length from the repo's git config
600
611
  const shaAbbrevLength = getShaAbbrevLength(review.local_path);
@@ -1364,6 +1375,15 @@ router.post('/api/local/:reviewId/refresh', async (req, res) => {
1364
1375
  logger.warn(`Could not check HEAD SHA: ${headError.message}`);
1365
1376
  }
1366
1377
 
1378
+ // Recompute branchAvailable so the frontend can update the scope selector
1379
+ // (e.g. after a commit creates the first branch-ahead commit).
1380
+ const config = req.app.get('config') || {};
1381
+ let branchName;
1382
+ try { branchName = await getCurrentBranch(localPath); } catch (_) { branchName = review.local_head_branch || null; }
1383
+ const branchAvailable = await checkBranchAvailable(
1384
+ localPath, branchName, scopeStart, config, review.repository
1385
+ );
1386
+
1367
1387
  // Non-branch HEAD change: skip diff computation entirely — the old diff is
1368
1388
  // preserved until the user decides (via resolve-head-change) what to do.
1369
1389
  // The resolve-head-change endpoint will recompute the diff for whichever
@@ -1373,6 +1393,7 @@ router.post('/api/local/:reviewId/refresh', async (req, res) => {
1373
1393
  success: true,
1374
1394
  message: 'HEAD changed — awaiting user decision',
1375
1395
  headShaChanged,
1396
+ branchAvailable,
1376
1397
  previousHeadSha: originalHeadSha,
1377
1398
  currentHeadSha: currentHeadSha || null,
1378
1399
  stats: {}
@@ -1397,6 +1418,7 @@ router.post('/api/local/:reviewId/refresh', async (req, res) => {
1397
1418
  success: true,
1398
1419
  message: 'Diff refreshed successfully',
1399
1420
  headShaChanged,
1421
+ branchAvailable,
1400
1422
  previousHeadSha: originalHeadSha,
1401
1423
  currentHeadSha: currentHeadSha || null,
1402
1424
  stats: {
@@ -1453,17 +1475,24 @@ router.post('/api/local/:reviewId/resolve-head-change', async (req, res) => {
1453
1475
  const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
1454
1476
 
1455
1477
  if (action === 'update') {
1456
- // Check for UNIQUE conflict before updating
1457
- const headBranch = review.local_head_branch || null;
1478
+ // Read live branch may differ from stored value after a checkout.
1479
+ // Lazy require to ensure testability via vi.spyOn on the module exports.
1480
+ let headBranch;
1481
+ try { headBranch = await require('../local-review').getCurrentBranch(localPath); } catch (_) { headBranch = review.local_head_branch || null; }
1482
+
1483
+ // Check for UNIQUE conflict before any mutation.
1484
+ // Use the live branch + new SHA so the conflict check targets the
1485
+ // final identity tuple (localPath, newHeadSha, headBranch).
1458
1486
  const conflict = await reviewRepo.getLocalReview(localPath, newHeadSha, headBranch);
1459
1487
  if (conflict && conflict.id !== reviewId) {
1460
1488
  logger.log('API', `UNIQUE conflict: session #${conflict.id} already exists for this HEAD`, 'yellow');
1461
1489
  return res.json({ success: true, action: 'redirect', sessionId: conflict.id });
1462
1490
  }
1463
1491
 
1464
- // Update SHA
1465
- await reviewRepo.updateLocalHeadSha(reviewId, newHeadSha);
1466
- logger.log('API', `Updated HEAD SHA on session ${reviewId}`, 'cyan');
1492
+ // Persist SHA and branch together in a single write so SQLite only
1493
+ // ever sees the final identity tuple — no transient intermediate state.
1494
+ await reviewRepo.updateReview(reviewId, { local_head_sha: newHeadSha, local_head_branch: headBranch });
1495
+ logger.log('API', `Updated HEAD SHA and branch on session ${reviewId}`, 'cyan');
1467
1496
 
1468
1497
  // Recompute and persist diff
1469
1498
  const scopedResult = await generateScopedDiff(localPath, scopeStart, scopeEnd, review.local_base_branch);
@@ -1475,7 +1504,14 @@ router.post('/api/local/:reviewId/resolve-head-change', async (req, res) => {
1475
1504
  logger.warn(`Could not persist diff to database: ${persistError.message}`);
1476
1505
  }
1477
1506
 
1478
- return res.json({ success: true, action: 'updated' });
1507
+ // Recompute branchAvailable the commit may have created the first
1508
+ // branch-ahead commit, making the Branch scope stop selectable.
1509
+ const config = req.app.get('config') || {};
1510
+ const branchAvailable = await checkBranchAvailable(
1511
+ localPath, headBranch, scopeStart, config, review.repository
1512
+ );
1513
+
1514
+ return res.json({ success: true, action: 'updated', branchAvailable });
1479
1515
  }
1480
1516
 
1481
1517
  // action === 'new-session'