claude-git-hooks 2.13.0 → 2.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +16 -9
- package/lib/commands/analyze-diff.js +32 -153
- package/lib/commands/bump-version.js +154 -47
- package/lib/commands/create-pr.js +15 -80
- package/lib/commands/helpers.js +7 -0
- package/lib/utils/claude-client.js +6 -1
- package/lib/utils/claude-diagnostics.js +2 -1
- package/lib/utils/git-operations.js +408 -1
- package/lib/utils/pr-metadata-engine.js +474 -0
- package/package.json +1 -1
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
import { execSync } from 'child_process';
|
|
7
7
|
import fs from 'fs';
|
|
8
8
|
import path from 'path';
|
|
9
|
-
import {
|
|
10
|
-
import { loadPrompt } from '../utils/prompt-builder.js';
|
|
9
|
+
import { analyzeBranchForPR } from '../utils/pr-metadata-engine.js';
|
|
11
10
|
import { getConfig } from '../config.js';
|
|
12
11
|
import { getOrPromptTaskId, formatWithTaskId } from '../utils/task-id.js';
|
|
13
12
|
import { getReviewersForFiles } from '../utils/github-client.js';
|
|
@@ -82,7 +81,7 @@ export async function runCreatePr(args) {
|
|
|
82
81
|
const taskId = await getOrPromptTaskId({
|
|
83
82
|
prompt: true, // DO prompt for PRs (unlike commit messages)
|
|
84
83
|
required: false, // Allow skipping
|
|
85
|
-
config
|
|
84
|
+
config // Pass config for custom pattern
|
|
86
85
|
});
|
|
87
86
|
logger.debug('create-pr', 'Task ID determined', { taskId });
|
|
88
87
|
|
|
@@ -336,90 +335,26 @@ export async function runCreatePr(args) {
|
|
|
336
335
|
}
|
|
337
336
|
}
|
|
338
337
|
|
|
339
|
-
// Step 6:
|
|
340
|
-
logger.debug('create-pr', 'Step 6:
|
|
341
|
-
|
|
342
|
-
const compareWith = `origin/${baseBranch}...HEAD`;
|
|
343
|
-
|
|
344
|
-
try {
|
|
345
|
-
execSync(`git rev-parse --verify origin/${baseBranch}`, { stdio: 'ignore' });
|
|
346
|
-
} catch (e) {
|
|
347
|
-
error(`Base branch origin/${baseBranch} does not exist`);
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
let diffFiles;
|
|
352
|
-
try {
|
|
353
|
-
diffFiles = execSync(`git diff ${compareWith} --name-only`, { encoding: 'utf8' }).trim();
|
|
354
|
-
if (!diffFiles) {
|
|
355
|
-
showWarning('No differences with remote branch. Nothing to create a PR for.');
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
} catch (e) {
|
|
359
|
-
error('Error getting differences: ' + e.message);
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
338
|
+
// Step 6: Generate PR metadata using engine
|
|
339
|
+
logger.debug('create-pr', 'Step 6: Generating PR metadata with engine');
|
|
340
|
+
showInfo('Generating PR metadata with Claude...');
|
|
362
341
|
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
fileCount: filesArray.length,
|
|
366
|
-
files: filesArray
|
|
342
|
+
const { success: engineSuccess, result: analysisResult, error: engineError } = await analyzeBranchForPR(baseBranch, {
|
|
343
|
+
hook: 'create-pr'
|
|
367
344
|
});
|
|
368
|
-
showInfo(`Found ${filesArray.length} modified file(s)`);
|
|
369
345
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
try {
|
|
374
|
-
fullDiff = execSync(`git diff ${compareWith}`, { encoding: 'utf8' });
|
|
375
|
-
commits = execSync(`git log origin/${baseBranch}..HEAD --oneline`, { encoding: 'utf8' }).trim();
|
|
376
|
-
} catch (e) {
|
|
377
|
-
error('Error getting diff or commits: ' + e.message);
|
|
346
|
+
if (!engineSuccess) {
|
|
347
|
+
logger.error('create-pr', 'Failed to generate PR metadata', { error: engineError });
|
|
348
|
+
error(engineError || 'Failed to generate PR metadata');
|
|
378
349
|
return;
|
|
379
350
|
}
|
|
380
351
|
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
: fullDiff;
|
|
384
|
-
|
|
385
|
-
const contextDescription = `${currentBranch} vs origin/${baseBranch}`;
|
|
386
|
-
const prompt = await loadPrompt('ANALYZE_DIFF.md', {
|
|
387
|
-
CONTEXT_DESCRIPTION: contextDescription,
|
|
388
|
-
SUBAGENT_INSTRUCTION: '',
|
|
389
|
-
COMMITS: commits,
|
|
390
|
-
DIFF_FILES: diffFiles,
|
|
391
|
-
FULL_DIFF: truncatedDiff
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
showInfo('Generating PR metadata with Claude...');
|
|
395
|
-
logger.debug('create-pr', 'Calling Claude with prompt', { promptLength: prompt.length });
|
|
396
|
-
|
|
397
|
-
// Prepare telemetry context for create-pr
|
|
398
|
-
const telemetryContext = {
|
|
352
|
+
const filesArray = analysisResult.context.files;
|
|
353
|
+
logger.debug('create-pr', 'PR metadata generated', {
|
|
399
354
|
fileCount: filesArray.length,
|
|
400
|
-
|
|
401
|
-
totalBatches: 1,
|
|
402
|
-
model: 'sonnet', // create-pr always uses main model
|
|
403
|
-
hook: 'create-pr'
|
|
404
|
-
};
|
|
405
|
-
|
|
406
|
-
const response = await executeClaudeWithRetry(prompt, {
|
|
407
|
-
timeout: 180000,
|
|
408
|
-
telemetryContext
|
|
355
|
+
hasPrTitle: !!analysisResult.prTitle
|
|
409
356
|
});
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const analysisResult = extractJSON(response);
|
|
413
|
-
logger.debug('create-pr', 'Analysis result extracted', {
|
|
414
|
-
hasResult: !!analysisResult,
|
|
415
|
-
hasPrTitle: !!analysisResult?.prTitle
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
if (!analysisResult || !analysisResult.prTitle) {
|
|
419
|
-
logger.error('create-pr', 'Failed to generate PR metadata from analysis', { analysisResult });
|
|
420
|
-
error('Failed to generate PR metadata from analysis');
|
|
421
|
-
return;
|
|
422
|
-
}
|
|
357
|
+
showInfo(`Found ${filesArray.length} modified file(s)`);
|
|
423
358
|
|
|
424
359
|
// Step 8: Prepare PR data
|
|
425
360
|
logger.debug('create-pr', 'Step 8: Preparing PR data');
|
|
@@ -562,7 +497,7 @@ export async function runCreatePr(args) {
|
|
|
562
497
|
|
|
563
498
|
} catch (err) {
|
|
564
499
|
logger.error('create-pr', 'Error creating PR', err);
|
|
565
|
-
showError(
|
|
500
|
+
showError(`Error creating PR: ${err.message}`);
|
|
566
501
|
|
|
567
502
|
if (err.context) {
|
|
568
503
|
logger.debug('create-pr', 'Error context', err.context);
|
package/lib/commands/helpers.js
CHANGED
|
@@ -193,8 +193,15 @@ export async function updateConfig(propertyPath, value, options = {}) {
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
// Set value at propertyPath (support dot notation like 'system.debug')
|
|
196
|
+
// For v2.8.0 format, write inside 'overrides' so loadUserConfig picks it up
|
|
196
197
|
const pathParts = propertyPath.split('.');
|
|
197
198
|
let current = config;
|
|
199
|
+
if (config.version === '2.8.0') {
|
|
200
|
+
if (!config.overrides || typeof config.overrides !== 'object') {
|
|
201
|
+
config.overrides = {};
|
|
202
|
+
}
|
|
203
|
+
current = config.overrides;
|
|
204
|
+
}
|
|
198
205
|
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
199
206
|
const part = pathParts[i];
|
|
200
207
|
if (!current[part] || typeof current[part] !== 'object') {
|
|
@@ -380,7 +380,12 @@ const executeClaude = (prompt, { timeout = 120000, allowedTools = [] } = {}) =>
|
|
|
380
380
|
reject(new ClaudeClientError('Claude CLI execution timed out', {
|
|
381
381
|
context: {
|
|
382
382
|
elapsedTime,
|
|
383
|
-
timeoutValue: timeout
|
|
383
|
+
timeoutValue: timeout,
|
|
384
|
+
errorInfo: {
|
|
385
|
+
type: ClaudeErrorType.TIMEOUT,
|
|
386
|
+
elapsedTime,
|
|
387
|
+
timeoutValue: timeout
|
|
388
|
+
}
|
|
384
389
|
}
|
|
385
390
|
}));
|
|
386
391
|
}, timeout);
|
|
@@ -353,5 +353,6 @@ const formatGenericError = (errorInfo) => {
|
|
|
353
353
|
export const isRecoverableError = (errorInfo) => {
|
|
354
354
|
return errorInfo.type === ClaudeErrorType.EXECUTION_ERROR ||
|
|
355
355
|
errorInfo.type === ClaudeErrorType.RATE_LIMIT ||
|
|
356
|
-
errorInfo.type === ClaudeErrorType.NETWORK
|
|
356
|
+
errorInfo.type === ClaudeErrorType.NETWORK ||
|
|
357
|
+
errorInfo.type === ClaudeErrorType.TIMEOUT;
|
|
357
358
|
};
|
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { execSync } from 'child_process';
|
|
16
|
+
import fs from 'fs';
|
|
16
17
|
import path from 'path';
|
|
17
18
|
import logger from './logger.js';
|
|
19
|
+
import { sanitizeBranchName } from './sanitize.js';
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* Custom error for git operation failures
|
|
@@ -616,6 +618,82 @@ const createCommit = (message, { noVerify = false } = {}) => {
|
|
|
616
618
|
}
|
|
617
619
|
};
|
|
618
620
|
|
|
621
|
+
/**
|
|
622
|
+
* Stages files for commit
|
|
623
|
+
* Why: Prepares specific files for git commit (used by version bumping, changelog generation)
|
|
624
|
+
*
|
|
625
|
+
* @param {Array<string>} filePaths - Array of absolute file paths to stage
|
|
626
|
+
* @returns {Object} Result: { success: boolean, stagedFiles: Array<string>, error: string }
|
|
627
|
+
*/
|
|
628
|
+
const stageFiles = (filePaths) => {
|
|
629
|
+
logger.debug('git-operations - stageFiles', 'Staging files', { count: filePaths.length });
|
|
630
|
+
|
|
631
|
+
if (!Array.isArray(filePaths) || filePaths.length === 0) {
|
|
632
|
+
logger.warning('git-operations - stageFiles', 'No files provided');
|
|
633
|
+
return {
|
|
634
|
+
success: false,
|
|
635
|
+
stagedFiles: [],
|
|
636
|
+
error: 'No files provided to stage'
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
try {
|
|
641
|
+
const repoRoot = getRepoRoot();
|
|
642
|
+
const stagedFiles = [];
|
|
643
|
+
|
|
644
|
+
// Stage each file individually for granular error handling
|
|
645
|
+
for (const filePath of filePaths) {
|
|
646
|
+
// Convert to repo-relative path
|
|
647
|
+
const relativePath = path.relative(repoRoot, filePath);
|
|
648
|
+
|
|
649
|
+
// Verify file exists
|
|
650
|
+
if (!fs.existsSync(filePath)) {
|
|
651
|
+
logger.warning('git-operations - stageFiles', 'File not found, skipping', { filePath });
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
try {
|
|
656
|
+
execGitCommand(`add "${relativePath}"`);
|
|
657
|
+
stagedFiles.push(filePath);
|
|
658
|
+
logger.debug('git-operations - stageFiles', 'File staged', { relativePath });
|
|
659
|
+
} catch (error) {
|
|
660
|
+
logger.warning('git-operations - stageFiles', 'Failed to stage file', {
|
|
661
|
+
filePath,
|
|
662
|
+
error: error.message
|
|
663
|
+
});
|
|
664
|
+
// Continue staging other files
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (stagedFiles.length === 0) {
|
|
669
|
+
return {
|
|
670
|
+
success: false,
|
|
671
|
+
stagedFiles: [],
|
|
672
|
+
error: 'No files were successfully staged'
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
logger.debug('git-operations - stageFiles', 'Files staged successfully', {
|
|
677
|
+
count: stagedFiles.length
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
return {
|
|
681
|
+
success: true,
|
|
682
|
+
stagedFiles,
|
|
683
|
+
error: ''
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
} catch (error) {
|
|
687
|
+
logger.error('git-operations - stageFiles', 'Staging failed', error);
|
|
688
|
+
|
|
689
|
+
return {
|
|
690
|
+
success: false,
|
|
691
|
+
stagedFiles: [],
|
|
692
|
+
error: error.message || 'Unknown staging error'
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
|
|
619
697
|
/**
|
|
620
698
|
* Pushes branch to remote
|
|
621
699
|
* Why: Publishes local branch to remote before creating PR
|
|
@@ -663,6 +741,327 @@ const pushBranch = (branchName, { setUpstream = false } = {}) => {
|
|
|
663
741
|
}
|
|
664
742
|
};
|
|
665
743
|
|
|
744
|
+
/**
|
|
745
|
+
* Fetches latest from remote
|
|
746
|
+
* Why: Ensures local git has up-to-date remote refs before comparing branches
|
|
747
|
+
*
|
|
748
|
+
* @param {string} [remoteName='origin'] - Remote name
|
|
749
|
+
*/
|
|
750
|
+
const fetchRemote = (remoteName = 'origin') => {
|
|
751
|
+
logger.debug('git-operations - fetchRemote', 'Fetching from remote', { remoteName });
|
|
752
|
+
|
|
753
|
+
// Sanitize remote name
|
|
754
|
+
const sanitizedRemote = sanitizeBranchName(remoteName);
|
|
755
|
+
if (!sanitizedRemote) {
|
|
756
|
+
throw new GitError('Invalid remote name', { command: 'fetch', output: remoteName });
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
try {
|
|
760
|
+
execGitCommand(`fetch ${sanitizedRemote}`);
|
|
761
|
+
logger.debug('git-operations - fetchRemote', 'Fetch successful', { remoteName: sanitizedRemote });
|
|
762
|
+
} catch (error) {
|
|
763
|
+
logger.error('git-operations - fetchRemote', 'Fetch failed', error);
|
|
764
|
+
throw new GitError('Failed to fetch from remote', {
|
|
765
|
+
command: `git fetch ${sanitizedRemote}`,
|
|
766
|
+
cause: error
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Checks if a branch reference exists
|
|
773
|
+
* Why: Validates branch refs before attempting operations to avoid failures
|
|
774
|
+
*
|
|
775
|
+
* @param {string} branchRef - Branch reference to verify (e.g., 'origin/main')
|
|
776
|
+
* @returns {boolean}
|
|
777
|
+
*/
|
|
778
|
+
const branchExists = (branchRef) => {
|
|
779
|
+
logger.debug('git-operations - branchExists', 'Checking if branch exists', { branchRef });
|
|
780
|
+
|
|
781
|
+
// Sanitize branch reference
|
|
782
|
+
const sanitizedRef = sanitizeBranchName(branchRef);
|
|
783
|
+
if (!sanitizedRef) {
|
|
784
|
+
logger.debug('git-operations - branchExists', 'Invalid branch reference', { branchRef });
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
try {
|
|
789
|
+
// Why: rev-parse --verify exits with 0 if ref exists, non-zero otherwise
|
|
790
|
+
// Why execSync instead of execGitCommand: Expected failures should not log at error level.
|
|
791
|
+
// branchExists() is a probe — failure is a valid outcome, not an error.
|
|
792
|
+
execSync(`git rev-parse --verify ${sanitizedRef}`, {
|
|
793
|
+
encoding: 'utf8',
|
|
794
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
795
|
+
});
|
|
796
|
+
logger.debug('git-operations - branchExists', 'Branch exists', { branchRef: sanitizedRef });
|
|
797
|
+
return true;
|
|
798
|
+
} catch (error) {
|
|
799
|
+
logger.debug('git-operations - branchExists', 'Branch does not exist', { branchRef: sanitizedRef });
|
|
800
|
+
return false;
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Gets remote branch names (stripped of remote prefix)
|
|
806
|
+
* Why: Used by resolveBaseBranch to suggest similar branches
|
|
807
|
+
*
|
|
808
|
+
* @param {string} [remoteName='origin'] - Remote name
|
|
809
|
+
* @returns {string[]} Branch names without remote prefix
|
|
810
|
+
*/
|
|
811
|
+
const getRemoteBranches = (remoteName = 'origin') => {
|
|
812
|
+
try {
|
|
813
|
+
const output = execSync(`git branch -r --list "${remoteName}/*"`, {
|
|
814
|
+
encoding: 'utf8',
|
|
815
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
816
|
+
}).trim();
|
|
817
|
+
|
|
818
|
+
if (!output) return [];
|
|
819
|
+
|
|
820
|
+
return output
|
|
821
|
+
.split(/\r?\n/)
|
|
822
|
+
.map(line => line.trim())
|
|
823
|
+
.filter(line => !line.includes('->')) // Skip HEAD -> origin/main
|
|
824
|
+
.map(line => line.replace(`${remoteName}/`, ''));
|
|
825
|
+
} catch {
|
|
826
|
+
return [];
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Finds remote branches similar to the target by shared keywords
|
|
832
|
+
*
|
|
833
|
+
* @param {string} target - Branch name to match against
|
|
834
|
+
* @param {string[]} branches - Available branch names
|
|
835
|
+
* @returns {string[]} Similar branch names, sorted by relevance
|
|
836
|
+
*/
|
|
837
|
+
const findSimilarBranches = (target, branches) => {
|
|
838
|
+
const targetWords = target.toLowerCase().split(/[/\-_.]/).filter(w => w.length > 1);
|
|
839
|
+
|
|
840
|
+
const scored = branches
|
|
841
|
+
.map(branch => {
|
|
842
|
+
const branchWords = branch.toLowerCase().split(/[/\-_.]/).filter(w => w.length > 1);
|
|
843
|
+
const matches = targetWords.filter(tw => branchWords.some(bw => bw.includes(tw) || tw.includes(bw)));
|
|
844
|
+
return { branch, score: matches.length };
|
|
845
|
+
})
|
|
846
|
+
.filter(({ score }) => score > 0)
|
|
847
|
+
.sort((a, b) => b.score - a.score);
|
|
848
|
+
|
|
849
|
+
return scored.map(({ branch }) => branch);
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Resolves base branch on remote.
|
|
854
|
+
* Verifies origin/{target} exists. If not, throws with suggestions
|
|
855
|
+
* of similar remote branches to help the user correct the name.
|
|
856
|
+
*
|
|
857
|
+
* @param {string} targetBranch - Requested base branch
|
|
858
|
+
* @returns {string} Resolved remote ref (e.g., 'origin/main')
|
|
859
|
+
* @throws {GitError} If branch not found (message includes suggestions)
|
|
860
|
+
*/
|
|
861
|
+
const resolveBaseBranch = (targetBranch) => {
|
|
862
|
+
logger.debug('git-operations - resolveBaseBranch', 'Resolving base branch', { targetBranch });
|
|
863
|
+
|
|
864
|
+
// Sanitize target branch
|
|
865
|
+
const sanitizedTarget = sanitizeBranchName(targetBranch);
|
|
866
|
+
if (!sanitizedTarget) {
|
|
867
|
+
throw new GitError('Invalid target branch name', { command: 'resolveBaseBranch', output: targetBranch });
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Try requested branch with origin/ prefix
|
|
871
|
+
const targetRef = `origin/${sanitizedTarget}`;
|
|
872
|
+
if (branchExists(targetRef)) {
|
|
873
|
+
logger.debug('git-operations - resolveBaseBranch', 'Target branch found', { ref: targetRef });
|
|
874
|
+
return targetRef;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Branch not found — build helpful error with suggestions
|
|
878
|
+
const remoteBranches = getRemoteBranches();
|
|
879
|
+
const similar = findSimilarBranches(sanitizedTarget, remoteBranches);
|
|
880
|
+
|
|
881
|
+
let suggestion = '';
|
|
882
|
+
if (similar.length > 0) {
|
|
883
|
+
const shown = similar.slice(0, 5);
|
|
884
|
+
suggestion = `\n\nSimilar branches on remote:\n${shown.map(b => ` - ${b}`).join('\n')}`;
|
|
885
|
+
} else if (remoteBranches.length > 0) {
|
|
886
|
+
const shown = remoteBranches.slice(0, 10);
|
|
887
|
+
suggestion = `\n\nAvailable branches on remote:\n${shown.map(b => ` - ${b}`).join('\n')}`;
|
|
888
|
+
if (remoteBranches.length > 10) {
|
|
889
|
+
suggestion += `\n ... and ${remoteBranches.length - 10} more`;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
throw new GitError(`Branch '${sanitizedTarget}' not found on remote${suggestion}`, {
|
|
894
|
+
command: 'resolveBaseBranch',
|
|
895
|
+
output: `target: ${targetBranch}`
|
|
896
|
+
});
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Gets list of files changed between two refs
|
|
901
|
+
* Why: Shows which files differ between branches for PR context
|
|
902
|
+
*
|
|
903
|
+
* @param {string} baseRef - Base reference (e.g., 'origin/main')
|
|
904
|
+
* @param {string} [headRef='HEAD'] - Head reference
|
|
905
|
+
* @returns {string[]} Array of changed file paths
|
|
906
|
+
*/
|
|
907
|
+
const getChangedFilesBetweenRefs = (baseRef, headRef = 'HEAD') => {
|
|
908
|
+
logger.debug('git-operations - getChangedFilesBetweenRefs', 'Getting changed files', { baseRef, headRef });
|
|
909
|
+
|
|
910
|
+
// Sanitize refs
|
|
911
|
+
const sanitizedBase = sanitizeBranchName(baseRef);
|
|
912
|
+
const sanitizedHead = sanitizeBranchName(headRef);
|
|
913
|
+
|
|
914
|
+
if (!sanitizedBase || !sanitizedHead) {
|
|
915
|
+
throw new GitError('Invalid branch reference', {
|
|
916
|
+
command: 'getChangedFilesBetweenRefs',
|
|
917
|
+
output: `base: ${baseRef}, head: ${headRef}`
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
try {
|
|
922
|
+
// Why: --name-only shows just file paths, ... compares merge-base to head
|
|
923
|
+
const output = execGitCommand(`diff --name-only ${sanitizedBase}...${sanitizedHead}`);
|
|
924
|
+
|
|
925
|
+
if (!output) {
|
|
926
|
+
logger.debug('git-operations - getChangedFilesBetweenRefs', 'No changed files', { baseRef, headRef });
|
|
927
|
+
return [];
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const files = output.split(/\r?\n/).filter(f => f.length > 0);
|
|
931
|
+
|
|
932
|
+
logger.debug('git-operations - getChangedFilesBetweenRefs', 'Changed files retrieved', {
|
|
933
|
+
baseRef,
|
|
934
|
+
headRef,
|
|
935
|
+
fileCount: files.length
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
return files;
|
|
939
|
+
|
|
940
|
+
} catch (error) {
|
|
941
|
+
logger.error('git-operations - getChangedFilesBetweenRefs', 'Failed to get changed files', error);
|
|
942
|
+
throw new GitError('Failed to get changed files between refs', {
|
|
943
|
+
command: `git diff --name-only ${sanitizedBase}...${sanitizedHead}`,
|
|
944
|
+
cause: error
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Gets full diff between two refs.
|
|
951
|
+
*
|
|
952
|
+
* Complements the existing getFileDiff() which operates on a single file's
|
|
953
|
+
* staged/unstaged diff (git diff [--cached] -- <file>). This function operates
|
|
954
|
+
* on the comparison between two branch refs (git diff origin/main...HEAD) —
|
|
955
|
+
* the branch-level counterpart that is currently missing, which is why both
|
|
956
|
+
* analyze-diff.js and create-pr.js resort to raw execSync.
|
|
957
|
+
*
|
|
958
|
+
* Returns the raw diff string. Truncation/reduction is handled by
|
|
959
|
+
* buildDiffPayload() in pr-metadata-engine.js, not here.
|
|
960
|
+
*
|
|
961
|
+
* @param {string} baseRef - Base reference
|
|
962
|
+
* @param {string} [headRef='HEAD'] - Head reference
|
|
963
|
+
* @param {Object} [options]
|
|
964
|
+
* @param {number} [options.contextLines=3] - Lines of context around changes (-U flag)
|
|
965
|
+
* @returns {string} Raw unified diff
|
|
966
|
+
*/
|
|
967
|
+
const getDiffBetweenRefs = (baseRef, headRef = 'HEAD', { contextLines = 3 } = {}) => {
|
|
968
|
+
logger.debug('git-operations - getDiffBetweenRefs', 'Getting diff between refs', {
|
|
969
|
+
baseRef,
|
|
970
|
+
headRef,
|
|
971
|
+
contextLines
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
// Sanitize refs
|
|
975
|
+
const sanitizedBase = sanitizeBranchName(baseRef);
|
|
976
|
+
const sanitizedHead = sanitizeBranchName(headRef);
|
|
977
|
+
|
|
978
|
+
if (!sanitizedBase || !sanitizedHead) {
|
|
979
|
+
throw new GitError('Invalid branch reference', {
|
|
980
|
+
command: 'getDiffBetweenRefs',
|
|
981
|
+
output: `base: ${baseRef}, head: ${headRef}`
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
try {
|
|
986
|
+
// Why: -U flag sets context lines, ... compares merge-base to head
|
|
987
|
+
const contextFlag = `-U${contextLines}`;
|
|
988
|
+
const output = execGitCommand(`diff ${contextFlag} ${sanitizedBase}...${sanitizedHead}`);
|
|
989
|
+
|
|
990
|
+
logger.debug('git-operations - getDiffBetweenRefs', 'Diff retrieved', {
|
|
991
|
+
baseRef,
|
|
992
|
+
headRef,
|
|
993
|
+
contextLines,
|
|
994
|
+
diffSize: output.length
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
return output;
|
|
998
|
+
|
|
999
|
+
} catch (error) {
|
|
1000
|
+
logger.error('git-operations - getDiffBetweenRefs', 'Failed to get diff', error);
|
|
1001
|
+
throw new GitError('Failed to get diff between refs', {
|
|
1002
|
+
command: `git diff -U${contextLines} ${sanitizedBase}...${sanitizedHead}`,
|
|
1003
|
+
cause: error
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Gets commit log between two refs
|
|
1010
|
+
* Why: Shows commit history for PR description and context
|
|
1011
|
+
*
|
|
1012
|
+
* @param {string} baseRef - Base reference
|
|
1013
|
+
* @param {string} [headRef='HEAD'] - Head reference
|
|
1014
|
+
* @param {Object} [options]
|
|
1015
|
+
* @param {string} [options.format='oneline'] - Git log format
|
|
1016
|
+
* @returns {string} Commit log output
|
|
1017
|
+
*/
|
|
1018
|
+
const getCommitsBetweenRefs = (baseRef, headRef = 'HEAD', { format = 'oneline' } = {}) => {
|
|
1019
|
+
logger.debug('git-operations - getCommitsBetweenRefs', 'Getting commits between refs', {
|
|
1020
|
+
baseRef,
|
|
1021
|
+
headRef,
|
|
1022
|
+
format
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
// Sanitize refs
|
|
1026
|
+
const sanitizedBase = sanitizeBranchName(baseRef);
|
|
1027
|
+
const sanitizedHead = sanitizeBranchName(headRef);
|
|
1028
|
+
|
|
1029
|
+
if (!sanitizedBase || !sanitizedHead) {
|
|
1030
|
+
throw new GitError('Invalid branch reference', {
|
|
1031
|
+
command: 'getCommitsBetweenRefs',
|
|
1032
|
+
output: `base: ${baseRef}, head: ${headRef}`
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
try {
|
|
1037
|
+
// Why: --format specifies output format, ... shows commits on head not on base
|
|
1038
|
+
const output = execGitCommand(`log --format=${format} ${sanitizedBase}...${sanitizedHead}`);
|
|
1039
|
+
|
|
1040
|
+
if (!output) {
|
|
1041
|
+
logger.debug('git-operations - getCommitsBetweenRefs', 'No commits found', { baseRef, headRef });
|
|
1042
|
+
return '';
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const commitCount = output.split(/\r?\n/).filter(line => line.length > 0).length;
|
|
1046
|
+
|
|
1047
|
+
logger.debug('git-operations - getCommitsBetweenRefs', 'Commits retrieved', {
|
|
1048
|
+
baseRef,
|
|
1049
|
+
headRef,
|
|
1050
|
+
format,
|
|
1051
|
+
commitCount
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
return output;
|
|
1055
|
+
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
logger.error('git-operations - getCommitsBetweenRefs', 'Failed to get commits', error);
|
|
1058
|
+
throw new GitError('Failed to get commits between refs', {
|
|
1059
|
+
command: `git log --format=${format} ${sanitizedBase}...${sanitizedHead}`,
|
|
1060
|
+
cause: error
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
|
|
666
1065
|
export {
|
|
667
1066
|
GitError,
|
|
668
1067
|
getStagedFiles,
|
|
@@ -679,5 +1078,13 @@ export {
|
|
|
679
1078
|
verifyRemoteExists,
|
|
680
1079
|
getBranchPushStatus,
|
|
681
1080
|
pushBranch,
|
|
682
|
-
|
|
1081
|
+
stageFiles,
|
|
1082
|
+
createCommit,
|
|
1083
|
+
fetchRemote,
|
|
1084
|
+
branchExists,
|
|
1085
|
+
getRemoteBranches,
|
|
1086
|
+
resolveBaseBranch,
|
|
1087
|
+
getChangedFilesBetweenRefs,
|
|
1088
|
+
getDiffBetweenRefs,
|
|
1089
|
+
getCommitsBetweenRefs
|
|
683
1090
|
};
|