@in-the-loop-labs/pair-review 2.6.3 → 3.0.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.
Files changed (150) hide show
  1. package/.pi/extensions/task/index.ts +1 -1
  2. package/.pi/skills/review-roulette/SKILL.md +1 -1
  3. package/LICENSE +201 -674
  4. package/README.md +2 -2
  5. package/bin/pair-review.js +1 -1
  6. package/package.json +2 -2
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin-code-critic/.claude-plugin/plugin.json +2 -2
  9. package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +1 -1
  10. package/public/css/ai-summary-modal.css +1 -1
  11. package/public/css/pr.css +194 -0
  12. package/public/index.html +168 -3
  13. package/public/js/components/AIPanel.js +17 -3
  14. package/public/js/components/AISummaryModal.js +1 -1
  15. package/public/js/components/AdvancedConfigTab.js +1 -1
  16. package/public/js/components/AnalysisConfigModal.js +1 -1
  17. package/public/js/components/ChatPanel.js +42 -7
  18. package/public/js/components/ConfirmDialog.js +22 -3
  19. package/public/js/components/CouncilProgressModal.js +14 -1
  20. package/public/js/components/DiffOptionsDropdown.js +411 -24
  21. package/public/js/components/EmojiPicker.js +1 -1
  22. package/public/js/components/KeyboardShortcuts.js +1 -1
  23. package/public/js/components/PanelGroup.js +1 -1
  24. package/public/js/components/PreviewModal.js +1 -1
  25. package/public/js/components/ReviewModal.js +1 -1
  26. package/public/js/components/SplitButton.js +1 -1
  27. package/public/js/components/StatusIndicator.js +1 -1
  28. package/public/js/components/SuggestionNavigator.js +13 -6
  29. package/public/js/components/TabTitle.js +96 -0
  30. package/public/js/components/TextInputDialog.js +1 -1
  31. package/public/js/components/TimeoutSelect.js +1 -1
  32. package/public/js/components/Toast.js +7 -1
  33. package/public/js/components/VoiceCentricConfigTab.js +1 -1
  34. package/public/js/index.js +649 -44
  35. package/public/js/local.js +570 -77
  36. package/public/js/modules/analysis-history.js +4 -3
  37. package/public/js/modules/comment-manager.js +6 -1
  38. package/public/js/modules/comment-minimizer.js +304 -0
  39. package/public/js/modules/diff-context.js +1 -1
  40. package/public/js/modules/diff-renderer.js +1 -1
  41. package/public/js/modules/file-comment-manager.js +1 -1
  42. package/public/js/modules/file-list-merger.js +1 -1
  43. package/public/js/modules/gap-coordinates.js +1 -1
  44. package/public/js/modules/hunk-parser.js +1 -1
  45. package/public/js/modules/line-tracker.js +1 -1
  46. package/public/js/modules/panel-resizer.js +1 -1
  47. package/public/js/modules/storage-cleanup.js +1 -1
  48. package/public/js/modules/suggestion-manager.js +1 -1
  49. package/public/js/pr.js +83 -7
  50. package/public/js/repo-settings.js +1 -1
  51. package/public/js/utils/category-emoji.js +1 -1
  52. package/public/js/utils/file-order.js +1 -1
  53. package/public/js/utils/markdown.js +1 -1
  54. package/public/js/utils/suggestion-ui.js +1 -1
  55. package/public/js/utils/tier-icons.js +1 -1
  56. package/public/js/utils/time.js +1 -1
  57. package/public/js/ws-client.js +1 -1
  58. package/public/local.html +14 -0
  59. package/public/pr.html +3 -0
  60. package/public/setup.html +1 -1
  61. package/src/ai/analyzer.js +18 -12
  62. package/src/ai/claude-cli.js +1 -1
  63. package/src/ai/claude-provider.js +1 -1
  64. package/src/ai/codex-provider.js +1 -1
  65. package/src/ai/copilot-provider.js +1 -1
  66. package/src/ai/cursor-agent-provider.js +1 -1
  67. package/src/ai/gemini-provider.js +1 -1
  68. package/src/ai/index.js +1 -1
  69. package/src/ai/opencode-provider.js +1 -1
  70. package/src/ai/pi-provider.js +1 -1
  71. package/src/ai/prompts/baseline/consolidation/balanced.js +1 -1
  72. package/src/ai/prompts/baseline/consolidation/fast.js +1 -1
  73. package/src/ai/prompts/baseline/consolidation/thorough.js +1 -1
  74. package/src/ai/prompts/baseline/level1/balanced.js +1 -1
  75. package/src/ai/prompts/baseline/level1/fast.js +1 -1
  76. package/src/ai/prompts/baseline/level1/thorough.js +1 -1
  77. package/src/ai/prompts/baseline/level2/balanced.js +1 -1
  78. package/src/ai/prompts/baseline/level2/fast.js +1 -1
  79. package/src/ai/prompts/baseline/level2/thorough.js +1 -1
  80. package/src/ai/prompts/baseline/level3/balanced.js +1 -1
  81. package/src/ai/prompts/baseline/level3/fast.js +1 -1
  82. package/src/ai/prompts/baseline/level3/thorough.js +1 -1
  83. package/src/ai/prompts/baseline/orchestration/balanced.js +1 -1
  84. package/src/ai/prompts/baseline/orchestration/fast.js +1 -1
  85. package/src/ai/prompts/baseline/orchestration/thorough.js +1 -1
  86. package/src/ai/prompts/config.js +1 -1
  87. package/src/ai/prompts/index.js +1 -1
  88. package/src/ai/prompts/line-number-guidance.js +1 -1
  89. package/src/ai/prompts/render-for-skill.js +1 -1
  90. package/src/ai/prompts/shared/diff-instructions.js +1 -1
  91. package/src/ai/prompts/shared/output-schema.js +1 -1
  92. package/src/ai/prompts/shared/valid-files.js +1 -1
  93. package/src/ai/prompts/sparse-checkout-guidance.js +1 -1
  94. package/src/ai/provider-availability.js +1 -1
  95. package/src/ai/provider.js +1 -1
  96. package/src/ai/stream-parser.js +1 -1
  97. package/src/chat/acp-bridge.js +1 -1
  98. package/src/chat/api-reference.js +1 -1
  99. package/src/chat/chat-providers.js +1 -1
  100. package/src/chat/claude-code-bridge.js +1 -1
  101. package/src/chat/codex-bridge.js +1 -1
  102. package/src/chat/pi-bridge.js +1 -1
  103. package/src/chat/prompt-builder.js +1 -1
  104. package/src/chat/session-manager.js +1 -1
  105. package/src/config.js +3 -1
  106. package/src/database.js +591 -40
  107. package/src/events/review-events.js +1 -1
  108. package/src/git/base-branch.js +173 -0
  109. package/src/git/gitattributes.js +1 -1
  110. package/src/git/sha-abbrev.js +35 -0
  111. package/src/git/worktree.js +1 -1
  112. package/src/github/client.js +33 -2
  113. package/src/github/parser.js +1 -1
  114. package/src/hooks/hook-runner.js +100 -0
  115. package/src/hooks/payloads.js +212 -0
  116. package/src/local-review.js +469 -130
  117. package/src/local-scope.js +58 -0
  118. package/src/main.js +56 -5
  119. package/src/mcp-stdio.js +1 -1
  120. package/src/protocol-handler.js +1 -1
  121. package/src/routes/analyses.js +74 -11
  122. package/src/routes/chat.js +34 -1
  123. package/src/routes/config.js +2 -1
  124. package/src/routes/context-files.js +1 -1
  125. package/src/routes/councils.js +1 -1
  126. package/src/routes/github-collections.js +1 -1
  127. package/src/routes/local.js +735 -69
  128. package/src/routes/mcp.js +21 -11
  129. package/src/routes/pr.js +91 -13
  130. package/src/routes/reviews.js +1 -1
  131. package/src/routes/setup.js +2 -1
  132. package/src/routes/shared.js +1 -1
  133. package/src/routes/worktrees.js +213 -149
  134. package/src/server.js +31 -1
  135. package/src/setup/local-setup.js +47 -6
  136. package/src/setup/pr-setup.js +29 -6
  137. package/src/utils/auto-context.js +1 -1
  138. package/src/utils/category-emoji.js +1 -1
  139. package/src/utils/comment-formatter.js +1 -1
  140. package/src/utils/diff-annotator.js +1 -1
  141. package/src/utils/diff-file-list.js +1 -1
  142. package/src/utils/instructions.js +1 -1
  143. package/src/utils/json-extractor.js +1 -1
  144. package/src/utils/line-validation.js +1 -1
  145. package/src/utils/logger.js +1 -1
  146. package/src/utils/paths.js +1 -1
  147. package/src/utils/safe-parse-json.js +1 -1
  148. package/src/utils/stats-calculator.js +1 -1
  149. package/src/ws/index.js +1 -1
  150. package/src/ws/server.js +1 -1
@@ -1,16 +1,20 @@
1
- // SPDX-License-Identifier: GPL-3.0-or-later
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  const { execSync, exec } = require('child_process');
3
3
  const { promisify } = require('util');
4
4
  const crypto = require('crypto');
5
5
  const path = require('path');
6
6
  const fs = require('fs').promises;
7
- const { loadConfig, showWelcomeMessage, resolveDbName } = require('./config');
7
+ const { loadConfig, showWelcomeMessage, resolveDbName, getGitHubToken } = require('./config');
8
8
  const logger = require('./utils/logger');
9
+ const { fireHooks, hasHooks } = require('./hooks/hook-runner');
10
+ const { buildReviewStartedPayload, buildReviewLoadedPayload, getCachedUser } = require('./hooks/payloads');
9
11
 
10
12
  const execAsync = promisify(exec);
13
+ const { STOPS, scopeIncludes, includesBranch, DEFAULT_SCOPE, scopeLabel } = require('./local-scope');
11
14
  const { initializeDatabase, ReviewRepository, RepoSettingsRepository } = require('./database');
12
15
  const { startServer } = require('./server');
13
16
  const { localReviewDiffs } = require('./routes/shared');
17
+ const { getShaAbbrevLength } = require('./git/sha-abbrev');
14
18
  const open = (...args) => import('open').then(({ default: open }) => open(...args));
15
19
 
16
20
  // Design note: This module uses execSync for git commands despite async function signatures.
@@ -360,98 +364,59 @@ async function getUntrackedFiles(repoPath) {
360
364
  }
361
365
 
362
366
  /**
363
- * Generate diff for local changes (unstaged only, not staged)
364
- * Local mode shows unstaged changes + untracked files, NOT staged changes.
365
- * This allows users to stage files to "hide" them from the review.
367
+ * Find merge-base between baseBranch and HEAD using local refs.
368
+ * This is only used in local review mode where the local ref is authoritative.
366
369
  * @param {string} repoPath - Path to the git repository
367
- * @returns {Promise<{diff: string, untrackedFiles: Array, stats: Object}>}
370
+ * @param {string} baseBranch - Base branch name
371
+ * @returns {Promise<string>} Merge-base SHA
368
372
  */
369
- async function generateLocalDiff(repoPath, options = {}) {
370
- let diff = '';
371
- const wFlag = options.hideWhitespace ? ' -w' : '';
372
- const stats = {
373
- trackedChanges: 0,
374
- untrackedFiles: 0,
375
- stagedChanges: 0,
376
- unstagedChanges: 0
377
- };
373
+ async function findMergeBase(repoPath, baseBranch) {
374
+ if (!baseBranch || !/^[\w.\-\/]+$/.test(baseBranch)) {
375
+ throw new Error(`Invalid branch name: ${baseBranch}`);
376
+ }
378
377
 
379
378
  try {
380
- // Count staged changes for stats (but don't include in diff)
381
- // This is informational only - staged files are excluded from review
382
- const stagedDiff = execSync(`git diff --cached --no-color --no-ext-diff --unified=25${wFlag}`, {
383
- cwd: repoPath,
384
- encoding: 'utf8',
385
- stdio: ['pipe', 'pipe', 'pipe'],
386
- maxBuffer: 50 * 1024 * 1024 // 50MB buffer
387
- });
388
-
389
- if (stagedDiff.trim()) {
390
- stats.stagedChanges = (stagedDiff.match(/^diff --git/gm) || []).length;
391
- }
392
-
393
- // Get unstaged changes to tracked files (this is what we show in the review)
394
- const unstagedDiff = execSync(`git diff --no-color --no-ext-diff --unified=25${wFlag}`, {
379
+ return execSync(`git merge-base ${baseBranch} HEAD`, {
395
380
  cwd: repoPath,
396
381
  encoding: 'utf8',
397
- stdio: ['pipe', 'pipe', 'pipe'],
398
- maxBuffer: 50 * 1024 * 1024 // 50MB buffer
399
- });
400
-
401
- if (unstagedDiff.trim()) {
402
- diff += unstagedDiff;
403
- stats.unstagedChanges = (unstagedDiff.match(/^diff --git/gm) || []).length;
404
- }
405
-
406
- stats.trackedChanges = stats.unstagedChanges;
407
-
382
+ stdio: ['pipe', 'pipe', 'pipe']
383
+ }).trim();
408
384
  } catch (error) {
409
- // Check for buffer overflow errors and provide clear user feedback
410
- if (error.message && error.message.includes('maxBuffer')) {
411
- console.error(`Error: Diff output exceeded the 50MB buffer limit.`);
412
- console.error(`This typically happens with very large repositories or binary files in the diff.`);
413
- console.error(`Consider staging some files to exclude them from the review.`);
414
- throw new Error('Diff output exceeded maximum buffer size (50MB). Try staging some files to reduce the diff size.');
415
- }
416
- console.warn(`Warning: Could not generate diff for tracked files: ${error.message}`);
385
+ throw new Error(`Could not find merge-base between ${baseBranch} and HEAD: ${error.message}`);
417
386
  }
387
+ }
418
388
 
419
- // Get untracked files
420
- const untrackedFiles = await getUntrackedFiles(repoPath);
421
- stats.untrackedFiles = untrackedFiles.length;
422
-
423
- // Generate authentic git diff for untracked files using git diff --no-index
389
+ /**
390
+ * Generate diff output for untracked files using git diff --no-index.
391
+ * @param {string} repoPath - Path to the git repository
392
+ * @param {Array} untrackedFiles - Array from getUntrackedFiles()
393
+ * @param {string} wFlag - Whitespace flag (e.g. ' -w' or '')
394
+ * @returns {string} Combined diff text for untracked files
395
+ */
396
+ function generateUntrackedDiffs(repoPath, untrackedFiles, wFlag) {
397
+ let diff = '';
424
398
  for (const untracked of untrackedFiles) {
425
399
  if (!untracked.skipped) {
426
400
  try {
427
401
  const filePath = path.join(repoPath, untracked.file);
428
- // git diff --no-index exits with code 1 when files differ, code 0 when identical
429
402
  let fileDiff;
430
403
  try {
431
404
  fileDiff = execSync(`git diff --no-index --no-color --no-ext-diff${wFlag} -- /dev/null "${filePath}"`, {
432
405
  cwd: repoPath,
433
406
  encoding: 'utf8',
434
407
  stdio: ['pipe', 'pipe', 'pipe'],
435
- maxBuffer: 10 * 1024 * 1024 // 10MB buffer per file
408
+ maxBuffer: 10 * 1024 * 1024
436
409
  });
437
410
  } catch (diffError) {
438
- // git diff --no-index returns exit code 1 when files differ (expected case)
439
- // Exit code 1 with stdout means files differ - this is the normal case for new files
440
- // Defensive check: ensure diffError is an object with expected properties
441
411
  if (diffError && typeof diffError === 'object' &&
442
412
  diffError.status === GIT_DIFF_HAS_DIFFERENCES && typeof diffError.stdout === 'string') {
443
413
  fileDiff = diffError.stdout;
444
414
  } else {
445
- // Any other error (status !== 1 or no stdout) is a real error
446
415
  throw diffError;
447
416
  }
448
417
  }
449
418
 
450
419
  if (fileDiff && fileDiff.trim()) {
451
- // The diff output from git diff --no-index shows the absolute path (without leading /),
452
- // e.g., "diff --git a/Users/tim/src/repo/file.js b/Users/tim/src/repo/file.js"
453
- // We need to normalize this to relative paths from the repo root,
454
- // e.g., "diff --git a/file.js b/file.js"
455
420
  const normalizedDiff = fileDiff
456
421
  .replace(/^diff --git a\/.+? b\/.+$/m, `diff --git a/${untracked.file} b/${untracked.file}`)
457
422
  .replace(/^\+\+\+ b\/.+$/m, `+++ b/${untracked.file}`);
@@ -462,15 +427,249 @@ async function generateLocalDiff(repoPath, options = {}) {
462
427
  diff += normalizedDiff;
463
428
  }
464
429
  } catch (fileError) {
465
- console.warn(`Warning: Could not generate diff for untracked file ${untracked.file}: ${fileError.message}`);
430
+ logger.warn(`Could not generate diff for untracked file ${untracked.file}: ${fileError.message}`);
466
431
  }
467
432
  }
468
433
  }
434
+ return diff;
435
+ }
436
+
437
+ /**
438
+ * Generate diff for a given scope range.
439
+ *
440
+ * Scope stops: branch → staged → unstaged → untracked
441
+ * When branch is in scope, diffs anchor against merge-base.
442
+ * Otherwise, diffs anchor against HEAD (staged) or INDEX (unstaged).
443
+ *
444
+ * @param {string} repoPath - Path to the git repository
445
+ * @param {string} scopeStart - Start of scope range (e.g. 'unstaged', 'branch')
446
+ * @param {string} scopeEnd - End of scope range (e.g. 'untracked', 'branch')
447
+ * @param {string} [baseBranch] - Base branch name (required when branch is in scope)
448
+ * @param {Object} [options]
449
+ * @param {boolean} [options.hideWhitespace] - Whether to hide whitespace changes
450
+ * @returns {Promise<{diff: string, stats: Object, mergeBaseSha: string|null}>}
451
+ */
452
+ async function generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch, options = {}) {
453
+ const wFlag = options.hideWhitespace ? ' -w' : '';
454
+ const stats = {
455
+ trackedChanges: 0,
456
+ untrackedFiles: 0,
457
+ stagedChanges: 0,
458
+ unstagedChanges: 0
459
+ };
460
+
461
+ const hasBranch = scopeIncludes(scopeStart, scopeEnd, 'branch');
462
+ const hasStaged = scopeIncludes(scopeStart, scopeEnd, 'staged');
463
+ const hasUnstaged = scopeIncludes(scopeStart, scopeEnd, 'unstaged');
464
+ const hasUntracked = scopeIncludes(scopeStart, scopeEnd, 'untracked');
465
+
466
+ let mergeBaseSha = null;
467
+ let diff = '';
468
+
469
+ // Resolve merge-base when branch is in scope
470
+ if (hasBranch) {
471
+ if (!baseBranch) {
472
+ throw new Error('baseBranch is required when scope includes branch');
473
+ }
474
+ mergeBaseSha = await findMergeBase(repoPath, baseBranch);
475
+ }
476
+
477
+ // Build the git diff command based on scope range
478
+ try {
479
+ if (hasBranch && !hasStaged && !hasUnstaged) {
480
+ // Branch only → committed changes since merge-base
481
+ diff = execSync(`git diff ${mergeBaseSha}..HEAD --no-color --no-ext-diff --unified=25${wFlag}`, {
482
+ cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
483
+ maxBuffer: 50 * 1024 * 1024
484
+ });
485
+ } else if (hasBranch && hasStaged && !hasUnstaged) {
486
+ // Branch–Staged → staged changes relative to merge-base
487
+ diff = execSync(`git diff --cached ${mergeBaseSha} --no-color --no-ext-diff --unified=25${wFlag}`, {
488
+ cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
489
+ maxBuffer: 50 * 1024 * 1024
490
+ });
491
+ } else if (hasBranch && hasUnstaged) {
492
+ // Branch–Unstaged (or Branch–Untracked) → working tree vs merge-base
493
+ diff = execSync(`git diff ${mergeBaseSha} --no-color --no-ext-diff --unified=25${wFlag}`, {
494
+ cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
495
+ maxBuffer: 50 * 1024 * 1024
496
+ });
497
+ } else if (hasStaged && !hasUnstaged) {
498
+ // Staged only → cached changes
499
+ diff = execSync(`git diff --cached --no-color --no-ext-diff --unified=25${wFlag}`, {
500
+ cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
501
+ maxBuffer: 50 * 1024 * 1024
502
+ });
503
+ } else if (hasStaged && hasUnstaged) {
504
+ // Staged–Unstaged (or Staged–Untracked) → all changes vs HEAD
505
+ diff = execSync(`git diff HEAD --no-color --no-ext-diff --unified=25${wFlag}`, {
506
+ cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
507
+ maxBuffer: 50 * 1024 * 1024
508
+ });
509
+ } else if (hasUnstaged) {
510
+ // Unstaged only or Unstaged–Untracked → working tree changes
511
+ diff = execSync(`git diff --no-color --no-ext-diff --unified=25${wFlag}`, {
512
+ cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
513
+ maxBuffer: 50 * 1024 * 1024
514
+ });
515
+ }
516
+ // hasUntracked-only: no git diff needed, just untracked files below
517
+ } catch (error) {
518
+ if (error.message && error.message.includes('maxBuffer')) {
519
+ throw new Error('Diff output exceeded maximum buffer size (50MB).');
520
+ }
521
+ throw new Error(`Failed to generate scoped diff: ${error.message}`);
522
+ }
523
+
524
+ if (diff.trim()) {
525
+ stats.trackedChanges = (diff.match(/^diff --git/gm) || []).length;
526
+ }
527
+
528
+ // Count staged/unstaged for stats when relevant
529
+ if (hasStaged) {
530
+ try {
531
+ const stagedDiff = execSync(`git diff --cached --stat --no-color --no-ext-diff`, {
532
+ cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
533
+ });
534
+ if (stagedDiff.trim()) {
535
+ stats.stagedChanges = (stagedDiff.match(/\|/g) || []).length;
536
+ }
537
+ } catch { /* non-critical */ }
538
+ }
539
+ if (hasUnstaged) {
540
+ try {
541
+ const unstagedDiff = execSync(`git diff --stat --no-color --no-ext-diff`, {
542
+ cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
543
+ });
544
+ if (unstagedDiff.trim()) {
545
+ stats.unstagedChanges = (unstagedDiff.match(/\|/g) || []).length;
546
+ }
547
+ } catch { /* non-critical */ }
548
+ }
549
+
550
+ // Append untracked file diffs
551
+ if (hasUntracked) {
552
+ const untrackedFiles = await getUntrackedFiles(repoPath);
553
+ stats.untrackedFiles = untrackedFiles.length;
554
+
555
+ const untrackedDiff = generateUntrackedDiffs(repoPath, untrackedFiles, wFlag);
556
+ if (untrackedDiff) {
557
+ if (diff) diff += '\n';
558
+ diff += untrackedDiff;
559
+ }
560
+ }
561
+
562
+ return { diff, stats, mergeBaseSha };
563
+ }
564
+
565
+ /**
566
+ * Compute a content digest for the current scope.
567
+ * Used for staleness detection — if the digest changes, the scope content changed.
568
+ *
569
+ * @param {string} repoPath - Path to the git repository
570
+ * @param {string} scopeStart - Start of scope range
571
+ * @param {string} scopeEnd - End of scope range
572
+ * @returns {Promise<string|null>} 16-char hex digest, or null on error
573
+ */
574
+ async function computeScopedDigest(repoPath, scopeStart, scopeEnd) {
575
+ let hasError = false;
576
+ const parts = [];
577
+
578
+ // Branch in scope → HEAD SHA matters
579
+ if (scopeIncludes(scopeStart, scopeEnd, 'branch')) {
580
+ try {
581
+ const result = await execAsync('git rev-parse HEAD', {
582
+ cwd: repoPath, encoding: 'utf8'
583
+ });
584
+ parts.push('HEAD:' + result.stdout.trim());
585
+ } catch {
586
+ hasError = true;
587
+ }
588
+ }
589
+
590
+ // Staged in scope → cached diff content
591
+ if (scopeIncludes(scopeStart, scopeEnd, 'staged')) {
592
+ try {
593
+ const result = await execAsync('git diff --cached --no-ext-diff', {
594
+ cwd: repoPath, encoding: 'utf8', maxBuffer: 50 * 1024 * 1024
595
+ });
596
+ parts.push('STAGED:' + result.stdout);
597
+ } catch {
598
+ hasError = true;
599
+ }
600
+ }
601
+
602
+ // Unstaged in scope → working tree diff
603
+ if (scopeIncludes(scopeStart, scopeEnd, 'unstaged')) {
604
+ try {
605
+ const result = await execAsync('git diff --no-ext-diff', {
606
+ cwd: repoPath, encoding: 'utf8', maxBuffer: 50 * 1024 * 1024
607
+ });
608
+ parts.push('UNSTAGED:' + result.stdout);
609
+ } catch {
610
+ hasError = true;
611
+ }
612
+ }
613
+
614
+ // Untracked in scope → file list with sizes/mtimes
615
+ if (scopeIncludes(scopeStart, scopeEnd, 'untracked')) {
616
+ try {
617
+ const result = await execAsync('git ls-files --others --exclude-standard', {
618
+ cwd: repoPath, encoding: 'utf8'
619
+ });
620
+ const files = result.stdout.trim().split('\n').filter(f => f.length > 0);
621
+ let untrackedInfo = '';
622
+ for (const file of files) {
623
+ try {
624
+ const fileStat = await fs.stat(path.join(repoPath, file));
625
+ untrackedInfo += `${file}:${fileStat.size}:${fileStat.mtimeMs}\n`;
626
+ } catch {
627
+ untrackedInfo += `${file}:missing\n`;
628
+ }
629
+ }
630
+ parts.push('UNTRACKED:' + untrackedInfo);
631
+ } catch {
632
+ hasError = true;
633
+ }
634
+ }
635
+
636
+ if (hasError && parts.length === 0) {
637
+ return null;
638
+ }
639
+
640
+ const combined = parts.join('\n---\n');
641
+ return crypto.createHash('sha256').update(combined).digest('hex').substring(0, 16);
642
+ }
643
+
644
+ /**
645
+ * Generate diff for local changes (unstaged + untracked).
646
+ * Thin wrapper around generateScopedDiff with legacy return shape.
647
+ * @param {string} repoPath - Path to the git repository
648
+ * @param {Object} [options]
649
+ * @param {boolean} [options.hideWhitespace] - Whether to hide whitespace changes
650
+ * @returns {Promise<{diff: string, untrackedFiles: Array, stats: Object}>}
651
+ */
652
+ async function generateLocalDiff(repoPath, options = {}) {
653
+ const result = await generateScopedDiff(repoPath, 'unstaged', 'untracked', null, options);
654
+ // Preserve legacy untrackedFiles field
655
+ const untrackedFiles = await getUntrackedFiles(repoPath);
656
+
657
+ // Always count staged changes for CLI info message, even when staged is out of scope
658
+ if (!result.stats.stagedChanges) {
659
+ try {
660
+ const stagedStat = execSync('git diff --cached --stat --no-color --no-ext-diff', {
661
+ cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
662
+ });
663
+ if (stagedStat.trim()) {
664
+ result.stats.stagedChanges = (stagedStat.match(/\|/g) || []).length;
665
+ }
666
+ } catch { /* non-critical */ }
667
+ }
469
668
 
470
669
  return {
471
- diff,
670
+ diff: result.diff,
472
671
  untrackedFiles,
473
- stats
672
+ stats: result.stats
474
673
  };
475
674
  }
476
675
 
@@ -544,41 +743,101 @@ async function handleLocalReview(targetPath, flags = {}) {
544
743
  }
545
744
 
546
745
  console.log('Checking for existing review session...');
547
- const existingReview = await reviewRepo.getLocalReview(repoPath, headSha);
746
+ let existingReview = await reviewRepo.getLocalReview(repoPath, headSha, branch);
747
+
748
+ if (!existingReview) {
749
+ // Adopt legacy sessions that predate branch tracking (local_head_branch is NULL)
750
+ const legacy = await reviewRepo.getLocalReviewByPathAndSha(repoPath, headSha);
751
+ if (legacy && legacy.local_head_branch === null) {
752
+ existingReview = legacy;
753
+ }
754
+ }
755
+
756
+ if (!existingReview) {
757
+ // Check for existing branch-scope session on this path
758
+ // (branch scope sessions persist across HEAD changes)
759
+ const branchSession = await reviewRepo.getLocalBranchReview(repoPath, branch);
760
+ if (branchSession) {
761
+ existingReview = branchSession;
762
+ }
763
+ }
548
764
 
549
765
  let sessionId;
550
766
  if (existingReview) {
551
- console.log(`Resuming existing review session (ID: ${existingReview.id})`);
552
767
  sessionId = existingReview.id;
768
+ // Update HEAD SHA if it changed (branch mode: new commits on same branch)
769
+ if (existingReview.local_head_sha !== headSha) {
770
+ await reviewRepo.updateLocalHeadSha(sessionId, headSha);
771
+ const abbrevLen = getShaAbbrevLength(repoPath);
772
+ console.log(`Updated HEAD SHA on session ${sessionId}: ${existingReview.local_head_sha.substring(0, abbrevLen)} -> ${headSha.substring(0, abbrevLen)}`);
773
+ }
774
+ // Backfill branch on legacy sessions
775
+ if (existingReview.local_head_branch === null) {
776
+ await reviewRepo.updateReview(sessionId, { local_head_branch: branch });
777
+ console.log(`Backfilled branch on session ${sessionId}: ${branch}`);
778
+ }
779
+ console.log(`Resuming existing review session (ID: ${existingReview.id})`);
553
780
  } else {
554
781
  console.log('Creating new review session...');
555
782
  sessionId = await reviewRepo.upsertLocalReview({
556
783
  localPath: repoPath,
557
784
  localHeadSha: headSha,
558
- repository
785
+ repository,
786
+ localHeadBranch: branch
559
787
  });
560
788
  console.log(`Created new review session (ID: ${sessionId})`);
561
789
  }
562
790
 
563
- // Generate local diff
564
- console.log('Generating diff for local changes...');
565
- const { diff, untrackedFiles, stats } = await generateLocalDiff(repoPath);
566
-
567
- if (!diff && untrackedFiles.length === 0) {
568
- console.log('\nNo local changes detected. The UI will open anyway - you can make changes and refresh.');
569
- } else {
570
- console.log(`Found changes:`);
571
- if (stats.unstagedChanges > 0) {
572
- console.log(` - ${stats.unstagedChanges} unstaged file(s)`);
573
- }
574
- if (stats.untrackedFiles > 0) {
575
- const skipped = untrackedFiles.filter(f => f.skipped).length;
576
- const included = stats.untrackedFiles - skipped;
577
- console.log(` - ${included} untracked file(s)${skipped > 0 ? ` (${skipped} skipped)` : ''}`);
791
+ // Read scope from session (or use defaults for new sessions)
792
+ const scopeStart = existingReview?.local_scope_start || DEFAULT_SCOPE.start;
793
+ const scopeEnd = existingReview?.local_scope_end || DEFAULT_SCOPE.end;
794
+
795
+ // Fire review hook (non-blocking)
796
+ const hookEvent = existingReview ? 'review.loaded' : 'review.started';
797
+ if (hasHooks(hookEvent, config)) {
798
+ getCachedUser(config).then(user => {
799
+ const builder = existingReview ? buildReviewLoadedPayload : buildReviewStartedPayload;
800
+ const si = STOPS.indexOf(scopeStart);
801
+ const ei = STOPS.indexOf(scopeEnd);
802
+ const scope = STOPS.slice(si, ei + 1);
803
+ const payload = builder({ reviewId: sessionId, mode: 'local', localContext: { path: repoPath, branch, headSha, scope }, user });
804
+ fireHooks(hookEvent, payload, config);
805
+ }).catch(err => { logger.warn(`Review hook failed: ${err.message}`); });
578
806
  }
579
- if (stats.stagedChanges > 0) {
580
- console.log(` - ${stats.stagedChanges} staged file(s) (excluded from review)`);
807
+ const baseBranch = existingReview?.local_base_branch || null;
808
+
809
+ // Generate diff using session's actual scope
810
+ console.log(`Generating diff for scope: ${scopeLabel(scopeStart, scopeEnd)}...`);
811
+ const { diff, stats } = await generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch);
812
+
813
+ // Branch detection: when scope does NOT include branch and no uncommitted changes,
814
+ // check if branch has commits ahead (frontend uses this to suggest expanding scope)
815
+ let branchInfo = null;
816
+ if (!includesBranch(scopeStart)) {
817
+ const untrackedFiles = await getUntrackedFiles(repoPath);
818
+ branchInfo = await detectAndBuildBranchInfo(repoPath, branch, {
819
+ repository,
820
+ diff,
821
+ untrackedFiles,
822
+ githubToken: getGitHubToken(config),
823
+ enableGraphite: config.enable_graphite === true
824
+ });
825
+ if (branchInfo) {
826
+ console.log(`\nNo uncommitted changes, but branch has ${branchInfo.commitCount} commit(s) ahead of ${branchInfo.baseBranch}.`);
827
+ console.log('The UI will offer to review branch changes.');
828
+ }
581
829
  }
830
+
831
+ if (!diff && !branchInfo) {
832
+ console.log('\nNo changes detected in current scope. The UI will open anyway - you can change scope or make changes and refresh.');
833
+ } else if (diff) {
834
+ console.log(`Found ${stats.trackedChanges || 0} file(s) changed`);
835
+ if (stats.untrackedFiles > 0) {
836
+ console.log(` - ${stats.untrackedFiles} untracked file(s)`);
837
+ }
838
+ if (stats.stagedChanges > 0 && !scopeIncludes(scopeStart, scopeEnd, 'staged')) {
839
+ console.log(` - ${stats.stagedChanges} staged file(s) (outside current scope)`);
840
+ }
582
841
  }
583
842
 
584
843
  // Set environment variables for local mode (metadata only, not large data)
@@ -591,10 +850,10 @@ async function handleLocalReview(targetPath, flags = {}) {
591
850
 
592
851
  // Compute baseline digest NOW for accurate staleness detection later
593
852
  // This must be done at diff-capture time, not lazily at check time
594
- const digest = await computeLocalDiffDigest(repoPath);
853
+ const digest = await computeScopedDigest(repoPath, scopeStart, scopeEnd);
595
854
 
596
855
  // Store diff data in module-level Map (avoids process.env size limits and security concerns)
597
- localReviewDiffs.set(sessionId, { diff, stats, digest });
856
+ localReviewDiffs.set(sessionId, { diff, stats, digest, branchInfo });
598
857
 
599
858
  // Persist diff to database so past sessions remain viewable without the server running
600
859
  try {
@@ -646,59 +905,132 @@ async function handleLocalReview(targetPath, flags = {}) {
646
905
  }
647
906
 
648
907
  /**
649
- * Compute a hash digest of local changes for staleness detection
650
- * Uses git diff output which captures actual content changes
651
- * Returns null on error (caller should treat as stale to be safe)
908
+ * Compute a hash digest of local changes for staleness detection.
909
+ * Thin wrapper around computeScopedDigest with scope unstaged→untracked.
652
910
  * @param {string} localPath - Path to the local git repository
653
911
  * @returns {Promise<string|null>} 16-character hex digest or null on error
654
912
  */
655
913
  async function computeLocalDiffDigest(localPath) {
656
- let hasError = false;
914
+ return computeScopedDigest(localPath, 'unstaged', 'untracked');
915
+ }
657
916
 
658
- // Get unstaged diff (the actual content being reviewed)
659
- let unstagedDiff = '';
660
- try {
661
- const result = await execAsync('git diff --no-ext-diff', {
662
- cwd: localPath,
663
- encoding: 'utf8',
664
- maxBuffer: 50 * 1024 * 1024
665
- });
666
- unstagedDiff = result.stdout;
667
- } catch (e) {
668
- hasError = true;
917
+ /**
918
+ * Generate diff for committed branch changes against a base branch.
919
+ * Thin wrapper around generateScopedDiff with scope branch→branch.
920
+ *
921
+ * @param {string} repoPath - Path to the git repository
922
+ * @param {string} baseBranch - Base branch name (e.g. 'main')
923
+ * @param {Object} [options]
924
+ * @param {boolean} [options.hideWhitespace] - Whether to hide whitespace changes
925
+ * @returns {Promise<{diff: string, stats: Object, mergeBaseSha: string}>}
926
+ */
927
+ async function generateBranchDiff(repoPath, baseBranch, options = {}) {
928
+ return generateScopedDiff(repoPath, 'branch', 'branch', baseBranch, options);
929
+ }
930
+
931
+ /**
932
+ * Get the number of commits on the current branch ahead of the base branch.
933
+ * @param {string} repoPath - Path to the git repository
934
+ * @param {string} baseBranch - Base branch name
935
+ * @returns {Promise<number>} Number of commits ahead
936
+ */
937
+ async function getBranchCommitCount(repoPath, baseBranch) {
938
+ if (!baseBranch || !/^[\w.\-\/]+$/.test(baseBranch)) {
939
+ throw new Error(`Invalid branch name: ${baseBranch}`);
669
940
  }
670
941
 
671
- // Get list of untracked files with their sizes (content proxy)
672
- let untrackedInfo = '';
673
- try {
674
- const result = await execAsync('git ls-files --others --exclude-standard', {
675
- cwd: localPath,
676
- encoding: 'utf8'
677
- });
678
- const untrackedFiles = result.stdout.trim().split('\n').filter(f => f.length > 0);
942
+ // Try origin/<base> first, fall back to local <base>
943
+ for (const ref of [`origin/${baseBranch}`, baseBranch]) {
944
+ try {
945
+ const count = execSync(`git rev-list --count ${ref}..HEAD`, {
946
+ cwd: repoPath,
947
+ encoding: 'utf8',
948
+ stdio: ['pipe', 'pipe', 'pipe']
949
+ }).trim();
950
+ return parseInt(count, 10) || 0;
951
+ } catch {
952
+ // Try next ref
953
+ }
954
+ }
955
+ return 0;
956
+ }
679
957
 
680
- // For untracked files, include file path and size in digest
681
- for (const file of untrackedFiles) {
682
- try {
683
- const stats = await fs.stat(path.join(localPath, file));
684
- untrackedInfo += `${file}:${stats.size}:${stats.mtimeMs}\n`;
685
- } catch (statError) {
686
- untrackedInfo += `${file}:missing\n`;
687
- }
958
+ /**
959
+ * Get the subject of the first commit on the branch relative to the base.
960
+ * Used as the default review name.
961
+ * @param {string} repoPath - Path to the git repository
962
+ * @param {string} baseBranch - Base branch name
963
+ * @returns {Promise<string|null>} First commit subject or null
964
+ */
965
+ async function getFirstCommitSubject(repoPath, baseBranch) {
966
+ if (!baseBranch || !/^[\w.\-\/]+$/.test(baseBranch)) {
967
+ throw new Error(`Invalid branch name: ${baseBranch}`);
968
+ }
969
+
970
+ for (const ref of [`origin/${baseBranch}`, baseBranch]) {
971
+ try {
972
+ const output = execSync(`git log ${ref}..HEAD --format=%s --reverse`, {
973
+ cwd: repoPath,
974
+ encoding: 'utf8',
975
+ stdio: ['pipe', 'pipe', 'pipe']
976
+ }).trim();
977
+ const firstLine = output.split('\n')[0];
978
+ return firstLine || null;
979
+ } catch {
980
+ // Try next ref
688
981
  }
689
- } catch (e) {
690
- hasError = true;
691
982
  }
983
+ return null;
984
+ }
692
985
 
693
- // If we had errors, return null to signal caller should assume stale
694
- if (hasError && !unstagedDiff && !untrackedInfo) {
986
+ /**
987
+ * Detect whether the current branch has commits ahead of its base branch
988
+ * and build a branchInfo object suitable for the frontend prompt.
989
+ *
990
+ * Encapsulates the full sequence: guard checks -> detectBaseBranch -> getBranchCommitCount.
991
+ * All call sites should use this instead of assembling branchInfo inline.
992
+ *
993
+ * @param {string} repoPath - Absolute path to the git repository
994
+ * @param {string} branch - Current branch name
995
+ * @param {Object} options
996
+ * @param {string} options.repository - owner/repo string
997
+ * @param {string} [options.diff] - The uncommitted diff content (empty = eligible)
998
+ * @param {Array} [options.untrackedFiles] - Untracked files array (empty = eligible)
999
+ * @param {string} [options.githubToken] - Resolved GitHub token for PR lookup
1000
+ * @param {boolean} [options.enableGraphite] - When true, try Graphite CLI for parent branch
1001
+ * @returns {Promise<{baseBranch: string, commitCount: number, source: string, prNumber?: number}|null>}
1002
+ */
1003
+ async function detectAndBuildBranchInfo(repoPath, branch, options = {}) {
1004
+ const { repository, diff, untrackedFiles, githubToken, enableGraphite } = options;
1005
+
1006
+ // Guard: detached HEAD, has uncommitted changes, or has untracked files
1007
+ if (branch === 'HEAD') return null;
1008
+ if (diff) return null;
1009
+ if (untrackedFiles && untrackedFiles.length > 0) return null;
1010
+
1011
+ try {
1012
+ const { detectBaseBranch } = require('./git/base-branch');
1013
+ const depsOverride = githubToken ? { getGitHubToken: () => githubToken } : undefined;
1014
+ const detection = await detectBaseBranch(repoPath, branch, {
1015
+ repository,
1016
+ enableGraphite,
1017
+ _deps: depsOverride
1018
+ });
1019
+ if (!detection) return null;
1020
+
1021
+ const commitCount = await getBranchCommitCount(repoPath, detection.baseBranch);
1022
+ if (commitCount <= 0) return null;
1023
+
1024
+ return {
1025
+ baseBranch: detection.baseBranch,
1026
+ commitCount,
1027
+ source: detection.source,
1028
+ prNumber: detection.prNumber || null
1029
+ };
1030
+ } catch (error) {
1031
+ logger.warn(`Branch detection failed: ${error.message}`);
695
1032
  return null;
696
1033
  }
697
-
698
- // Combine and hash
699
- const combined = unstagedDiff + '\n---UNTRACKED---\n' + untrackedInfo;
700
- const digest = crypto.createHash('sha256').update(combined).digest('hex').substring(0, 16);
701
- return digest;
702
1034
  }
703
1035
 
704
1036
  module.exports = {
@@ -709,7 +1041,14 @@ module.exports = {
709
1041
  getRepositoryName,
710
1042
  getCurrentBranch,
711
1043
  generateLocalDiff,
1044
+ generateBranchDiff,
1045
+ generateScopedDiff,
1046
+ getBranchCommitCount,
1047
+ getFirstCommitSubject,
1048
+ detectAndBuildBranchInfo,
712
1049
  generateLocalReviewId,
713
1050
  getUntrackedFiles,
714
- computeLocalDiffDigest
1051
+ computeLocalDiffDigest,
1052
+ computeScopedDigest,
1053
+ findMergeBase
715
1054
  };