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.
- package/CHANGELOG.md +89 -20
- package/README.md +95 -4
- package/bin/claude-hooks +16 -0
- package/lib/commands/analyze.js +217 -0
- package/lib/commands/bump-version.js +467 -0
- package/lib/commands/create-pr.js +104 -0
- package/lib/commands/generate-changelog.js +154 -0
- package/lib/commands/help.js +53 -0
- package/lib/hooks/pre-commit.js +26 -265
- package/lib/utils/analysis-engine.js +469 -0
- package/lib/utils/changelog-generator.js +382 -0
- package/lib/utils/git-operations.js +130 -1
- package/lib/utils/git-tag-manager.js +566 -0
- package/lib/utils/interactive-ui.js +86 -1
- package/lib/utils/resolution-prompt.js +57 -34
- package/lib/utils/version-manager.js +750 -0
- package/package.json +1 -1
- package/templates/GENERATE_CHANGELOG.md +83 -0
|
@@ -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)
|
|
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
|
+
};
|