@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.
- package/.pi/extensions/task/index.ts +1 -1
- package/.pi/skills/review-roulette/SKILL.md +1 -1
- package/LICENSE +201 -674
- package/README.md +2 -2
- package/bin/pair-review.js +1 -1
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin-code-critic/.claude-plugin/plugin.json +2 -2
- package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +1 -1
- package/public/css/ai-summary-modal.css +1 -1
- package/public/css/pr.css +194 -0
- package/public/index.html +168 -3
- package/public/js/components/AIPanel.js +17 -3
- package/public/js/components/AISummaryModal.js +1 -1
- package/public/js/components/AdvancedConfigTab.js +1 -1
- package/public/js/components/AnalysisConfigModal.js +1 -1
- package/public/js/components/ChatPanel.js +42 -7
- package/public/js/components/ConfirmDialog.js +22 -3
- package/public/js/components/CouncilProgressModal.js +14 -1
- package/public/js/components/DiffOptionsDropdown.js +411 -24
- package/public/js/components/EmojiPicker.js +1 -1
- package/public/js/components/KeyboardShortcuts.js +1 -1
- package/public/js/components/PanelGroup.js +1 -1
- package/public/js/components/PreviewModal.js +1 -1
- package/public/js/components/ReviewModal.js +1 -1
- package/public/js/components/SplitButton.js +1 -1
- package/public/js/components/StatusIndicator.js +1 -1
- package/public/js/components/SuggestionNavigator.js +13 -6
- package/public/js/components/TabTitle.js +96 -0
- package/public/js/components/TextInputDialog.js +1 -1
- package/public/js/components/TimeoutSelect.js +1 -1
- package/public/js/components/Toast.js +7 -1
- package/public/js/components/VoiceCentricConfigTab.js +1 -1
- package/public/js/index.js +649 -44
- package/public/js/local.js +570 -77
- package/public/js/modules/analysis-history.js +4 -3
- package/public/js/modules/comment-manager.js +6 -1
- package/public/js/modules/comment-minimizer.js +304 -0
- package/public/js/modules/diff-context.js +1 -1
- package/public/js/modules/diff-renderer.js +1 -1
- package/public/js/modules/file-comment-manager.js +1 -1
- package/public/js/modules/file-list-merger.js +1 -1
- package/public/js/modules/gap-coordinates.js +1 -1
- package/public/js/modules/hunk-parser.js +1 -1
- package/public/js/modules/line-tracker.js +1 -1
- package/public/js/modules/panel-resizer.js +1 -1
- package/public/js/modules/storage-cleanup.js +1 -1
- package/public/js/modules/suggestion-manager.js +1 -1
- package/public/js/pr.js +83 -7
- package/public/js/repo-settings.js +1 -1
- package/public/js/utils/category-emoji.js +1 -1
- package/public/js/utils/file-order.js +1 -1
- package/public/js/utils/markdown.js +1 -1
- package/public/js/utils/suggestion-ui.js +1 -1
- package/public/js/utils/tier-icons.js +1 -1
- package/public/js/utils/time.js +1 -1
- package/public/js/ws-client.js +1 -1
- package/public/local.html +14 -0
- package/public/pr.html +3 -0
- package/public/setup.html +1 -1
- package/src/ai/analyzer.js +18 -12
- package/src/ai/claude-cli.js +1 -1
- package/src/ai/claude-provider.js +1 -1
- package/src/ai/codex-provider.js +1 -1
- package/src/ai/copilot-provider.js +1 -1
- package/src/ai/cursor-agent-provider.js +1 -1
- package/src/ai/gemini-provider.js +1 -1
- package/src/ai/index.js +1 -1
- package/src/ai/opencode-provider.js +1 -1
- package/src/ai/pi-provider.js +1 -1
- package/src/ai/prompts/baseline/consolidation/balanced.js +1 -1
- package/src/ai/prompts/baseline/consolidation/fast.js +1 -1
- package/src/ai/prompts/baseline/consolidation/thorough.js +1 -1
- package/src/ai/prompts/baseline/level1/balanced.js +1 -1
- package/src/ai/prompts/baseline/level1/fast.js +1 -1
- package/src/ai/prompts/baseline/level1/thorough.js +1 -1
- package/src/ai/prompts/baseline/level2/balanced.js +1 -1
- package/src/ai/prompts/baseline/level2/fast.js +1 -1
- package/src/ai/prompts/baseline/level2/thorough.js +1 -1
- package/src/ai/prompts/baseline/level3/balanced.js +1 -1
- package/src/ai/prompts/baseline/level3/fast.js +1 -1
- package/src/ai/prompts/baseline/level3/thorough.js +1 -1
- package/src/ai/prompts/baseline/orchestration/balanced.js +1 -1
- package/src/ai/prompts/baseline/orchestration/fast.js +1 -1
- package/src/ai/prompts/baseline/orchestration/thorough.js +1 -1
- package/src/ai/prompts/config.js +1 -1
- package/src/ai/prompts/index.js +1 -1
- package/src/ai/prompts/line-number-guidance.js +1 -1
- package/src/ai/prompts/render-for-skill.js +1 -1
- package/src/ai/prompts/shared/diff-instructions.js +1 -1
- package/src/ai/prompts/shared/output-schema.js +1 -1
- package/src/ai/prompts/shared/valid-files.js +1 -1
- package/src/ai/prompts/sparse-checkout-guidance.js +1 -1
- package/src/ai/provider-availability.js +1 -1
- package/src/ai/provider.js +1 -1
- package/src/ai/stream-parser.js +1 -1
- package/src/chat/acp-bridge.js +1 -1
- package/src/chat/api-reference.js +1 -1
- package/src/chat/chat-providers.js +1 -1
- package/src/chat/claude-code-bridge.js +1 -1
- package/src/chat/codex-bridge.js +1 -1
- package/src/chat/pi-bridge.js +1 -1
- package/src/chat/prompt-builder.js +1 -1
- package/src/chat/session-manager.js +1 -1
- package/src/config.js +3 -1
- package/src/database.js +591 -40
- package/src/events/review-events.js +1 -1
- package/src/git/base-branch.js +173 -0
- package/src/git/gitattributes.js +1 -1
- package/src/git/sha-abbrev.js +35 -0
- package/src/git/worktree.js +1 -1
- package/src/github/client.js +33 -2
- package/src/github/parser.js +1 -1
- package/src/hooks/hook-runner.js +100 -0
- package/src/hooks/payloads.js +212 -0
- package/src/local-review.js +469 -130
- package/src/local-scope.js +58 -0
- package/src/main.js +56 -5
- package/src/mcp-stdio.js +1 -1
- package/src/protocol-handler.js +1 -1
- package/src/routes/analyses.js +74 -11
- package/src/routes/chat.js +34 -1
- package/src/routes/config.js +2 -1
- package/src/routes/context-files.js +1 -1
- package/src/routes/councils.js +1 -1
- package/src/routes/github-collections.js +1 -1
- package/src/routes/local.js +735 -69
- package/src/routes/mcp.js +21 -11
- package/src/routes/pr.js +91 -13
- package/src/routes/reviews.js +1 -1
- package/src/routes/setup.js +2 -1
- package/src/routes/shared.js +1 -1
- package/src/routes/worktrees.js +213 -149
- package/src/server.js +31 -1
- package/src/setup/local-setup.js +47 -6
- package/src/setup/pr-setup.js +29 -6
- package/src/utils/auto-context.js +1 -1
- package/src/utils/category-emoji.js +1 -1
- package/src/utils/comment-formatter.js +1 -1
- package/src/utils/diff-annotator.js +1 -1
- package/src/utils/diff-file-list.js +1 -1
- package/src/utils/instructions.js +1 -1
- package/src/utils/json-extractor.js +1 -1
- package/src/utils/line-validation.js +1 -1
- package/src/utils/logger.js +1 -1
- package/src/utils/paths.js +1 -1
- package/src/utils/safe-parse-json.js +1 -1
- package/src/utils/stats-calculator.js +1 -1
- package/src/ws/index.js +1 -1
- package/src/ws/server.js +1 -1
package/src/local-review.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
// SPDX-License-Identifier:
|
|
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
|
-
*
|
|
364
|
-
*
|
|
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
|
-
* @
|
|
370
|
+
* @param {string} baseBranch - Base branch name
|
|
371
|
+
* @returns {Promise<string>} Merge-base SHA
|
|
368
372
|
*/
|
|
369
|
-
async function
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
564
|
-
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
580
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
914
|
+
return computeScopedDigest(localPath, 'unstaged', 'untracked');
|
|
915
|
+
}
|
|
657
916
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
//
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
694
|
-
|
|
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
|
};
|