claude-git-hooks 2.12.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 +54 -1
- package/README.md +52 -13
- package/bin/claude-hooks +8 -0
- package/lib/commands/analyze-diff.js +32 -153
- package/lib/commands/analyze.js +217 -0
- package/lib/commands/bump-version.js +172 -50
- package/lib/commands/create-pr.js +15 -80
- package/lib/commands/helpers.js +7 -0
- package/lib/hooks/pre-commit.js +26 -265
- package/lib/utils/analysis-engine.js +469 -0
- package/lib/utils/claude-client.js +6 -1
- package/lib/utils/claude-diagnostics.js +2 -1
- package/lib/utils/git-operations.js +537 -1
- package/lib/utils/git-tag-manager.js +58 -8
- package/lib/utils/interactive-ui.js +86 -1
- package/lib/utils/pr-metadata-engine.js +474 -0
- package/lib/utils/resolution-prompt.js +57 -34
- package/lib/utils/version-manager.js +219 -52
- package/package.json +1 -1
|
@@ -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
|
|
@@ -130,6 +132,92 @@ const getStagedFiles = ({ extensions = [], includeDeleted = false } = {}) => {
|
|
|
130
132
|
return files;
|
|
131
133
|
};
|
|
132
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Gets list of unstaged files (modified but not staged)
|
|
137
|
+
* Why: For on-demand analysis of working tree changes
|
|
138
|
+
*
|
|
139
|
+
* @param {Object} options - Filter options
|
|
140
|
+
* @param {Array<string>} options.extensions - File extensions to filter (e.g., ['.java', '.xml'])
|
|
141
|
+
* @param {boolean} options.includeDeleted - Include deleted files (default: false)
|
|
142
|
+
* @returns {Array<string>} Array of unstaged file paths
|
|
143
|
+
*/
|
|
144
|
+
const getUnstagedFiles = ({ extensions = [], includeDeleted = false } = {}) => {
|
|
145
|
+
logger.debug(
|
|
146
|
+
'git-operations - getUnstagedFiles',
|
|
147
|
+
'Getting unstaged files',
|
|
148
|
+
{ extensions, includeDeleted }
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Why: Without --cached flag, shows working tree changes
|
|
152
|
+
const filter = includeDeleted ? 'ACMR' : 'ACM';
|
|
153
|
+
const output = execGitCommand(`diff --name-only --diff-filter=${filter}`);
|
|
154
|
+
|
|
155
|
+
if (!output) {
|
|
156
|
+
logger.debug('git-operations - getUnstagedFiles', 'No unstaged files found');
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const files = output.split(/\r?\n/).filter(f => f.length > 0);
|
|
161
|
+
|
|
162
|
+
if (extensions.length > 0) {
|
|
163
|
+
const filtered = files.filter(file =>
|
|
164
|
+
extensions.some(ext => file.endsWith(ext))
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
logger.debug(
|
|
168
|
+
'git-operations - getUnstagedFiles',
|
|
169
|
+
'Filtered files by extension',
|
|
170
|
+
{ totalFiles: files.length, filteredFiles: filtered.length, extensions }
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
return filtered;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return files;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Gets all tracked files in repository
|
|
181
|
+
* Why: For comprehensive analysis of entire codebase
|
|
182
|
+
*
|
|
183
|
+
* @param {Object} options - Filter options
|
|
184
|
+
* @param {Array<string>} options.extensions - File extensions to filter (e.g., ['.java', '.xml'])
|
|
185
|
+
* @returns {Array<string>} Array of all tracked file paths
|
|
186
|
+
*/
|
|
187
|
+
const getAllTrackedFiles = ({ extensions = [] } = {}) => {
|
|
188
|
+
logger.debug(
|
|
189
|
+
'git-operations - getAllTrackedFiles',
|
|
190
|
+
'Getting all tracked files',
|
|
191
|
+
{ extensions }
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// Why: ls-files shows all tracked files in the repository
|
|
195
|
+
const output = execGitCommand('ls-files');
|
|
196
|
+
|
|
197
|
+
if (!output) {
|
|
198
|
+
logger.debug('git-operations - getAllTrackedFiles', 'No tracked files found');
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const files = output.split(/\r?\n/).filter(f => f.length > 0);
|
|
203
|
+
|
|
204
|
+
if (extensions.length > 0) {
|
|
205
|
+
const filtered = files.filter(file =>
|
|
206
|
+
extensions.some(ext => file.endsWith(ext))
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
logger.debug(
|
|
210
|
+
'git-operations - getAllTrackedFiles',
|
|
211
|
+
'Filtered files by extension',
|
|
212
|
+
{ totalFiles: files.length, filteredFiles: filtered.length, extensions }
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
return filtered;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return files;
|
|
219
|
+
};
|
|
220
|
+
|
|
133
221
|
/**
|
|
134
222
|
* Gets the diff for a specific file
|
|
135
223
|
* Why: Shows what changed in a file, essential for code review
|
|
@@ -490,6 +578,122 @@ const getBranchPushStatus = (branchName) => {
|
|
|
490
578
|
return status;
|
|
491
579
|
};
|
|
492
580
|
|
|
581
|
+
/**
|
|
582
|
+
* Creates a git commit with the specified message
|
|
583
|
+
* Why: Allows programmatic commit creation after analysis approval
|
|
584
|
+
*
|
|
585
|
+
* @param {string} message - Commit message (use "auto" for auto-generation)
|
|
586
|
+
* @param {Object} options - Commit options
|
|
587
|
+
* @param {boolean} options.noVerify - Skip pre-commit and commit-msg hooks (default: false)
|
|
588
|
+
* @returns {Object} Result object with:
|
|
589
|
+
* - success: boolean
|
|
590
|
+
* - output: string (commit output including hash)
|
|
591
|
+
* - error: string (error message if failed)
|
|
592
|
+
*/
|
|
593
|
+
const createCommit = (message, { noVerify = false } = {}) => {
|
|
594
|
+
logger.debug('git-operations - createCommit', 'Creating commit', { message, noVerify });
|
|
595
|
+
|
|
596
|
+
try {
|
|
597
|
+
const flags = noVerify ? '--no-verify ' : '';
|
|
598
|
+
// Escape double quotes in message for shell safety
|
|
599
|
+
const escapedMessage = message.replace(/"/g, '\\"');
|
|
600
|
+
const output = execGitCommand(`commit ${flags}-m "${escapedMessage}"`);
|
|
601
|
+
|
|
602
|
+
logger.debug('git-operations - createCommit', 'Commit successful', { output });
|
|
603
|
+
|
|
604
|
+
return {
|
|
605
|
+
success: true,
|
|
606
|
+
output,
|
|
607
|
+
error: ''
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
} catch (error) {
|
|
611
|
+
logger.error('git-operations - createCommit', 'Commit failed', error);
|
|
612
|
+
|
|
613
|
+
return {
|
|
614
|
+
success: false,
|
|
615
|
+
output: '',
|
|
616
|
+
error: error.output || error.cause?.message || error.message || 'Unknown commit error'
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
};
|
|
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
|
+
|
|
493
697
|
/**
|
|
494
698
|
* Pushes branch to remote
|
|
495
699
|
* Why: Publishes local branch to remote before creating PR
|
|
@@ -537,9 +741,332 @@ const pushBranch = (branchName, { setUpstream = false } = {}) => {
|
|
|
537
741
|
}
|
|
538
742
|
};
|
|
539
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
|
+
|
|
540
1065
|
export {
|
|
541
1066
|
GitError,
|
|
542
1067
|
getStagedFiles,
|
|
1068
|
+
getUnstagedFiles,
|
|
1069
|
+
getAllTrackedFiles,
|
|
543
1070
|
getFileDiff,
|
|
544
1071
|
getFileContentFromStaging,
|
|
545
1072
|
isNewFile,
|
|
@@ -550,5 +1077,14 @@ export {
|
|
|
550
1077
|
getRemoteName,
|
|
551
1078
|
verifyRemoteExists,
|
|
552
1079
|
getBranchPushStatus,
|
|
553
|
-
pushBranch
|
|
1080
|
+
pushBranch,
|
|
1081
|
+
stageFiles,
|
|
1082
|
+
createCommit,
|
|
1083
|
+
fetchRemote,
|
|
1084
|
+
branchExists,
|
|
1085
|
+
getRemoteBranches,
|
|
1086
|
+
resolveBaseBranch,
|
|
1087
|
+
getChangedFilesBetweenRefs,
|
|
1088
|
+
getDiffBetweenRefs,
|
|
1089
|
+
getCommitsBetweenRefs
|
|
554
1090
|
};
|
|
@@ -65,16 +65,35 @@ function execGitTagCommand(command) {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Checks if a tag is a valid semver tag
|
|
70
|
+
* Why: Filter out non-version tags (Docker tags, etc.)
|
|
71
|
+
*
|
|
72
|
+
* @param {string} tagName - Tag name to check
|
|
73
|
+
* @returns {boolean} True if tag matches semver pattern
|
|
74
|
+
*/
|
|
75
|
+
export function isSemverTag(tagName) {
|
|
76
|
+
// Match: v1.2.3, 1.2.3, v1.2.3-SNAPSHOT, 1.2.3-RC1, etc.
|
|
77
|
+
const semverPattern = /^v?\d+\.\d+\.\d+(-[\w.]+)?$/;
|
|
78
|
+
return semverPattern.test(tagName);
|
|
79
|
+
}
|
|
80
|
+
|
|
68
81
|
/**
|
|
69
82
|
* Parses tag name to extract version
|
|
70
83
|
* Why: Tags have 'v' prefix (v2.7.0), need clean version
|
|
71
84
|
*
|
|
72
85
|
* @param {string} tagName - Tag name (e.g., "v2.7.0-SNAPSHOT")
|
|
73
|
-
* @returns {string} Version without 'v' prefix
|
|
86
|
+
* @returns {string|null} Version without 'v' prefix, or null if not a semver tag
|
|
74
87
|
*/
|
|
75
88
|
export function parseTagVersion(tagName) {
|
|
76
89
|
logger.debug('git-tag-manager - parseTagVersion', 'Parsing tag version', { tagName });
|
|
77
90
|
|
|
91
|
+
// Check if it's a valid semver tag first
|
|
92
|
+
if (!isSemverTag(tagName)) {
|
|
93
|
+
logger.debug('git-tag-manager - parseTagVersion', 'Not a semver tag, skipping', { tagName });
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
78
97
|
// Remove 'v' prefix if present
|
|
79
98
|
const version = tagName.replace(/^v/, '');
|
|
80
99
|
|
|
@@ -136,7 +155,7 @@ export function getLocalTags() {
|
|
|
136
155
|
* Gets latest local tag (by version order)
|
|
137
156
|
* Why: Determines current version for alignment checks
|
|
138
157
|
*
|
|
139
|
-
* @returns {string|null} Latest tag name or null if no tags
|
|
158
|
+
* @returns {string|null} Latest semver tag name or null if no tags
|
|
140
159
|
*/
|
|
141
160
|
export function getLatestLocalTag() {
|
|
142
161
|
logger.debug('git-tag-manager - getLatestLocalTag', 'Getting latest local tag');
|
|
@@ -151,9 +170,24 @@ export function getLatestLocalTag() {
|
|
|
151
170
|
}
|
|
152
171
|
|
|
153
172
|
const tags = output.split(/\r?\n/).filter(t => t.length > 0);
|
|
154
|
-
const latestTag = tags[0] || null;
|
|
155
173
|
|
|
156
|
-
|
|
174
|
+
// Filter to only semver tags
|
|
175
|
+
const semverTags = tags.filter(isSemverTag);
|
|
176
|
+
|
|
177
|
+
if (semverTags.length === 0) {
|
|
178
|
+
logger.debug('git-tag-manager - getLatestLocalTag', 'No semver tags found', {
|
|
179
|
+
totalTags: tags.length
|
|
180
|
+
});
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const latestTag = semverTags[0];
|
|
185
|
+
|
|
186
|
+
logger.debug('git-tag-manager - getLatestLocalTag', 'Latest semver tag found', {
|
|
187
|
+
latestTag,
|
|
188
|
+
totalTags: tags.length,
|
|
189
|
+
semverTags: semverTags.length
|
|
190
|
+
});
|
|
157
191
|
|
|
158
192
|
return latestTag;
|
|
159
193
|
|
|
@@ -214,7 +248,7 @@ export async function getRemoteTags(remoteName = null) {
|
|
|
214
248
|
* Why: Compare local version with deployed version
|
|
215
249
|
*
|
|
216
250
|
* @param {string} remoteName - Remote name (default: 'origin')
|
|
217
|
-
* @returns {Promise<string|null>} Latest tag name or null
|
|
251
|
+
* @returns {Promise<string|null>} Latest semver tag name or null
|
|
218
252
|
*/
|
|
219
253
|
export async function getLatestRemoteTag(remoteName = null) {
|
|
220
254
|
logger.debug('git-tag-manager - getLatestRemoteTag', 'Getting latest remote tag');
|
|
@@ -227,12 +261,24 @@ export async function getLatestRemoteTag(remoteName = null) {
|
|
|
227
261
|
return null;
|
|
228
262
|
}
|
|
229
263
|
|
|
264
|
+
// Filter to only semver tags
|
|
265
|
+
const semverTags = tags.filter(isSemverTag);
|
|
266
|
+
|
|
267
|
+
if (semverTags.length === 0) {
|
|
268
|
+
logger.debug('git-tag-manager - getLatestRemoteTag', 'No semver tags found', {
|
|
269
|
+
totalTags: tags.length
|
|
270
|
+
});
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
230
274
|
// Sort tags by version (descending)
|
|
231
|
-
|
|
232
|
-
const sortedTags = tags.sort((a, b) => {
|
|
275
|
+
const sortedTags = semverTags.sort((a, b) => {
|
|
233
276
|
const versionA = parseTagVersion(a);
|
|
234
277
|
const versionB = parseTagVersion(b);
|
|
235
278
|
|
|
279
|
+
// Both should be valid since we filtered, but check anyway
|
|
280
|
+
if (!versionA || !versionB) return 0;
|
|
281
|
+
|
|
236
282
|
try {
|
|
237
283
|
// compareVersions returns: 1 if a > b, -1 if a < b, 0 if equal
|
|
238
284
|
// For descending sort, we need to reverse the comparison
|
|
@@ -250,7 +296,11 @@ export async function getLatestRemoteTag(remoteName = null) {
|
|
|
250
296
|
|
|
251
297
|
const latestTag = sortedTags[0];
|
|
252
298
|
|
|
253
|
-
logger.debug('git-tag-manager - getLatestRemoteTag', 'Latest remote tag found', {
|
|
299
|
+
logger.debug('git-tag-manager - getLatestRemoteTag', 'Latest remote semver tag found', {
|
|
300
|
+
latestTag,
|
|
301
|
+
totalTags: tags.length,
|
|
302
|
+
semverTags: semverTags.length
|
|
303
|
+
});
|
|
254
304
|
|
|
255
305
|
return latestTag;
|
|
256
306
|
|