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.
@@ -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
- logger.debug('git-tag-manager - getLatestLocalTag', 'Latest tag found', { latestTag });
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
- // Why: Reuse compareVersions for consistency and maintainability
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', { latestTag });
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