@ksw8954/git-ai-commit 1.0.5 → 1.0.7

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.
@@ -8,7 +8,12 @@ jest.mock('../commands/git', () => ({
8
8
  getLatestTag: jest.fn(),
9
9
  getCommitSummariesSince: jest.fn(),
10
10
  createAnnotatedTag: jest.fn(),
11
- pushTag: jest.fn()
11
+ pushTag: jest.fn(),
12
+ tagExists: jest.fn(),
13
+ remoteTagExists: jest.fn(),
14
+ deleteLocalTag: jest.fn(),
15
+ deleteRemoteTag: jest.fn(),
16
+ forcePushTag: jest.fn()
12
17
  }
13
18
  }));
14
19
 
@@ -44,6 +49,10 @@ describe('TagCommand', () => {
44
49
 
45
50
  (ConfigService.validateConfig as jest.Mock).mockReturnValue(undefined);
46
51
 
52
+ // Tag doesn't exist by default
53
+ (GitService.tagExists as jest.Mock).mockResolvedValue(false);
54
+ (GitService.remoteTagExists as jest.Mock).mockResolvedValue(false);
55
+
47
56
  // Confirm creation by default
48
57
  jest
49
58
  .spyOn(TagCommand.prototype as any, 'confirmTagCreate')
@@ -194,4 +203,176 @@ describe('TagCommand', () => {
194
203
  expect(GitService.pushTag).toHaveBeenCalledWith('v3.0.0');
195
204
  expect(exitSpy).toHaveBeenCalledWith(1);
196
205
  });
206
+
207
+ it('should cancel when user declines to replace existing local tag', async () => {
208
+ (GitService.tagExists as jest.Mock).mockResolvedValueOnce(true);
209
+ jest
210
+ .spyOn(TagCommand.prototype as any, 'confirmTagDelete')
211
+ .mockResolvedValueOnce(false);
212
+
213
+ const command = getTagCommand();
214
+
215
+ await (command as any).handleTag('v1.0.0', { message: 'Release notes' });
216
+
217
+ expect(GitService.tagExists).toHaveBeenCalledWith('v1.0.0');
218
+ expect(GitService.createAnnotatedTag).not.toHaveBeenCalled();
219
+ expect(GitService.pushTag).not.toHaveBeenCalled();
220
+ });
221
+
222
+ it('should delete local tag and create new one when user confirms', async () => {
223
+ (GitService.tagExists as jest.Mock).mockResolvedValueOnce(true);
224
+ (GitService.remoteTagExists as jest.Mock).mockResolvedValueOnce(false);
225
+ (GitService.deleteLocalTag as jest.Mock).mockResolvedValueOnce(true);
226
+ (GitService.createAnnotatedTag as jest.Mock).mockResolvedValueOnce(true);
227
+
228
+ jest
229
+ .spyOn(TagCommand.prototype as any, 'confirmTagDelete')
230
+ .mockResolvedValueOnce(true);
231
+
232
+ const command = getTagCommand();
233
+
234
+ await (command as any).handleTag('v1.0.0', { message: 'Release notes' });
235
+
236
+ expect(GitService.tagExists).toHaveBeenCalledWith('v1.0.0');
237
+ expect(GitService.deleteLocalTag).toHaveBeenCalledWith('v1.0.0');
238
+ expect(GitService.createAnnotatedTag).toHaveBeenCalledWith('v1.0.0', 'Release notes');
239
+ });
240
+
241
+ it('should delete both local and remote tag when user confirms', async () => {
242
+ (GitService.tagExists as jest.Mock).mockResolvedValueOnce(true);
243
+ (GitService.remoteTagExists as jest.Mock).mockResolvedValueOnce(true);
244
+ (GitService.deleteLocalTag as jest.Mock).mockResolvedValueOnce(true);
245
+ (GitService.deleteRemoteTag as jest.Mock).mockResolvedValueOnce(true);
246
+ (GitService.createAnnotatedTag as jest.Mock).mockResolvedValueOnce(true);
247
+
248
+ jest
249
+ .spyOn(TagCommand.prototype as any, 'confirmTagDelete')
250
+ .mockResolvedValueOnce(true);
251
+ jest
252
+ .spyOn(TagCommand.prototype as any, 'confirmRemoteTagDelete')
253
+ .mockResolvedValueOnce(true);
254
+
255
+ const command = getTagCommand();
256
+
257
+ await (command as any).handleTag('v1.0.0', { message: 'Release notes' });
258
+
259
+ expect(GitService.remoteTagExists).toHaveBeenCalledWith('v1.0.0');
260
+ expect(GitService.deleteRemoteTag).toHaveBeenCalledWith('v1.0.0');
261
+ expect(GitService.deleteLocalTag).toHaveBeenCalledWith('v1.0.0');
262
+ expect(GitService.createAnnotatedTag).toHaveBeenCalledWith('v1.0.0', 'Release notes');
263
+ });
264
+
265
+ it('should exit when local tag deletion fails', async () => {
266
+ (GitService.tagExists as jest.Mock).mockResolvedValueOnce(true);
267
+ (GitService.remoteTagExists as jest.Mock).mockResolvedValueOnce(false);
268
+ (GitService.deleteLocalTag as jest.Mock).mockResolvedValueOnce(false);
269
+
270
+ jest
271
+ .spyOn(TagCommand.prototype as any, 'confirmTagDelete')
272
+ .mockResolvedValueOnce(true);
273
+
274
+ const command = getTagCommand();
275
+
276
+ await (command as any).handleTag('v1.0.0', { message: 'Release notes' });
277
+
278
+ expect(GitService.deleteLocalTag).toHaveBeenCalledWith('v1.0.0');
279
+ expect(GitService.createAnnotatedTag).not.toHaveBeenCalled();
280
+ expect(exitSpy).toHaveBeenCalledWith(1);
281
+ });
282
+
283
+ it('should exit when remote tag deletion fails', async () => {
284
+ (GitService.tagExists as jest.Mock).mockResolvedValueOnce(true);
285
+ (GitService.remoteTagExists as jest.Mock).mockResolvedValueOnce(true);
286
+ (GitService.deleteRemoteTag as jest.Mock).mockResolvedValueOnce(false);
287
+
288
+ jest
289
+ .spyOn(TagCommand.prototype as any, 'confirmTagDelete')
290
+ .mockResolvedValueOnce(true);
291
+ jest
292
+ .spyOn(TagCommand.prototype as any, 'confirmRemoteTagDelete')
293
+ .mockResolvedValueOnce(true);
294
+
295
+ const command = getTagCommand();
296
+
297
+ await (command as any).handleTag('v1.0.0', { message: 'Release notes' });
298
+
299
+ expect(GitService.deleteRemoteTag).toHaveBeenCalledWith('v1.0.0');
300
+ expect(GitService.deleteLocalTag).not.toHaveBeenCalled();
301
+ expect(GitService.createAnnotatedTag).not.toHaveBeenCalled();
302
+ expect(exitSpy).toHaveBeenCalledWith(1);
303
+ });
304
+
305
+ it('should force push when tag was replaced and user confirms', async () => {
306
+ (GitService.tagExists as jest.Mock).mockResolvedValueOnce(true);
307
+ (GitService.remoteTagExists as jest.Mock).mockResolvedValueOnce(false);
308
+ (GitService.deleteLocalTag as jest.Mock).mockResolvedValueOnce(true);
309
+ (GitService.createAnnotatedTag as jest.Mock).mockResolvedValueOnce(true);
310
+ (GitService.forcePushTag as jest.Mock).mockResolvedValueOnce(true);
311
+
312
+ jest
313
+ .spyOn(TagCommand.prototype as any, 'confirmTagDelete')
314
+ .mockResolvedValueOnce(true);
315
+ confirmSpy.mockResolvedValueOnce(true);
316
+ jest
317
+ .spyOn(TagCommand.prototype as any, 'confirmForcePush')
318
+ .mockResolvedValueOnce(true);
319
+
320
+ const command = getTagCommand();
321
+
322
+ await (command as any).handleTag('v1.0.0', { message: 'Release notes' });
323
+
324
+ expect(GitService.deleteLocalTag).toHaveBeenCalledWith('v1.0.0');
325
+ expect(GitService.createAnnotatedTag).toHaveBeenCalledWith('v1.0.0', 'Release notes');
326
+ expect(GitService.forcePushTag).toHaveBeenCalledWith('v1.0.0');
327
+ });
328
+
329
+ it('should cancel push when user declines force push', async () => {
330
+ (GitService.tagExists as jest.Mock).mockResolvedValueOnce(true);
331
+ (GitService.remoteTagExists as jest.Mock).mockResolvedValueOnce(false);
332
+ (GitService.deleteLocalTag as jest.Mock).mockResolvedValueOnce(true);
333
+ (GitService.createAnnotatedTag as jest.Mock).mockResolvedValueOnce(true);
334
+
335
+ jest
336
+ .spyOn(TagCommand.prototype as any, 'confirmTagDelete')
337
+ .mockResolvedValueOnce(true);
338
+ confirmSpy.mockResolvedValueOnce(true);
339
+ jest
340
+ .spyOn(TagCommand.prototype as any, 'confirmForcePush')
341
+ .mockResolvedValueOnce(false);
342
+
343
+ const command = getTagCommand();
344
+
345
+ await (command as any).handleTag('v1.0.0', { message: 'Release notes' });
346
+
347
+ expect(GitService.createAnnotatedTag).toHaveBeenCalledWith('v1.0.0', 'Release notes');
348
+ expect(GitService.forcePushTag).not.toHaveBeenCalled();
349
+ expect(GitService.pushTag).not.toHaveBeenCalled();
350
+ });
351
+
352
+ it('should force push when remote tag exists and user confirms', async () => {
353
+ (GitService.tagExists as jest.Mock).mockResolvedValueOnce(true);
354
+ (GitService.remoteTagExists as jest.Mock).mockResolvedValueOnce(true);
355
+ (GitService.deleteRemoteTag as jest.Mock).mockResolvedValueOnce(false); // User declined remote deletion
356
+ (GitService.deleteLocalTag as jest.Mock).mockResolvedValueOnce(true);
357
+ (GitService.createAnnotatedTag as jest.Mock).mockResolvedValueOnce(true);
358
+ (GitService.forcePushTag as jest.Mock).mockResolvedValueOnce(true);
359
+
360
+ jest
361
+ .spyOn(TagCommand.prototype as any, 'confirmTagDelete')
362
+ .mockResolvedValueOnce(true);
363
+ jest
364
+ .spyOn(TagCommand.prototype as any, 'confirmRemoteTagDelete')
365
+ .mockResolvedValueOnce(false); // Decline remote deletion
366
+ confirmSpy.mockResolvedValueOnce(true);
367
+ jest
368
+ .spyOn(TagCommand.prototype as any, 'confirmForcePush')
369
+ .mockResolvedValueOnce(true);
370
+
371
+ const command = getTagCommand();
372
+
373
+ await (command as any).handleTag('v1.0.0', { message: 'Release notes' });
374
+
375
+ expect(GitService.forcePushTag).toHaveBeenCalledWith('v1.0.0');
376
+ expect(GitService.pushTag).not.toHaveBeenCalled();
377
+ });
197
378
  });
@@ -72,6 +72,11 @@ export class AIService {
72
72
  // Remove any remaining XML/HTML-like tags
73
73
  cleaned = cleaned.replace(/<[^>]+>/g, '');
74
74
 
75
+ // Strip common Markdown emphasis so headers remain plain text
76
+ cleaned = cleaned.replace(/\*\*(.*?)\*\*/g, '$1');
77
+ cleaned = cleaned.replace(/__(.*?)__/g, '$1');
78
+ cleaned = cleaned.replace(/`([^`]+)`/g, '$1');
79
+
75
80
  // Normalize whitespace
76
81
  cleaned = cleaned.replace(/[\t\r]+/g, '');
77
82
  cleaned = cleaned.replace(/\n{3,}/g, '\n\n').trim();
@@ -217,6 +222,14 @@ export class AIService {
217
222
  }
218
223
  }
219
224
 
225
+ // Drop additional commit headers to enforce a single conventional commit
226
+ if (lines.length > 0) {
227
+ const nextHeaderIdx = lines.slice(1).findIndex(l => headerPattern.test(l));
228
+ if (nextHeaderIdx >= 0) {
229
+ lines = lines.slice(0, nextHeaderIdx + 1);
230
+ }
231
+ }
232
+
220
233
  finalMessage = lines.join('\n').trim();
221
234
 
222
235
  return {
@@ -1,9 +1,12 @@
1
- import { Command } from 'commander';
2
- import readline from 'readline';
3
- import { GitService, GitDiffResult } from './git';
4
- import { AIService, AIServiceConfig } from './ai';
5
- import { ConfigService } from './config';
6
- import { LogService } from './log';
1
+ import { Command } from "commander";
2
+ import readline from "readline";
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import { spawn } from "child_process";
6
+ import { GitService, GitDiffResult } from "./git";
7
+ import { AIService, AIServiceConfig } from "./ai";
8
+ import { ConfigService } from "./config";
9
+ import { LogService } from "./log";
7
10
 
8
11
  export interface CommitOptions {
9
12
  apiKey?: string;
@@ -18,24 +21,117 @@ export class CommitCommand {
18
21
  private program: Command;
19
22
 
20
23
  constructor() {
21
- this.program = new Command('commit')
22
- .description('Generate AI-powered commit message')
23
- .option('-k, --api-key <key>', 'OpenAI API key (overrides env var)')
24
- .option('-b, --base-url <url>', 'Custom API base URL (overrides env var)')
25
- .option('--model <model>', 'Model to use (overrides env var)')
26
- .option('-m, --message-only', 'Output only the generated commit message and skip git actions')
27
- .option('-p, --push', 'Push current branch after creating the commit (implies --commit)')
28
- .option('--prompt <text>', 'Additional instructions to append to the AI prompt for this commit')
24
+ this.program = new Command("commit")
25
+ .description("Generate AI-powered commit message")
26
+ .option("-k, --api-key <key>", "OpenAI API key (overrides env var)")
27
+ .option("-b, --base-url <url>", "Custom API base URL (overrides env var)")
28
+ .option("--model <model>", "Model to use (overrides env var)")
29
+ .option(
30
+ "-m, --message-only",
31
+ "Output only the generated commit message and skip git actions"
32
+ )
33
+ .option(
34
+ "-p, --push",
35
+ "Push current branch after creating the commit (implies --commit)"
36
+ )
37
+ .option(
38
+ "--prompt <text>",
39
+ "Additional instructions to append to the AI prompt for this commit"
40
+ )
29
41
  .action(this.handleCommit.bind(this));
30
42
  }
31
43
 
44
+ private async runPreCommitHook(): Promise<void> {
45
+ // 1. Check for npm pre-commit script
46
+ const packageJsonPath = path.resolve(process.cwd(), "package.json");
47
+ if (fs.existsSync(packageJsonPath)) {
48
+ try {
49
+ const raw = fs.readFileSync(packageJsonPath, "utf-8");
50
+ const pkg = JSON.parse(raw);
51
+
52
+ if (pkg.scripts && pkg.scripts["pre-commit"]) {
53
+ console.log("Running npm pre-commit script...");
54
+
55
+ await new Promise<void>((resolve, reject) => {
56
+ const child = spawn("npm", ["run", "pre-commit"], {
57
+ stdio: "inherit",
58
+ shell: true,
59
+ });
60
+
61
+ child.on("close", (code) => {
62
+ if (code === 0) {
63
+ console.log("✅ npm pre-commit script passed");
64
+ resolve();
65
+ } else {
66
+ console.error(`❌ npm pre-commit script failed with code ${code}`);
67
+ reject(new Error(`npm pre-commit script failed with code ${code}`));
68
+ }
69
+ });
70
+
71
+ child.on("error", (err) => {
72
+ reject(err);
73
+ });
74
+ });
75
+ }
76
+ } catch (error) {
77
+ if (!(error instanceof SyntaxError)) {
78
+ throw error;
79
+ }
80
+ }
81
+ }
82
+
83
+ // 2. Check for .pre-commit-config.yaml (Python/General pre-commit)
84
+ const preCommitConfigPath = path.resolve(process.cwd(), ".pre-commit-config.yaml");
85
+ if (fs.existsSync(preCommitConfigPath)) {
86
+ console.log("Found .pre-commit-config.yaml, running pre-commit hooks...");
87
+
88
+ try {
89
+ await new Promise<void>((resolve, reject) => {
90
+ const child = spawn("pre-commit", ["run"], {
91
+ stdio: "inherit",
92
+ shell: true,
93
+ });
94
+
95
+ child.on("close", (code) => {
96
+ if (code === 0) {
97
+ console.log("✅ pre-commit hooks passed");
98
+ resolve();
99
+ } else {
100
+ console.error(`❌ pre-commit hooks failed with code ${code}`);
101
+ reject(new Error(`pre-commit hooks failed with code ${code}`));
102
+ }
103
+ });
104
+
105
+ child.on("error", (err) => {
106
+ // If pre-commit is not installed/found, we might want to warn instead of fail?
107
+ // But usually 'error' event on spawn (with shell:true) is rare for command not found (it usually exits with 127).
108
+ // However, if it fails to spawn, we reject.
109
+ reject(err);
110
+ });
111
+ });
112
+ } catch (error) {
113
+ // If the error suggests command not found, we might warn.
114
+ // But since we use shell:true, 'command not found' usually results in exit code 127, which goes to 'close' event.
115
+ // So we catch the error from the promise rejection above.
116
+ const msg = error instanceof Error ? error.message : String(error);
117
+ if (msg.includes("code 127") || msg.includes("ENOENT")) {
118
+ console.warn("⚠️ 'pre-commit' command not found, skipping hooks despite configuration file presence.");
119
+ return;
120
+ }
121
+ throw error;
122
+ }
123
+ }
124
+ }
125
+
32
126
  private async handleCommit(options: CommitOptions) {
33
127
  const start = Date.now();
34
128
  const safeArgs = {
35
129
  ...options,
36
- apiKey: options.apiKey ? '***' : undefined
130
+ apiKey: options.apiKey ? "***" : undefined,
37
131
  } as Record<string, unknown>;
38
132
  try {
133
+ await this.runPreCommitHook();
134
+
39
135
  const existingConfig = ConfigService.getConfig();
40
136
 
41
137
  const mergedApiKey = options.apiKey || existingConfig.apiKey;
@@ -51,7 +147,7 @@ export class CommitCommand {
51
147
 
52
148
  ConfigService.validateConfig({
53
149
  apiKey: mergedApiKey,
54
- language: existingConfig.language
150
+ language: existingConfig.language,
55
151
  });
56
152
 
57
153
  const aiConfig: AIServiceConfig = {
@@ -59,81 +155,84 @@ export class CommitCommand {
59
155
  baseURL: mergedBaseURL,
60
156
  model: mergedModel,
61
157
  language: existingConfig.language,
62
- verbose: !messageOnly
158
+ verbose: !messageOnly,
63
159
  };
64
160
 
65
- log('Getting staged changes...');
66
-
161
+ log("Getting staged changes...");
162
+
67
163
  const diffResult: GitDiffResult = await GitService.getStagedDiff();
68
-
164
+
69
165
  if (!diffResult.success) {
70
- console.error('Error:', diffResult.error);
166
+ console.error("Error:", diffResult.error);
71
167
  await LogService.append({
72
- command: 'commit',
168
+ command: "commit",
73
169
  args: safeArgs,
74
- status: 'failure',
170
+ status: "failure",
75
171
  details: diffResult.error,
76
- durationMs: Date.now() - start
172
+ durationMs: Date.now() - start,
77
173
  });
78
174
  process.exit(1);
79
175
  }
80
176
 
81
- log('Generating commit message...');
82
-
177
+ log("Generating commit message...");
178
+
83
179
  const aiService = new AIService(aiConfig);
84
- const aiResult = await aiService.generateCommitMessage(diffResult.diff!, options.prompt);
180
+ const aiResult = await aiService.generateCommitMessage(
181
+ diffResult.diff!,
182
+ options.prompt
183
+ );
85
184
 
86
185
  if (!aiResult.success) {
87
- console.error('Error:', aiResult.error);
186
+ console.error("Error:", aiResult.error);
88
187
  await LogService.append({
89
- command: 'commit',
188
+ command: "commit",
90
189
  args: safeArgs,
91
- status: 'failure',
190
+ status: "failure",
92
191
  details: aiResult.error,
93
- durationMs: Date.now() - start
192
+ durationMs: Date.now() - start,
94
193
  });
95
194
  process.exit(1);
96
195
  }
97
196
 
98
- if (typeof aiResult.message !== 'string') {
99
- console.error('Error: Failed to generate commit message');
197
+ if (typeof aiResult.message !== "string") {
198
+ console.error("Error: Failed to generate commit message");
100
199
  process.exit(1);
101
200
  }
102
201
 
103
202
  if (messageOnly) {
104
203
  console.log(aiResult.message);
105
204
  await LogService.append({
106
- command: 'commit',
205
+ command: "commit",
107
206
  args: { ...safeArgs, messageOnly: true },
108
- status: 'success',
109
- details: 'message-only output',
110
- durationMs: Date.now() - start
207
+ status: "success",
208
+ details: "message-only output",
209
+ durationMs: Date.now() - start,
111
210
  });
112
211
  return;
113
212
  }
114
213
 
115
- console.log('\nGenerated commit message:');
214
+ console.log("\nGenerated commit message:");
116
215
  console.log(aiResult.message);
117
216
 
118
217
  const confirmed = await this.confirmCommit();
119
218
 
120
219
  if (!confirmed) {
121
- console.log('Commit cancelled by user.');
220
+ console.log("Commit cancelled by user.");
122
221
  await LogService.append({
123
- command: 'commit',
222
+ command: "commit",
124
223
  args: safeArgs,
125
- status: 'cancelled',
126
- details: 'user declined commit',
127
- durationMs: Date.now() - start
224
+ status: "cancelled",
225
+ details: "user declined commit",
226
+ durationMs: Date.now() - start,
128
227
  });
129
228
  return;
130
229
  }
131
230
 
132
- console.log('\nCreating commit...');
231
+ console.log("\nCreating commit...");
133
232
  const commitSuccess = await GitService.createCommit(aiResult.message!);
134
233
 
135
234
  if (commitSuccess) {
136
- console.log('✅ Commit created successfully!');
235
+ console.log("✅ Commit created successfully!");
137
236
 
138
237
  const pushRequested = Boolean(options.push);
139
238
  const pushFromConfig = !pushRequested && existingConfig.autoPush;
@@ -141,53 +240,53 @@ export class CommitCommand {
141
240
 
142
241
  if (shouldPush) {
143
242
  if (pushFromConfig) {
144
- console.log('Auto push enabled in config; pushing to remote...');
243
+ console.log("Auto push enabled in config; pushing to remote...");
145
244
  } else {
146
- console.log('Pushing to remote...');
245
+ console.log("Pushing to remote...");
147
246
  }
148
247
 
149
248
  const pushSuccess = await GitService.push();
150
249
 
151
250
  if (pushSuccess) {
152
- console.log('✅ Push completed successfully!');
251
+ console.log("✅ Push completed successfully!");
153
252
  } else {
154
- console.error('❌ Failed to push to remote');
253
+ console.error("❌ Failed to push to remote");
155
254
  await LogService.append({
156
- command: 'commit',
255
+ command: "commit",
157
256
  args: { ...safeArgs, push: true },
158
- status: 'failure',
159
- details: 'push failed',
160
- durationMs: Date.now() - start
257
+ status: "failure",
258
+ details: "push failed",
259
+ durationMs: Date.now() - start,
161
260
  });
162
261
  process.exit(1);
163
262
  }
164
263
  }
165
264
  } else {
166
- console.error('❌ Failed to create commit');
265
+ console.error("❌ Failed to create commit");
167
266
  await LogService.append({
168
- command: 'commit',
267
+ command: "commit",
169
268
  args: safeArgs,
170
- status: 'failure',
171
- details: 'git commit failed',
172
- durationMs: Date.now() - start
269
+ status: "failure",
270
+ details: "git commit failed",
271
+ durationMs: Date.now() - start,
173
272
  });
174
273
  process.exit(1);
175
274
  }
176
275
  await LogService.append({
177
- command: 'commit',
276
+ command: "commit",
178
277
  args: safeArgs,
179
- status: 'success',
180
- durationMs: Date.now() - start
278
+ status: "success",
279
+ durationMs: Date.now() - start,
181
280
  });
182
281
  } catch (error) {
183
282
  const message = error instanceof Error ? error.message : String(error);
184
- console.error('Error:', message);
283
+ console.error("Error:", message);
185
284
  await LogService.append({
186
- command: 'commit',
285
+ command: "commit",
187
286
  args: safeArgs,
188
- status: 'failure',
287
+ status: "failure",
189
288
  details: message,
190
- durationMs: Date.now() - start
289
+ durationMs: Date.now() - start,
191
290
  });
192
291
  process.exit(1);
193
292
  }
@@ -196,17 +295,17 @@ export class CommitCommand {
196
295
  private async confirmCommit(): Promise<boolean> {
197
296
  const rl = readline.createInterface({
198
297
  input: process.stdin,
199
- output: process.stdout
298
+ output: process.stdout,
200
299
  });
201
300
 
202
- const answer: string = await new Promise(resolve => {
203
- rl.question('Proceed with git commit? (y/n): ', resolve);
301
+ const answer: string = await new Promise((resolve) => {
302
+ rl.question("Proceed with git commit? (y/n): ", resolve);
204
303
  });
205
304
 
206
305
  rl.close();
207
306
 
208
307
  const normalized = answer.trim().toLowerCase();
209
- return normalized === 'y' || normalized === 'yes';
308
+ return normalized === "y" || normalized === "yes";
210
309
  }
211
310
 
212
311
  getCommand(): Command {
@@ -171,4 +171,52 @@ export class GitService {
171
171
  return false;
172
172
  }
173
173
  }
174
+
175
+ static async tagExists(tagName: string): Promise<boolean> {
176
+ try {
177
+ await execFileAsync('git', ['rev-parse', `refs/tags/${tagName}`]);
178
+ return true;
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
183
+
184
+ static async remoteTagExists(tagName: string): Promise<boolean> {
185
+ try {
186
+ const { stdout } = await execAsync(`git ls-remote --tags origin refs/tags/${tagName}`);
187
+ return stdout.trim().length > 0;
188
+ } catch {
189
+ return false;
190
+ }
191
+ }
192
+
193
+ static async deleteLocalTag(tagName: string): Promise<boolean> {
194
+ try {
195
+ await execFileAsync('git', ['tag', '-d', tagName]);
196
+ return true;
197
+ } catch (error) {
198
+ console.error('Failed to delete local tag:', error instanceof Error ? error.message : error);
199
+ return false;
200
+ }
201
+ }
202
+
203
+ static async deleteRemoteTag(tagName: string): Promise<boolean> {
204
+ try {
205
+ await execFileAsync('git', ['push', 'origin', '--delete', tagName]);
206
+ return true;
207
+ } catch (error) {
208
+ console.error('Failed to delete remote tag:', error instanceof Error ? error.message : error);
209
+ return false;
210
+ }
211
+ }
212
+
213
+ static async forcePushTag(tagName: string): Promise<boolean> {
214
+ try {
215
+ await execFileAsync('git', ['push', 'origin', tagName, '--force']);
216
+ return true;
217
+ } catch (error) {
218
+ console.error('Failed to force push tag to remote:', error instanceof Error ? error.message : error);
219
+ return false;
220
+ }
221
+ }
174
222
  }