claude-git-hooks 2.11.0 → 2.12.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,516 @@
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
+ * Parses tag name to extract version
70
+ * Why: Tags have 'v' prefix (v2.7.0), need clean version
71
+ *
72
+ * @param {string} tagName - Tag name (e.g., "v2.7.0-SNAPSHOT")
73
+ * @returns {string} Version without 'v' prefix
74
+ */
75
+ export function parseTagVersion(tagName) {
76
+ logger.debug('git-tag-manager - parseTagVersion', 'Parsing tag version', { tagName });
77
+
78
+ // Remove 'v' prefix if present
79
+ const version = tagName.replace(/^v/, '');
80
+
81
+ logger.debug('git-tag-manager - parseTagVersion', 'Version parsed', { tagName, version });
82
+
83
+ return version;
84
+ }
85
+
86
+ /**
87
+ * Formats version as tag name
88
+ * Why: Adds 'v' prefix consistently
89
+ *
90
+ * @param {string} version - Version string (e.g., "2.7.0")
91
+ * @returns {string} Tag name (e.g., "v2.7.0")
92
+ */
93
+ export function formatTagName(version) {
94
+ logger.debug('git-tag-manager - formatTagName', 'Formatting tag name', { version });
95
+
96
+ // Add 'v' prefix if not present
97
+ const tagName = version.startsWith('v') ? version : `v${version}`;
98
+
99
+ logger.debug('git-tag-manager - formatTagName', 'Tag name formatted', { version, tagName });
100
+
101
+ return tagName;
102
+ }
103
+
104
+ /**
105
+ * Gets all local tags
106
+ * Why: Lists tags for comparison and validation
107
+ *
108
+ * @returns {Array<string>} Array of tag names
109
+ */
110
+ export function getLocalTags() {
111
+ logger.debug('git-tag-manager - getLocalTags', 'Getting local tags');
112
+
113
+ try {
114
+ const output = execGitTagCommand('git tag --list');
115
+
116
+ if (!output) {
117
+ logger.debug('git-tag-manager - getLocalTags', 'No local tags found');
118
+ return [];
119
+ }
120
+
121
+ const tags = output.split(/\r?\n/).filter(t => t.length > 0);
122
+
123
+ logger.debug('git-tag-manager - getLocalTags', 'Local tags retrieved', {
124
+ count: tags.length
125
+ });
126
+
127
+ return tags;
128
+
129
+ } catch (error) {
130
+ logger.error('git-tag-manager - getLocalTags', 'Failed to get local tags', error);
131
+ return [];
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Gets latest local tag (by version order)
137
+ * Why: Determines current version for alignment checks
138
+ *
139
+ * @returns {string|null} Latest tag name or null if no tags
140
+ */
141
+ export function getLatestLocalTag() {
142
+ logger.debug('git-tag-manager - getLatestLocalTag', 'Getting latest local tag');
143
+
144
+ try {
145
+ // Get tags sorted by version (descending)
146
+ const output = execGitTagCommand('git tag --list --sort=-v:refname');
147
+
148
+ if (!output) {
149
+ logger.debug('git-tag-manager - getLatestLocalTag', 'No tags found');
150
+ return null;
151
+ }
152
+
153
+ const tags = output.split(/\r?\n/).filter(t => t.length > 0);
154
+ const latestTag = tags[0] || null;
155
+
156
+ logger.debug('git-tag-manager - getLatestLocalTag', 'Latest tag found', { latestTag });
157
+
158
+ return latestTag;
159
+
160
+ } catch (error) {
161
+ logger.error('git-tag-manager - getLatestLocalTag', 'Failed to get latest tag', error);
162
+ return null;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Gets all remote tags
168
+ * Why: Compare local tags with remote for push status
169
+ *
170
+ * @param {string} remoteName - Remote name (default: 'origin')
171
+ * @returns {Promise<Array<string>>} Array of tag names
172
+ */
173
+ export async function getRemoteTags(remoteName = null) {
174
+ logger.debug('git-tag-manager - getRemoteTags', 'Getting remote tags', { remoteName });
175
+
176
+ try {
177
+ const remote = remoteName || getRemoteName();
178
+ const output = execGitTagCommand(`git ls-remote --tags ${remote}`);
179
+
180
+ if (!output) {
181
+ logger.debug('git-tag-manager - getRemoteTags', 'No remote tags found');
182
+ return [];
183
+ }
184
+
185
+ // Parse output: "hash refs/tags/tagname"
186
+ const tags = output
187
+ .split(/\r?\n/)
188
+ .filter(line => line.length > 0)
189
+ .map(line => {
190
+ const parts = line.split('\t');
191
+ if (parts.length < 2) return null;
192
+ const ref = parts[1];
193
+ // Extract tag name from refs/tags/v2.7.0
194
+ const match = ref.match(/refs\/tags\/(.+?)(\^\{\})?$/);
195
+ return match ? match[1] : null;
196
+ })
197
+ .filter(tag => tag !== null);
198
+
199
+ logger.debug('git-tag-manager - getRemoteTags', 'Remote tags retrieved', {
200
+ remote,
201
+ count: tags.length
202
+ });
203
+
204
+ return tags;
205
+
206
+ } catch (error) {
207
+ logger.error('git-tag-manager - getRemoteTags', 'Failed to get remote tags', error);
208
+ return [];
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Gets latest remote tag (by version order)
214
+ * Why: Compare local version with deployed version
215
+ *
216
+ * @param {string} remoteName - Remote name (default: 'origin')
217
+ * @returns {Promise<string|null>} Latest tag name or null
218
+ */
219
+ export async function getLatestRemoteTag(remoteName = null) {
220
+ logger.debug('git-tag-manager - getLatestRemoteTag', 'Getting latest remote tag');
221
+
222
+ try {
223
+ const tags = await getRemoteTags(remoteName);
224
+
225
+ if (tags.length === 0) {
226
+ logger.debug('git-tag-manager - getLatestRemoteTag', 'No remote tags found');
227
+ return null;
228
+ }
229
+
230
+ // Sort tags by version (descending)
231
+ // Why: Reuse compareVersions for consistency and maintainability
232
+ const sortedTags = tags.sort((a, b) => {
233
+ const versionA = parseTagVersion(a);
234
+ const versionB = parseTagVersion(b);
235
+
236
+ try {
237
+ // compareVersions returns: 1 if a > b, -1 if a < b, 0 if equal
238
+ // For descending sort, we need to reverse the comparison
239
+ return -compareVersions(versionA, versionB);
240
+ } catch (error) {
241
+ // Fallback: keep original order if comparison fails
242
+ logger.debug('git-tag-manager - getLatestRemoteTag', 'Version comparison failed', {
243
+ versionA,
244
+ versionB,
245
+ error: error.message
246
+ });
247
+ return 0;
248
+ }
249
+ });
250
+
251
+ const latestTag = sortedTags[0];
252
+
253
+ logger.debug('git-tag-manager - getLatestRemoteTag', 'Latest remote tag found', { latestTag });
254
+
255
+ return latestTag;
256
+
257
+ } catch (error) {
258
+ logger.error('git-tag-manager - getLatestRemoteTag', 'Failed to get latest remote tag', error);
259
+ return null;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Checks if tag exists
265
+ * Why: Prevents duplicate tag creation
266
+ *
267
+ * @param {string} tagName - Tag name to check
268
+ * @param {string} location - Where to check: 'local' | 'remote' | 'both'
269
+ * @returns {Promise<boolean>} True if tag exists
270
+ */
271
+ export async function tagExists(tagName, location = 'local') {
272
+ logger.debug('git-tag-manager - tagExists', 'Checking tag existence', { tagName, location });
273
+
274
+ try {
275
+ if (location === 'local' || location === 'both') {
276
+ const localTags = getLocalTags();
277
+ if (localTags.includes(tagName)) {
278
+ logger.debug('git-tag-manager - tagExists', 'Tag exists locally', { tagName });
279
+ return true;
280
+ }
281
+ }
282
+
283
+ if (location === 'remote' || location === 'both') {
284
+ const remoteTags = await getRemoteTags();
285
+ if (remoteTags.includes(tagName)) {
286
+ logger.debug('git-tag-manager - tagExists', 'Tag exists on remote', { tagName });
287
+ return true;
288
+ }
289
+ }
290
+
291
+ logger.debug('git-tag-manager - tagExists', 'Tag does not exist', { tagName, location });
292
+ return false;
293
+
294
+ } catch (error) {
295
+ logger.error('git-tag-manager - tagExists', 'Failed to check tag existence', error);
296
+ return false;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Creates annotated Git tag
302
+ * Why: Tags mark version releases with metadata
303
+ *
304
+ * @param {string} version - Version string (e.g., "2.7.0")
305
+ * @param {string} message - Tag annotation message
306
+ * @param {Object} options - Tag options
307
+ * @param {boolean} options.force - Force tag creation (overwrite existing)
308
+ * @returns {Object} Result: { success, tagName, error }
309
+ */
310
+ export function createTag(version, message, { force = false } = {}) {
311
+ logger.debug('git-tag-manager - createTag', 'Creating tag', { version, message, force });
312
+
313
+ const tagName = formatTagName(version);
314
+
315
+ try {
316
+ // Check if tag already exists (unless force)
317
+ if (!force) {
318
+ const localTags = getLocalTags();
319
+ if (localTags.includes(tagName)) {
320
+ logger.warning('git-tag-manager - createTag', 'Tag already exists', { tagName });
321
+ return {
322
+ success: false,
323
+ tagName,
324
+ error: `Tag ${tagName} already exists. Use --force to overwrite.`
325
+ };
326
+ }
327
+ }
328
+
329
+ // Create annotated tag
330
+ // Why: Escape message to prevent shell injection and handle special characters
331
+ const escapedMessage = message.replace(/"/g, '\\"').replace(/`/g, '\\`').replace(/\$/g, '\\$');
332
+ const forceFlag = force ? '-f' : '';
333
+ const command = `git tag ${forceFlag} -a ${tagName} -m "${escapedMessage}"`;
334
+ execGitTagCommand(command);
335
+
336
+ logger.debug('git-tag-manager - createTag', 'Tag created successfully', { tagName });
337
+
338
+ return {
339
+ success: true,
340
+ tagName,
341
+ error: null
342
+ };
343
+
344
+ } catch (error) {
345
+ logger.error('git-tag-manager - createTag', 'Failed to create tag', error);
346
+
347
+ return {
348
+ success: false,
349
+ tagName,
350
+ error: error.message
351
+ };
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Pushes tag(s) to remote
357
+ * Why: Publishes version tags to remote repository
358
+ *
359
+ * @param {string} remoteName - Remote name (default: from config)
360
+ * @param {string|Array<string>} tags - Tag name(s) to push, or 'all' for all tags
361
+ * @returns {Object} Result: { success, pushed, failed, error }
362
+ */
363
+ export function pushTags(remoteName = null, tags = 'all') {
364
+ logger.debug('git-tag-manager - pushTags', 'Pushing tags', { remoteName, tags });
365
+
366
+ const remote = remoteName || getRemoteName();
367
+
368
+ try {
369
+ if (tags === 'all') {
370
+ // Push all tags
371
+ execGitTagCommand(`git push ${remote} --tags`);
372
+
373
+ logger.debug('git-tag-manager - pushTags', 'All tags pushed successfully');
374
+
375
+ return {
376
+ success: true,
377
+ pushed: 'all',
378
+ failed: [],
379
+ error: null
380
+ };
381
+
382
+ } else {
383
+ // Push specific tag(s)
384
+ const tagList = Array.isArray(tags) ? tags : [tags];
385
+ const pushed = [];
386
+ const failed = [];
387
+
388
+ for (const tag of tagList) {
389
+ try {
390
+ execGitTagCommand(`git push ${remote} ${tag}`);
391
+ pushed.push(tag);
392
+ logger.debug('git-tag-manager - pushTags', 'Tag pushed', { tag });
393
+ } catch (error) {
394
+ failed.push({ tag, error: error.message });
395
+ logger.error('git-tag-manager - pushTags', 'Failed to push tag', { tag, error });
396
+ }
397
+ }
398
+
399
+ const success = failed.length === 0;
400
+
401
+ logger.debug('git-tag-manager - pushTags', 'Tag push complete', {
402
+ pushed: pushed.length,
403
+ failed: failed.length
404
+ });
405
+
406
+ return {
407
+ success,
408
+ pushed,
409
+ failed,
410
+ error: failed.length > 0 ? `Failed to push ${failed.length} tag(s)` : null
411
+ };
412
+ }
413
+
414
+ } catch (error) {
415
+ logger.error('git-tag-manager - pushTags', 'Failed to push tags', error);
416
+
417
+ return {
418
+ success: false,
419
+ pushed: [],
420
+ failed: [],
421
+ error: error.message
422
+ };
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Compares local and remote tags
428
+ * Why: Identifies unpushed tags for create-pr validation
429
+ *
430
+ * @returns {Promise<Object>} Comparison result: { localNewer, remoteNewer, common }
431
+ */
432
+ export async function compareLocalAndRemoteTags() {
433
+ logger.debug('git-tag-manager - compareLocalAndRemoteTags', 'Comparing tags');
434
+
435
+ try {
436
+ const localTags = getLocalTags();
437
+ const remoteTags = await getRemoteTags();
438
+
439
+ const localSet = new Set(localTags);
440
+ const remoteSet = new Set(remoteTags);
441
+
442
+ // Tags only in local (not pushed)
443
+ const localNewer = localTags.filter(tag => !remoteSet.has(tag));
444
+
445
+ // Tags only in remote (local behind)
446
+ const remoteNewer = remoteTags.filter(tag => !localSet.has(tag));
447
+
448
+ // Tags in both
449
+ const common = localTags.filter(tag => remoteSet.has(tag));
450
+
451
+ const result = {
452
+ localNewer,
453
+ remoteNewer,
454
+ common
455
+ };
456
+
457
+ logger.debug('git-tag-manager - compareLocalAndRemoteTags', 'Comparison complete', {
458
+ localNewer: localNewer.length,
459
+ remoteNewer: remoteNewer.length,
460
+ common: common.length
461
+ });
462
+
463
+ return result;
464
+
465
+ } catch (error) {
466
+ logger.error('git-tag-manager - compareLocalAndRemoteTags', 'Comparison failed', error);
467
+
468
+ return {
469
+ localNewer: [],
470
+ remoteNewer: [],
471
+ common: []
472
+ };
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Deletes a tag
478
+ * Why: Remove incorrect or test tags
479
+ *
480
+ * @param {string} tagName - Tag name to delete
481
+ * @param {Object} options - Delete options
482
+ * @param {boolean} options.remote - Also delete from remote
483
+ * @returns {Object} Result: { success, error }
484
+ */
485
+ export function deleteTag(tagName, { remote = false } = {}) {
486
+ logger.debug('git-tag-manager - deleteTag', 'Deleting tag', { tagName, remote });
487
+
488
+ try {
489
+ // Delete local tag
490
+ execGitTagCommand(`git tag -d ${tagName}`);
491
+
492
+ logger.debug('git-tag-manager - deleteTag', 'Local tag deleted', { tagName });
493
+
494
+ // Delete remote tag if requested
495
+ if (remote) {
496
+ const remoteName = getRemoteName();
497
+ execGitTagCommand(`git push ${remoteName} --delete ${tagName}`);
498
+ logger.debug('git-tag-manager - deleteTag', 'Remote tag deleted', { tagName });
499
+ }
500
+
501
+ return {
502
+ success: true,
503
+ error: null
504
+ };
505
+
506
+ } catch (error) {
507
+ logger.error('git-tag-manager - deleteTag', 'Failed to delete tag', error);
508
+
509
+ return {
510
+ success: false,
511
+ error: error.message
512
+ };
513
+ }
514
+ }
515
+
516
+ export { GitTagError };