claude-git-hooks 2.11.0 → 2.13.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.
@@ -0,0 +1,566 @@
1
+ /**
2
+ * File: git-tag-manager.js
3
+ * Purpose: Manage Git tag operations for version management
4
+ *
5
+ * Handles:
6
+ * - Creating annotated tags
7
+ * - Listing local and remote tags
8
+ * - Comparing tag versions
9
+ * - Pushing tags to remote
10
+ */
11
+
12
+ import { execSync } from 'child_process';
13
+ import logger from './logger.js';
14
+ import { getRemoteName } from './git-operations.js';
15
+ import { compareVersions } from './version-manager.js';
16
+
17
+ /**
18
+ * Custom error for git tag operation failures
19
+ */
20
+ class GitTagError extends Error {
21
+ constructor(message, { command, cause, output } = {}) {
22
+ super(message);
23
+ this.name = 'GitTagError';
24
+ this.command = command;
25
+ this.cause = cause;
26
+ this.output = output;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Executes a git command for tag operations
32
+ * Why: Consistent error handling for tag-specific operations
33
+ *
34
+ * @param {string} command - Git command to execute
35
+ * @returns {string} Command output
36
+ * @throws {GitTagError} If command fails
37
+ */
38
+ function execGitTagCommand(command) {
39
+ logger.debug('git-tag-manager - execGitTagCommand', 'Executing command', { command });
40
+
41
+ try {
42
+ const output = execSync(command, {
43
+ encoding: 'utf8',
44
+ stdio: ['pipe', 'pipe', 'pipe']
45
+ });
46
+
47
+ logger.debug('git-tag-manager - execGitTagCommand', 'Command successful', {
48
+ command,
49
+ outputLength: output.length
50
+ });
51
+
52
+ return output.trim();
53
+
54
+ } catch (error) {
55
+ logger.error('git-tag-manager - execGitTagCommand', 'Command failed', {
56
+ command,
57
+ error: error.message
58
+ });
59
+
60
+ throw new GitTagError('Git tag command failed', {
61
+ command,
62
+ cause: error,
63
+ output: error.stderr || error.stdout
64
+ });
65
+ }
66
+ }
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
+
81
+ /**
82
+ * Parses tag name to extract version
83
+ * Why: Tags have 'v' prefix (v2.7.0), need clean version
84
+ *
85
+ * @param {string} tagName - Tag name (e.g., "v2.7.0-SNAPSHOT")
86
+ * @returns {string|null} Version without 'v' prefix, or null if not a semver tag
87
+ */
88
+ export function parseTagVersion(tagName) {
89
+ logger.debug('git-tag-manager - parseTagVersion', 'Parsing tag version', { tagName });
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
+
97
+ // Remove 'v' prefix if present
98
+ const version = tagName.replace(/^v/, '');
99
+
100
+ logger.debug('git-tag-manager - parseTagVersion', 'Version parsed', { tagName, version });
101
+
102
+ return version;
103
+ }
104
+
105
+ /**
106
+ * Formats version as tag name
107
+ * Why: Adds 'v' prefix consistently
108
+ *
109
+ * @param {string} version - Version string (e.g., "2.7.0")
110
+ * @returns {string} Tag name (e.g., "v2.7.0")
111
+ */
112
+ export function formatTagName(version) {
113
+ logger.debug('git-tag-manager - formatTagName', 'Formatting tag name', { version });
114
+
115
+ // Add 'v' prefix if not present
116
+ const tagName = version.startsWith('v') ? version : `v${version}`;
117
+
118
+ logger.debug('git-tag-manager - formatTagName', 'Tag name formatted', { version, tagName });
119
+
120
+ return tagName;
121
+ }
122
+
123
+ /**
124
+ * Gets all local tags
125
+ * Why: Lists tags for comparison and validation
126
+ *
127
+ * @returns {Array<string>} Array of tag names
128
+ */
129
+ export function getLocalTags() {
130
+ logger.debug('git-tag-manager - getLocalTags', 'Getting local tags');
131
+
132
+ try {
133
+ const output = execGitTagCommand('git tag --list');
134
+
135
+ if (!output) {
136
+ logger.debug('git-tag-manager - getLocalTags', 'No local tags found');
137
+ return [];
138
+ }
139
+
140
+ const tags = output.split(/\r?\n/).filter(t => t.length > 0);
141
+
142
+ logger.debug('git-tag-manager - getLocalTags', 'Local tags retrieved', {
143
+ count: tags.length
144
+ });
145
+
146
+ return tags;
147
+
148
+ } catch (error) {
149
+ logger.error('git-tag-manager - getLocalTags', 'Failed to get local tags', error);
150
+ return [];
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Gets latest local tag (by version order)
156
+ * Why: Determines current version for alignment checks
157
+ *
158
+ * @returns {string|null} Latest semver tag name or null if no tags
159
+ */
160
+ export function getLatestLocalTag() {
161
+ logger.debug('git-tag-manager - getLatestLocalTag', 'Getting latest local tag');
162
+
163
+ try {
164
+ // Get tags sorted by version (descending)
165
+ const output = execGitTagCommand('git tag --list --sort=-v:refname');
166
+
167
+ if (!output) {
168
+ logger.debug('git-tag-manager - getLatestLocalTag', 'No tags found');
169
+ return null;
170
+ }
171
+
172
+ const tags = output.split(/\r?\n/).filter(t => t.length > 0);
173
+
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
+ });
191
+
192
+ return latestTag;
193
+
194
+ } catch (error) {
195
+ logger.error('git-tag-manager - getLatestLocalTag', 'Failed to get latest tag', error);
196
+ return null;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Gets all remote tags
202
+ * Why: Compare local tags with remote for push status
203
+ *
204
+ * @param {string} remoteName - Remote name (default: 'origin')
205
+ * @returns {Promise<Array<string>>} Array of tag names
206
+ */
207
+ export async function getRemoteTags(remoteName = null) {
208
+ logger.debug('git-tag-manager - getRemoteTags', 'Getting remote tags', { remoteName });
209
+
210
+ try {
211
+ const remote = remoteName || getRemoteName();
212
+ const output = execGitTagCommand(`git ls-remote --tags ${remote}`);
213
+
214
+ if (!output) {
215
+ logger.debug('git-tag-manager - getRemoteTags', 'No remote tags found');
216
+ return [];
217
+ }
218
+
219
+ // Parse output: "hash refs/tags/tagname"
220
+ const tags = output
221
+ .split(/\r?\n/)
222
+ .filter(line => line.length > 0)
223
+ .map(line => {
224
+ const parts = line.split('\t');
225
+ if (parts.length < 2) return null;
226
+ const ref = parts[1];
227
+ // Extract tag name from refs/tags/v2.7.0
228
+ const match = ref.match(/refs\/tags\/(.+?)(\^\{\})?$/);
229
+ return match ? match[1] : null;
230
+ })
231
+ .filter(tag => tag !== null);
232
+
233
+ logger.debug('git-tag-manager - getRemoteTags', 'Remote tags retrieved', {
234
+ remote,
235
+ count: tags.length
236
+ });
237
+
238
+ return tags;
239
+
240
+ } catch (error) {
241
+ logger.error('git-tag-manager - getRemoteTags', 'Failed to get remote tags', error);
242
+ return [];
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Gets latest remote tag (by version order)
248
+ * Why: Compare local version with deployed version
249
+ *
250
+ * @param {string} remoteName - Remote name (default: 'origin')
251
+ * @returns {Promise<string|null>} Latest semver tag name or null
252
+ */
253
+ export async function getLatestRemoteTag(remoteName = null) {
254
+ logger.debug('git-tag-manager - getLatestRemoteTag', 'Getting latest remote tag');
255
+
256
+ try {
257
+ const tags = await getRemoteTags(remoteName);
258
+
259
+ if (tags.length === 0) {
260
+ logger.debug('git-tag-manager - getLatestRemoteTag', 'No remote tags found');
261
+ return null;
262
+ }
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
+
274
+ // Sort tags by version (descending)
275
+ const sortedTags = semverTags.sort((a, b) => {
276
+ const versionA = parseTagVersion(a);
277
+ const versionB = parseTagVersion(b);
278
+
279
+ // Both should be valid since we filtered, but check anyway
280
+ if (!versionA || !versionB) return 0;
281
+
282
+ try {
283
+ // compareVersions returns: 1 if a > b, -1 if a < b, 0 if equal
284
+ // For descending sort, we need to reverse the comparison
285
+ return -compareVersions(versionA, versionB);
286
+ } catch (error) {
287
+ // Fallback: keep original order if comparison fails
288
+ logger.debug('git-tag-manager - getLatestRemoteTag', 'Version comparison failed', {
289
+ versionA,
290
+ versionB,
291
+ error: error.message
292
+ });
293
+ return 0;
294
+ }
295
+ });
296
+
297
+ const latestTag = sortedTags[0];
298
+
299
+ logger.debug('git-tag-manager - getLatestRemoteTag', 'Latest remote semver tag found', {
300
+ latestTag,
301
+ totalTags: tags.length,
302
+ semverTags: semverTags.length
303
+ });
304
+
305
+ return latestTag;
306
+
307
+ } catch (error) {
308
+ logger.error('git-tag-manager - getLatestRemoteTag', 'Failed to get latest remote tag', error);
309
+ return null;
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Checks if tag exists
315
+ * Why: Prevents duplicate tag creation
316
+ *
317
+ * @param {string} tagName - Tag name to check
318
+ * @param {string} location - Where to check: 'local' | 'remote' | 'both'
319
+ * @returns {Promise<boolean>} True if tag exists
320
+ */
321
+ export async function tagExists(tagName, location = 'local') {
322
+ logger.debug('git-tag-manager - tagExists', 'Checking tag existence', { tagName, location });
323
+
324
+ try {
325
+ if (location === 'local' || location === 'both') {
326
+ const localTags = getLocalTags();
327
+ if (localTags.includes(tagName)) {
328
+ logger.debug('git-tag-manager - tagExists', 'Tag exists locally', { tagName });
329
+ return true;
330
+ }
331
+ }
332
+
333
+ if (location === 'remote' || location === 'both') {
334
+ const remoteTags = await getRemoteTags();
335
+ if (remoteTags.includes(tagName)) {
336
+ logger.debug('git-tag-manager - tagExists', 'Tag exists on remote', { tagName });
337
+ return true;
338
+ }
339
+ }
340
+
341
+ logger.debug('git-tag-manager - tagExists', 'Tag does not exist', { tagName, location });
342
+ return false;
343
+
344
+ } catch (error) {
345
+ logger.error('git-tag-manager - tagExists', 'Failed to check tag existence', error);
346
+ return false;
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Creates annotated Git tag
352
+ * Why: Tags mark version releases with metadata
353
+ *
354
+ * @param {string} version - Version string (e.g., "2.7.0")
355
+ * @param {string} message - Tag annotation message
356
+ * @param {Object} options - Tag options
357
+ * @param {boolean} options.force - Force tag creation (overwrite existing)
358
+ * @returns {Object} Result: { success, tagName, error }
359
+ */
360
+ export function createTag(version, message, { force = false } = {}) {
361
+ logger.debug('git-tag-manager - createTag', 'Creating tag', { version, message, force });
362
+
363
+ const tagName = formatTagName(version);
364
+
365
+ try {
366
+ // Check if tag already exists (unless force)
367
+ if (!force) {
368
+ const localTags = getLocalTags();
369
+ if (localTags.includes(tagName)) {
370
+ logger.warning('git-tag-manager - createTag', 'Tag already exists', { tagName });
371
+ return {
372
+ success: false,
373
+ tagName,
374
+ error: `Tag ${tagName} already exists. Use --force to overwrite.`
375
+ };
376
+ }
377
+ }
378
+
379
+ // Create annotated tag
380
+ // Why: Escape message to prevent shell injection and handle special characters
381
+ const escapedMessage = message.replace(/"/g, '\\"').replace(/`/g, '\\`').replace(/\$/g, '\\$');
382
+ const forceFlag = force ? '-f' : '';
383
+ const command = `git tag ${forceFlag} -a ${tagName} -m "${escapedMessage}"`;
384
+ execGitTagCommand(command);
385
+
386
+ logger.debug('git-tag-manager - createTag', 'Tag created successfully', { tagName });
387
+
388
+ return {
389
+ success: true,
390
+ tagName,
391
+ error: null
392
+ };
393
+
394
+ } catch (error) {
395
+ logger.error('git-tag-manager - createTag', 'Failed to create tag', error);
396
+
397
+ return {
398
+ success: false,
399
+ tagName,
400
+ error: error.message
401
+ };
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Pushes tag(s) to remote
407
+ * Why: Publishes version tags to remote repository
408
+ *
409
+ * @param {string} remoteName - Remote name (default: from config)
410
+ * @param {string|Array<string>} tags - Tag name(s) to push, or 'all' for all tags
411
+ * @returns {Object} Result: { success, pushed, failed, error }
412
+ */
413
+ export function pushTags(remoteName = null, tags = 'all') {
414
+ logger.debug('git-tag-manager - pushTags', 'Pushing tags', { remoteName, tags });
415
+
416
+ const remote = remoteName || getRemoteName();
417
+
418
+ try {
419
+ if (tags === 'all') {
420
+ // Push all tags
421
+ execGitTagCommand(`git push ${remote} --tags`);
422
+
423
+ logger.debug('git-tag-manager - pushTags', 'All tags pushed successfully');
424
+
425
+ return {
426
+ success: true,
427
+ pushed: 'all',
428
+ failed: [],
429
+ error: null
430
+ };
431
+
432
+ } else {
433
+ // Push specific tag(s)
434
+ const tagList = Array.isArray(tags) ? tags : [tags];
435
+ const pushed = [];
436
+ const failed = [];
437
+
438
+ for (const tag of tagList) {
439
+ try {
440
+ execGitTagCommand(`git push ${remote} ${tag}`);
441
+ pushed.push(tag);
442
+ logger.debug('git-tag-manager - pushTags', 'Tag pushed', { tag });
443
+ } catch (error) {
444
+ failed.push({ tag, error: error.message });
445
+ logger.error('git-tag-manager - pushTags', 'Failed to push tag', { tag, error });
446
+ }
447
+ }
448
+
449
+ const success = failed.length === 0;
450
+
451
+ logger.debug('git-tag-manager - pushTags', 'Tag push complete', {
452
+ pushed: pushed.length,
453
+ failed: failed.length
454
+ });
455
+
456
+ return {
457
+ success,
458
+ pushed,
459
+ failed,
460
+ error: failed.length > 0 ? `Failed to push ${failed.length} tag(s)` : null
461
+ };
462
+ }
463
+
464
+ } catch (error) {
465
+ logger.error('git-tag-manager - pushTags', 'Failed to push tags', error);
466
+
467
+ return {
468
+ success: false,
469
+ pushed: [],
470
+ failed: [],
471
+ error: error.message
472
+ };
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Compares local and remote tags
478
+ * Why: Identifies unpushed tags for create-pr validation
479
+ *
480
+ * @returns {Promise<Object>} Comparison result: { localNewer, remoteNewer, common }
481
+ */
482
+ export async function compareLocalAndRemoteTags() {
483
+ logger.debug('git-tag-manager - compareLocalAndRemoteTags', 'Comparing tags');
484
+
485
+ try {
486
+ const localTags = getLocalTags();
487
+ const remoteTags = await getRemoteTags();
488
+
489
+ const localSet = new Set(localTags);
490
+ const remoteSet = new Set(remoteTags);
491
+
492
+ // Tags only in local (not pushed)
493
+ const localNewer = localTags.filter(tag => !remoteSet.has(tag));
494
+
495
+ // Tags only in remote (local behind)
496
+ const remoteNewer = remoteTags.filter(tag => !localSet.has(tag));
497
+
498
+ // Tags in both
499
+ const common = localTags.filter(tag => remoteSet.has(tag));
500
+
501
+ const result = {
502
+ localNewer,
503
+ remoteNewer,
504
+ common
505
+ };
506
+
507
+ logger.debug('git-tag-manager - compareLocalAndRemoteTags', 'Comparison complete', {
508
+ localNewer: localNewer.length,
509
+ remoteNewer: remoteNewer.length,
510
+ common: common.length
511
+ });
512
+
513
+ return result;
514
+
515
+ } catch (error) {
516
+ logger.error('git-tag-manager - compareLocalAndRemoteTags', 'Comparison failed', error);
517
+
518
+ return {
519
+ localNewer: [],
520
+ remoteNewer: [],
521
+ common: []
522
+ };
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Deletes a tag
528
+ * Why: Remove incorrect or test tags
529
+ *
530
+ * @param {string} tagName - Tag name to delete
531
+ * @param {Object} options - Delete options
532
+ * @param {boolean} options.remote - Also delete from remote
533
+ * @returns {Object} Result: { success, error }
534
+ */
535
+ export function deleteTag(tagName, { remote = false } = {}) {
536
+ logger.debug('git-tag-manager - deleteTag', 'Deleting tag', { tagName, remote });
537
+
538
+ try {
539
+ // Delete local tag
540
+ execGitTagCommand(`git tag -d ${tagName}`);
541
+
542
+ logger.debug('git-tag-manager - deleteTag', 'Local tag deleted', { tagName });
543
+
544
+ // Delete remote tag if requested
545
+ if (remote) {
546
+ const remoteName = getRemoteName();
547
+ execGitTagCommand(`git push ${remoteName} --delete ${tagName}`);
548
+ logger.debug('git-tag-manager - deleteTag', 'Remote tag deleted', { tagName });
549
+ }
550
+
551
+ return {
552
+ success: true,
553
+ error: null
554
+ };
555
+
556
+ } catch (error) {
557
+ logger.error('git-tag-manager - deleteTag', 'Failed to delete tag', error);
558
+
559
+ return {
560
+ success: false,
561
+ error: error.message
562
+ };
563
+ }
564
+ }
565
+
566
+ export { GitTagError };
@@ -66,7 +66,7 @@ export const showPRPreview = (prData) => {
66
66
  */
67
67
  const truncate = (str, maxLen) => {
68
68
  const padded = str.padEnd(maxLen, ' ');
69
- return padded.length > maxLen ? padded.substring(0, maxLen - 3) + '...' : padded;
69
+ return padded.length > maxLen ? `${padded.substring(0, maxLen - 3) }...` : padded;
70
70
  };
71
71
 
72
72
  /**
@@ -312,3 +312,88 @@ export const waitForEnter = async (message = 'Press Enter to continue') => {
312
312
  });
313
313
  });
314
314
  };
315
+
316
+ /**
317
+ * Show detailed issue list with enhanced formatting
318
+ * Why: Give user complete context about detected issues
319
+ * @private
320
+ *
321
+ * @param {Object} result - Analysis result
322
+ */
323
+ const showDetailedIssues = (result) => {
324
+ console.log('\n');
325
+ console.log('═══════════════════════════════════════════════════════════════');
326
+ console.log(' DETAILED ISSUE LIST');
327
+ console.log('═══════════════════════════════════════════════════════════════');
328
+ console.log('');
329
+
330
+ if (Array.isArray(result.details) && result.details.length > 0) {
331
+ result.details.forEach((detail, index) => {
332
+ console.log(`Issue ${index + 1}/${result.details.length}`);
333
+ console.log(` Severity: ${detail.severity}`);
334
+ console.log(` Type: ${detail.type}`);
335
+ console.log(` Location: ${detail.file}:${detail.line || '?'}`);
336
+ if (detail.method) console.log(` Method: ${detail.method}`);
337
+ console.log(` Message: ${detail.message}`);
338
+ if (detail.rule) console.log(` Rule: ${detail.rule}`);
339
+ console.log('');
340
+ });
341
+ }
342
+
343
+ console.log('═══════════════════════════════════════════════════════════════');
344
+ };
345
+
346
+ /**
347
+ * Prompt user to confirm commit with non-blocking issues
348
+ * Why: Give developers control to abort and fix issues immediately
349
+ *
350
+ * Shows issue summary and options: continue, abort, view details
351
+ * Loops if user selects "view" to show details then return to prompt
352
+ *
353
+ * @param {Object} result - Analysis result with issues
354
+ * @returns {Promise<string>} - 'continue' or 'abort'
355
+ */
356
+ export const promptUserConfirmation = async (result) => {
357
+ const { issues = {}, details = [] } = result;
358
+ const { major = 0, minor = 0, info = 0 } = issues;
359
+
360
+ // Calculate total non-blocking issues
361
+ const totalIssues = major + minor + info;
362
+ const fileCount = new Set(details.map(d => d.file)).size;
363
+
364
+ console.log('\n');
365
+ console.log('⚠️ Non-blocking issues detected');
366
+ console.log('');
367
+ console.log(`📊 ${totalIssues} issue(s) found across ${fileCount} file(s):`);
368
+ if (major > 0) console.log(` 🟡 Major: ${major}`);
369
+ if (minor > 0) console.log(` 🔵 Minor: ${minor}`);
370
+ if (info > 0) console.log(` ⚪ Info: ${info}`);
371
+ console.log('');
372
+
373
+ // Loop to handle 'view' option
374
+ // eslint-disable-next-line no-constant-condition
375
+ while (true) {
376
+ const options = [
377
+ { key: 'y', label: 'Continue - Create commit with issues' },
378
+ { key: 'n', label: 'Abort - Fix issues before committing' },
379
+ { key: 'v', label: 'View - Show detailed issue list' }
380
+ ];
381
+
382
+ const choice = await promptMenu(
383
+ 'Do you want to continue with the commit?',
384
+ options,
385
+ 'n' // Default to abort (safer)
386
+ );
387
+
388
+ if (choice === 'y') {
389
+ return 'continue';
390
+ } else if (choice === 'n') {
391
+ return 'abort';
392
+ } else if (choice === 'v') {
393
+ // Show details and loop back to prompt
394
+ showDetailedIssues(result);
395
+ await waitForEnter('Press Enter to return to menu');
396
+ // Continue loop
397
+ }
398
+ }
399
+ };