@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.
- package/dist/commands/ai.d.ts.map +1 -1
- package/dist/commands/ai.js +11 -0
- package/dist/commands/ai.js.map +1 -1
- package/dist/commands/commit.d.ts +2 -1
- package/dist/commands/commit.d.ts.map +1 -1
- package/dist/commands/commit.js +140 -59
- package/dist/commands/commit.js.map +1 -1
- package/dist/commands/git.d.ts +5 -0
- package/dist/commands/git.d.ts.map +1 -1
- package/dist/commands/git.js +48 -0
- package/dist/commands/git.js.map +1 -1
- package/dist/commands/tag.d.ts +3 -0
- package/dist/commands/tag.d.ts.map +1 -1
- package/dist/commands/tag.js +138 -12
- package/dist/commands/tag.js.map +1 -1
- package/dist/prompts/commit.d.ts.map +1 -1
- package/dist/prompts/commit.js +3 -1
- package/dist/prompts/commit.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/ai.test.ts +38 -0
- package/src/__tests__/git.test.ts +101 -1
- package/src/__tests__/preCommit.test.ts +146 -0
- package/src/__tests__/tagCommand.test.ts +182 -1
- package/src/commands/ai.ts +13 -0
- package/src/commands/commit.ts +168 -69
- package/src/commands/git.ts +48 -0
- package/src/commands/tag.ts +159 -12
- package/src/prompts/commit.ts +3 -1
|
@@ -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
|
});
|
package/src/commands/ai.ts
CHANGED
|
@@ -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 {
|
package/src/commands/commit.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { Command } from
|
|
2
|
-
import readline from
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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(
|
|
22
|
-
.description(
|
|
23
|
-
.option(
|
|
24
|
-
.option(
|
|
25
|
-
.option(
|
|
26
|
-
.option(
|
|
27
|
-
|
|
28
|
-
|
|
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 ?
|
|
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(
|
|
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(
|
|
166
|
+
console.error("Error:", diffResult.error);
|
|
71
167
|
await LogService.append({
|
|
72
|
-
command:
|
|
168
|
+
command: "commit",
|
|
73
169
|
args: safeArgs,
|
|
74
|
-
status:
|
|
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(
|
|
82
|
-
|
|
177
|
+
log("Generating commit message...");
|
|
178
|
+
|
|
83
179
|
const aiService = new AIService(aiConfig);
|
|
84
|
-
const aiResult = await aiService.generateCommitMessage(
|
|
180
|
+
const aiResult = await aiService.generateCommitMessage(
|
|
181
|
+
diffResult.diff!,
|
|
182
|
+
options.prompt
|
|
183
|
+
);
|
|
85
184
|
|
|
86
185
|
if (!aiResult.success) {
|
|
87
|
-
console.error(
|
|
186
|
+
console.error("Error:", aiResult.error);
|
|
88
187
|
await LogService.append({
|
|
89
|
-
command:
|
|
188
|
+
command: "commit",
|
|
90
189
|
args: safeArgs,
|
|
91
|
-
status:
|
|
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 !==
|
|
99
|
-
console.error(
|
|
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:
|
|
205
|
+
command: "commit",
|
|
107
206
|
args: { ...safeArgs, messageOnly: true },
|
|
108
|
-
status:
|
|
109
|
-
details:
|
|
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(
|
|
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(
|
|
220
|
+
console.log("Commit cancelled by user.");
|
|
122
221
|
await LogService.append({
|
|
123
|
-
command:
|
|
222
|
+
command: "commit",
|
|
124
223
|
args: safeArgs,
|
|
125
|
-
status:
|
|
126
|
-
details:
|
|
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(
|
|
231
|
+
console.log("\nCreating commit...");
|
|
133
232
|
const commitSuccess = await GitService.createCommit(aiResult.message!);
|
|
134
233
|
|
|
135
234
|
if (commitSuccess) {
|
|
136
|
-
console.log(
|
|
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(
|
|
243
|
+
console.log("Auto push enabled in config; pushing to remote...");
|
|
145
244
|
} else {
|
|
146
|
-
console.log(
|
|
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(
|
|
251
|
+
console.log("✅ Push completed successfully!");
|
|
153
252
|
} else {
|
|
154
|
-
console.error(
|
|
253
|
+
console.error("❌ Failed to push to remote");
|
|
155
254
|
await LogService.append({
|
|
156
|
-
command:
|
|
255
|
+
command: "commit",
|
|
157
256
|
args: { ...safeArgs, push: true },
|
|
158
|
-
status:
|
|
159
|
-
details:
|
|
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(
|
|
265
|
+
console.error("❌ Failed to create commit");
|
|
167
266
|
await LogService.append({
|
|
168
|
-
command:
|
|
267
|
+
command: "commit",
|
|
169
268
|
args: safeArgs,
|
|
170
|
-
status:
|
|
171
|
-
details:
|
|
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:
|
|
276
|
+
command: "commit",
|
|
178
277
|
args: safeArgs,
|
|
179
|
-
status:
|
|
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(
|
|
283
|
+
console.error("Error:", message);
|
|
185
284
|
await LogService.append({
|
|
186
|
-
command:
|
|
285
|
+
command: "commit",
|
|
187
286
|
args: safeArgs,
|
|
188
|
-
status:
|
|
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(
|
|
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 ===
|
|
308
|
+
return normalized === "y" || normalized === "yes";
|
|
210
309
|
}
|
|
211
310
|
|
|
212
311
|
getCommand(): Command {
|
package/src/commands/git.ts
CHANGED
|
@@ -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
|
}
|