@ksw8954/git-ai-commit 1.1.7 → 1.2.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.
Files changed (53) hide show
  1. package/.github/workflows/publish.yml +36 -0
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +25 -4
  4. package/dist/commands/ai.d.ts +7 -1
  5. package/dist/commands/ai.d.ts.map +1 -1
  6. package/dist/commands/ai.js +144 -22
  7. package/dist/commands/ai.js.map +1 -1
  8. package/dist/commands/commit.d.ts +1 -0
  9. package/dist/commands/commit.d.ts.map +1 -1
  10. package/dist/commands/commit.js +37 -34
  11. package/dist/commands/commit.js.map +1 -1
  12. package/dist/commands/completion.d.ts.map +1 -1
  13. package/dist/commands/completion.js +15 -5
  14. package/dist/commands/completion.js.map +1 -1
  15. package/dist/commands/config.d.ts +7 -2
  16. package/dist/commands/config.d.ts.map +1 -1
  17. package/dist/commands/config.js +37 -15
  18. package/dist/commands/config.js.map +1 -1
  19. package/dist/commands/configCommand.d.ts +5 -1
  20. package/dist/commands/configCommand.d.ts.map +1 -1
  21. package/dist/commands/configCommand.js +30 -3
  22. package/dist/commands/configCommand.js.map +1 -1
  23. package/dist/commands/git.js +3 -3
  24. package/dist/commands/hookCommand.d.ts +14 -0
  25. package/dist/commands/hookCommand.d.ts.map +1 -0
  26. package/dist/commands/hookCommand.js +180 -0
  27. package/dist/commands/hookCommand.js.map +1 -0
  28. package/dist/commands/prCommand.d.ts.map +1 -1
  29. package/dist/commands/prCommand.js +3 -1
  30. package/dist/commands/prCommand.js.map +1 -1
  31. package/dist/commands/tag.d.ts.map +1 -1
  32. package/dist/commands/tag.js +12 -2
  33. package/dist/commands/tag.js.map +1 -1
  34. package/dist/index.js +3 -0
  35. package/dist/index.js.map +1 -1
  36. package/package.json +2 -1
  37. package/src/__tests__/ai.test.ts +486 -7
  38. package/src/__tests__/commitCommand.test.ts +111 -0
  39. package/src/__tests__/config.test.ts +24 -6
  40. package/src/__tests__/git.test.ts +421 -98
  41. package/src/__tests__/preCommit.test.ts +19 -0
  42. package/src/__tests__/tagCommand.test.ts +510 -17
  43. package/src/commands/ai.ts +175 -24
  44. package/src/commands/commit.ts +40 -34
  45. package/src/commands/completion.ts +15 -5
  46. package/src/commands/config.ts +46 -23
  47. package/src/commands/configCommand.ts +41 -8
  48. package/src/commands/git.ts +3 -3
  49. package/src/commands/hookCommand.ts +193 -0
  50. package/src/commands/prCommand.ts +3 -1
  51. package/src/commands/tag.ts +13 -2
  52. package/src/index.ts +3 -0
  53. package/src/schema/config.schema.json +72 -0
@@ -1,9 +1,15 @@
1
1
  import { AIService } from '../commands/ai';
2
2
  import { generateCommitPrompt } from '../prompts/commit';
3
3
  import { generatePullRequestPrompt } from '../prompts/pr';
4
+ import { generateTagPrompt } from '../prompts/tag';
4
5
  import OpenAI from 'openai';
5
6
 
6
7
  jest.mock('openai');
8
+ jest.mock('@google/genai', () => ({
9
+ GoogleGenAI: jest.fn().mockImplementation(() => ({
10
+ models: { generateContentStream: jest.fn() }
11
+ }))
12
+ }));
7
13
  const MockedOpenAI = OpenAI as jest.MockedClass<typeof OpenAI>;
8
14
 
9
15
  function createMockStream(content: string | null) {
@@ -22,6 +28,16 @@ function createMockStream(content: string | null) {
22
28
  };
23
29
  }
24
30
 
31
+ function createGeminiMockStream(chunks: Array<{ text?: string; usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number } }>) {
32
+ return {
33
+ [Symbol.asyncIterator]: async function* () {
34
+ for (const chunk of chunks) {
35
+ yield chunk;
36
+ }
37
+ }
38
+ };
39
+ }
40
+
25
41
  describe('AIService', () => {
26
42
  let aiService: AIService;
27
43
  let mockOpenai: jest.Mocked<OpenAI>;
@@ -76,8 +92,9 @@ describe('AIService', () => {
76
92
  content: `Git diff:\n${diff}`
77
93
  }
78
94
  ],
79
- max_completion_tokens: 3000,
80
- stream: true
95
+ max_completion_tokens: 1000,
96
+ stream: true,
97
+ stream_options: { include_usage: true }
81
98
  });
82
99
  });
83
100
 
@@ -153,8 +170,9 @@ describe('AIService', () => {
153
170
  content: `Git diff:\n${diff}`
154
171
  }
155
172
  ],
156
- max_completion_tokens: 3000,
157
- stream: true
173
+ max_completion_tokens: 1000,
174
+ stream: true,
175
+ stream_options: { include_usage: true }
158
176
  });
159
177
 
160
178
  expect(mockOpenai.chat.completions.create).toHaveBeenNthCalledWith(2, {
@@ -173,8 +191,9 @@ describe('AIService', () => {
173
191
  content: `Git diff:\n${diff}`
174
192
  }
175
193
  ],
176
- max_tokens: 3000,
177
- stream: true
194
+ max_tokens: 1000,
195
+ stream: true,
196
+ stream_options: { include_usage: true }
178
197
  });
179
198
  });
180
199
 
@@ -242,7 +261,8 @@ describe('AIService', () => {
242
261
  }
243
262
  ],
244
263
  max_completion_tokens: 4000,
245
- stream: true
264
+ stream: true,
265
+ stream_options: { include_usage: true }
246
266
  });
247
267
  });
248
268
 
@@ -271,4 +291,463 @@ describe('AIService', () => {
271
291
  });
272
292
  });
273
293
  });
294
+
295
+ describe('private helper methods', () => {
296
+ it('isUnsupportedTokenParamError should return false for null/non-object', () => {
297
+ expect((aiService as any).isUnsupportedTokenParamError(null, 'max_tokens')).toBe(false);
298
+ expect((aiService as any).isUnsupportedTokenParamError('oops', 'max_tokens')).toBe(false);
299
+ });
300
+
301
+ it('isUnsupportedTokenParamError should match by code and param', () => {
302
+ const error = { code: 'unsupported_parameter', param: 'max_completion_tokens' };
303
+ expect((aiService as any).isUnsupportedTokenParamError(error, 'max_completion_tokens')).toBe(true);
304
+ });
305
+
306
+ it('isUnsupportedTokenParamError should match by message string', () => {
307
+ const error = { message: 'Unsupported parameter: max_tokens is not supported' };
308
+ expect((aiService as any).isUnsupportedTokenParamError(error, 'max_tokens')).toBe(true);
309
+ });
310
+
311
+ it('isUnsupportedValueError should return false for null', () => {
312
+ expect((aiService as any).isUnsupportedValueError(null, 'temperature')).toBe(false);
313
+ });
314
+
315
+ it('isUnsupportedValueError should match by code and param', () => {
316
+ const error = { code: 'unsupported_value', param: 'temperature' };
317
+ expect((aiService as any).isUnsupportedValueError(error, 'temperature')).toBe(true);
318
+ });
319
+
320
+ it('isUnsupportedValueError should match by message string', () => {
321
+ const error = { message: 'Unsupported value for temperature' };
322
+ expect((aiService as any).isUnsupportedValueError(error, 'temperature')).toBe(true);
323
+ });
324
+
325
+ it('swapTokenParam should return rest when no token value exists', () => {
326
+ const request = {
327
+ model: 'gpt-4',
328
+ messages: [{ role: 'user' as const, content: 'hello' }],
329
+ temperature: 0.3
330
+ };
331
+ expect((aiService as any).swapTokenParam(request, 'max_tokens')).toEqual(request);
332
+ });
333
+
334
+ it('swapTokenParam should swap to max_completion_tokens', () => {
335
+ const request = {
336
+ model: 'gpt-4',
337
+ messages: [{ role: 'user' as const, content: 'hello' }],
338
+ max_tokens: 123
339
+ };
340
+ expect((aiService as any).swapTokenParam(request, 'max_completion_tokens')).toEqual({
341
+ model: 'gpt-4',
342
+ messages: [{ role: 'user', content: 'hello' }],
343
+ max_completion_tokens: 123
344
+ });
345
+ });
346
+
347
+ it('removeTemperature should remove temperature from request', () => {
348
+ const request = {
349
+ model: 'gpt-4',
350
+ messages: [{ role: 'user' as const, content: 'hello' }],
351
+ temperature: 0.9,
352
+ max_completion_tokens: 222
353
+ };
354
+ expect((aiService as any).removeTemperature(request)).toEqual({
355
+ model: 'gpt-4',
356
+ messages: [{ role: 'user', content: 'hello' }],
357
+ max_completion_tokens: 222
358
+ });
359
+ });
360
+ });
361
+
362
+ describe('commit message cleanup edge cases', () => {
363
+ it('should prefix chore for version/update messages', async () => {
364
+ const quietService = new AIService({ apiKey: 'test-api-key', model: 'gpt-4', verbose: false });
365
+ (mockOpenai.chat.completions.create as jest.Mock).mockResolvedValue(createMockStream('version update release process'));
366
+
367
+ const result = await quietService.generateCommitMessage('diff');
368
+ expect(result).toEqual({ success: true, message: 'chore: version update release process' });
369
+ });
370
+
371
+ it('should prefix feat for feature/add messages', async () => {
372
+ const quietService = new AIService({ apiKey: 'test-api-key', model: 'gpt-4', verbose: false });
373
+ (mockOpenai.chat.completions.create as jest.Mock).mockResolvedValue(createMockStream('add payment webhook support'));
374
+
375
+ const result = await quietService.generateCommitMessage('diff');
376
+ expect(result).toEqual({ success: true, message: 'feat: add payment webhook support' });
377
+ });
378
+
379
+ it('should prefix fix for fix/bug messages', async () => {
380
+ const quietService = new AIService({ apiKey: 'test-api-key', model: 'gpt-4', verbose: false });
381
+ (mockOpenai.chat.completions.create as jest.Mock).mockResolvedValue(createMockStream('bug in retry handler'));
382
+
383
+ const result = await quietService.generateCommitMessage('diff');
384
+ expect(result).toEqual({ success: true, message: 'fix: bug in retry handler' });
385
+ });
386
+
387
+ it('should normalize output when model returns type-only line before header', async () => {
388
+ const quietService = new AIService({ apiKey: 'test-api-key', model: 'gpt-4', verbose: false });
389
+ (mockOpenai.chat.completions.create as jest.Mock).mockResolvedValue(
390
+ createMockStream('chore:\n\nfeat(scope): real header\n\nbody text')
391
+ );
392
+
393
+ const result = await quietService.generateCommitMessage('diff');
394
+ expect(result).toEqual({ success: true, message: 'chore: chore:' });
395
+ });
396
+
397
+ it('should find the first header when non-header text comes first', async () => {
398
+ const quietService = new AIService({ apiKey: 'test-api-key', model: 'gpt-4', verbose: false });
399
+ (mockOpenai.chat.completions.create as jest.Mock).mockResolvedValue(
400
+ createMockStream('This is not a header\nfix: handle edge case')
401
+ );
402
+
403
+ const result = await quietService.generateCommitMessage('diff');
404
+ expect(result).toEqual({ success: true, message: 'fix: This is not a header' });
405
+ });
406
+
407
+ it('should collapse multiple blank lines between header and body', async () => {
408
+ const quietService = new AIService({ apiKey: 'test-api-key', model: 'gpt-4', verbose: false });
409
+ (mockOpenai.chat.completions.create as jest.Mock).mockResolvedValue(
410
+ createMockStream('docs: update usage\n\n\n\nline 1\nline 2')
411
+ );
412
+
413
+ const result = await quietService.generateCommitMessage('diff');
414
+ expect(result).toEqual({ success: true, message: 'docs: update usage\n\nline 1\nline 2' });
415
+ });
416
+ });
417
+
418
+ describe('retry and reasoning paths', () => {
419
+ it('should retry without temperature when unsupported value error occurs', async () => {
420
+ const quietService = new AIService({
421
+ apiKey: 'test-api-key',
422
+ model: 'gpt-4',
423
+ verbose: false
424
+ });
425
+
426
+ const error = {
427
+ code: 'unsupported_value',
428
+ param: 'temperature',
429
+ message: 'Unsupported value: temperature'
430
+ };
431
+
432
+ (mockOpenai.chat.completions.create as jest.Mock)
433
+ .mockRejectedValueOnce(error)
434
+ .mockResolvedValueOnce(createMockStream('feat: fallback without temperature'));
435
+
436
+ const request = {
437
+ model: 'gpt-4',
438
+ messages: [{ role: 'user' as const, content: 'test' }],
439
+ max_completion_tokens: 100,
440
+ temperature: 0.7
441
+ };
442
+
443
+ const result = await (quietService as any).createStreamingCompletion(request);
444
+ expect(result).toBe('feat: fallback without temperature');
445
+
446
+ expect(mockOpenai.chat.completions.create).toHaveBeenNthCalledWith(2, {
447
+ model: 'gpt-4',
448
+ messages: [{ role: 'user', content: 'test' }],
449
+ max_completion_tokens: 100,
450
+ stream: true,
451
+ stream_options: { include_usage: true }
452
+ });
453
+ });
454
+
455
+ it('should retry with max_completion_tokens when max_tokens is unsupported', async () => {
456
+ const quietService = new AIService({ apiKey: 'test-api-key', model: 'gpt-4', verbose: false });
457
+
458
+ const error = {
459
+ code: 'unsupported_parameter',
460
+ param: 'max_tokens',
461
+ message: 'Unsupported parameter: max_tokens'
462
+ };
463
+
464
+ (mockOpenai.chat.completions.create as jest.Mock)
465
+ .mockRejectedValueOnce(error)
466
+ .mockResolvedValueOnce(createMockStream('feat: retried with max completion tokens'));
467
+
468
+ const request = {
469
+ model: 'gpt-4',
470
+ messages: [{ role: 'user' as const, content: 'test' }],
471
+ max_tokens: 101
472
+ };
473
+
474
+ const result = await (quietService as any).createStreamingCompletion(request);
475
+ expect(result).toBe('feat: retried with max completion tokens');
476
+
477
+ expect(mockOpenai.chat.completions.create).toHaveBeenNthCalledWith(2, {
478
+ model: 'gpt-4',
479
+ messages: [{ role: 'user', content: 'test' }],
480
+ max_completion_tokens: 101,
481
+ stream: true,
482
+ stream_options: { include_usage: true }
483
+ });
484
+ });
485
+
486
+ it('should retry with fallback model on 429 rate limit', async () => {
487
+ const quietService = new AIService({
488
+ apiKey: 'test-api-key',
489
+ model: 'gpt-4',
490
+ fallbackModel: 'gpt-3.5',
491
+ verbose: false
492
+ });
493
+
494
+ const error = { status: 429, message: 'Rate limit' };
495
+
496
+ (mockOpenai.chat.completions.create as jest.Mock)
497
+ .mockRejectedValueOnce(error)
498
+ .mockResolvedValueOnce(createMockStream('chore: fallback model used'));
499
+
500
+ const request = {
501
+ model: 'gpt-4',
502
+ messages: [{ role: 'user' as const, content: 'test' }],
503
+ max_completion_tokens: 100
504
+ };
505
+
506
+ const result = await (quietService as any).createStreamingCompletion(request);
507
+ expect(result).toBe('chore: fallback model used');
508
+
509
+ expect(mockOpenai.chat.completions.create).toHaveBeenNthCalledWith(2, {
510
+ model: 'gpt-3.5',
511
+ messages: [{ role: 'user', content: 'test' }],
512
+ max_completion_tokens: 100,
513
+ stream: true,
514
+ stream_options: { include_usage: true }
515
+ });
516
+ });
517
+
518
+ it('should handle reasoning chunks in stream and still return content', async () => {
519
+ const stream = {
520
+ [Symbol.asyncIterator]: async function* () {
521
+ yield { choices: [{ delta: { reasoning_content: 'thinking...' } }] };
522
+ yield { choices: [{ delta: { content: 'feat: ' } }] };
523
+ yield { choices: [{ delta: { content: 'final message' } }] };
524
+ }
525
+ };
526
+
527
+ (mockOpenai.chat.completions.create as jest.Mock).mockResolvedValue(stream);
528
+
529
+ const result = await aiService.generateCommitMessage('diff');
530
+ expect(result).toEqual({ success: true, message: 'feat: final message' });
531
+ });
532
+
533
+ it('should include reasoning token stats when usage has reasoning tokens', async () => {
534
+ const writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
535
+
536
+ const stream = {
537
+ [Symbol.asyncIterator]: async function* () {
538
+ yield { choices: [{ delta: { content: 'feat: done' } }] };
539
+ yield {
540
+ choices: [{ delta: {} }],
541
+ usage: {
542
+ prompt_tokens: 10,
543
+ completion_tokens: 30,
544
+ completion_tokens_details: { reasoning_tokens: 12 }
545
+ }
546
+ };
547
+ }
548
+ };
549
+
550
+ (mockOpenai.chat.completions.create as jest.Mock).mockResolvedValue(stream);
551
+
552
+ const result = await aiService.generateCommitMessage('diff');
553
+ expect(result).toEqual({ success: true, message: 'feat: done' });
554
+ expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining('thinking: 12'));
555
+
556
+ writeSpy.mockRestore();
557
+ });
558
+ });
559
+
560
+ describe('generateTagNotes', () => {
561
+ it('should return success with notes', async () => {
562
+ const quietService = new AIService({ apiKey: 'test-api-key', model: 'gpt-4', verbose: false });
563
+ (mockOpenai.chat.completions.create as jest.Mock).mockResolvedValue(
564
+ createMockStream('## Changes\n- Added feature')
565
+ );
566
+
567
+ const result = await quietService.generateTagNotes('v1.0.0', 'abc123 feat: add feature');
568
+ expect(result).toEqual({ success: true, notes: '## Changes\n- Added feature' });
569
+ });
570
+
571
+ it('should return error when response is empty', async () => {
572
+ const quietService = new AIService({ apiKey: 'test-api-key', model: 'gpt-4', verbose: false });
573
+ (mockOpenai.chat.completions.create as jest.Mock).mockResolvedValue(createMockStream(null));
574
+
575
+ const result = await quietService.generateTagNotes('v1.0.0', 'abc123 feat: add feature');
576
+ expect(result).toEqual({ success: false, error: 'No tag notes generated' });
577
+ });
578
+
579
+ it('should return error when API fails', async () => {
580
+ const quietService = new AIService({ apiKey: 'test-api-key', model: 'gpt-4', verbose: false });
581
+ (mockOpenai.chat.completions.create as jest.Mock).mockRejectedValue(new Error('tag API failed'));
582
+
583
+ const result = await quietService.generateTagNotes('v1.0.0', 'abc123 feat: add feature');
584
+ expect(result).toEqual({ success: false, error: 'tag API failed' });
585
+ });
586
+
587
+ it('should include style reference in user content when previous message is not provided', async () => {
588
+ const quietService = new AIService({ apiKey: 'test-api-key', model: 'gpt-4', verbose: false });
589
+ (mockOpenai.chat.completions.create as jest.Mock).mockResolvedValue(createMockStream('notes'));
590
+
591
+ await quietService.generateTagNotes('v1.2.0', 'log', undefined, null, 'Style sample text');
592
+
593
+ expect(mockOpenai.chat.completions.create).toHaveBeenCalledWith(
594
+ expect.objectContaining({
595
+ messages: [
596
+ {
597
+ role: 'system',
598
+ content: generateTagPrompt('v1.2.0', '', 'ko', false, true)
599
+ },
600
+ {
601
+ role: 'user',
602
+ content: expect.stringContaining('Style reference (follow this format):\nStyle sample text')
603
+ }
604
+ ]
605
+ })
606
+ );
607
+ });
608
+
609
+ it('should include previous message in user content', async () => {
610
+ const quietService = new AIService({ apiKey: 'test-api-key', model: 'gpt-4', verbose: false });
611
+ (mockOpenai.chat.completions.create as jest.Mock).mockResolvedValue(createMockStream('notes'));
612
+
613
+ await quietService.generateTagNotes('v1.2.0', 'log', undefined, 'Previous release notes', 'Style sample text');
614
+
615
+ expect(mockOpenai.chat.completions.create).toHaveBeenCalledWith(
616
+ expect.objectContaining({
617
+ messages: [
618
+ {
619
+ role: 'system',
620
+ content: generateTagPrompt('v1.2.0', '', 'ko', true, true)
621
+ },
622
+ {
623
+ role: 'user',
624
+ content: expect.stringContaining('Previous release notes for this tag (improve upon this):\nPrevious release notes')
625
+ }
626
+ ]
627
+ })
628
+ );
629
+ });
630
+
631
+ it('should include extra instructions in the system prompt', async () => {
632
+ const quietService = new AIService({ apiKey: 'test-api-key', model: 'gpt-4', verbose: false });
633
+ (mockOpenai.chat.completions.create as jest.Mock).mockResolvedValue(createMockStream('notes'));
634
+
635
+ await quietService.generateTagNotes('v2.0.0', 'log', 'Keep it short');
636
+
637
+ expect(mockOpenai.chat.completions.create).toHaveBeenCalledWith(
638
+ expect.objectContaining({
639
+ messages: [
640
+ {
641
+ role: 'system',
642
+ content: generateTagPrompt('v2.0.0', 'Keep it short', 'ko', false, false)
643
+ },
644
+ expect.any(Object)
645
+ ]
646
+ })
647
+ );
648
+ });
649
+ });
650
+
651
+ describe('Gemini mode', () => {
652
+ it('constructor should initialize Gemini client when mode is gemini', () => {
653
+ const { GoogleGenAI } = require('@google/genai') as { GoogleGenAI: jest.Mock };
654
+ GoogleGenAI.mockClear();
655
+
656
+ const geminiService = new AIService({ apiKey: 'gem-key', mode: 'gemini', model: 'gemini-2.0-flash', verbose: false });
657
+
658
+ expect(GoogleGenAI).toHaveBeenCalledWith({ apiKey: 'gem-key' });
659
+ expect((geminiService as any).gemini).toBeTruthy();
660
+ expect((geminiService as any).model).toBe('gemini-2.0-flash');
661
+ });
662
+
663
+ it('should stream content successfully in gemini mode', async () => {
664
+ const { GoogleGenAI } = require('@google/genai') as { GoogleGenAI: jest.Mock };
665
+ const generateContentStream = jest.fn().mockResolvedValue(
666
+ createGeminiMockStream([{ text: 'feat: ' }, { text: 'add gemini support' }])
667
+ );
668
+ GoogleGenAI.mockImplementation(() => ({ models: { generateContentStream } }));
669
+
670
+ const geminiService = new AIService({ apiKey: 'gem-key', mode: 'gemini', model: 'gem-model', verbose: false });
671
+ const result = await geminiService.generateCommitMessage('diff --git a/a b/a');
672
+
673
+ expect(result).toEqual({ success: true, message: 'feat: add gemini support' });
674
+ });
675
+
676
+ it('should handle empty gemini response', async () => {
677
+ const { GoogleGenAI } = require('@google/genai') as { GoogleGenAI: jest.Mock };
678
+ const generateContentStream = jest.fn().mockResolvedValue(createGeminiMockStream([]));
679
+ GoogleGenAI.mockImplementation(() => ({ models: { generateContentStream } }));
680
+
681
+ const geminiService = new AIService({ apiKey: 'gem-key', mode: 'gemini', verbose: false });
682
+ const result = await geminiService.generateCommitMessage('diff');
683
+
684
+ expect(result).toEqual({ success: false, error: 'No commit message generated' });
685
+ });
686
+
687
+ it('should handle gemini stream error', async () => {
688
+ const { GoogleGenAI } = require('@google/genai') as { GoogleGenAI: jest.Mock };
689
+ const generateContentStream = jest.fn().mockRejectedValue(new Error('gemini failed'));
690
+ GoogleGenAI.mockImplementation(() => ({ models: { generateContentStream } }));
691
+
692
+ const geminiService = new AIService({ apiKey: 'gem-key', mode: 'gemini', verbose: false });
693
+ const result = await geminiService.generateCommitMessage('diff');
694
+
695
+ expect(result).toEqual({ success: false, error: 'gemini failed' });
696
+ });
697
+
698
+ it('should pass system message and user messages to gemini stream request', async () => {
699
+ const { GoogleGenAI } = require('@google/genai') as { GoogleGenAI: jest.Mock };
700
+ const generateContentStream = jest.fn().mockResolvedValue(createGeminiMockStream([{ text: 'ok' }]));
701
+ GoogleGenAI.mockImplementation(() => ({ models: { generateContentStream } }));
702
+
703
+ const geminiService = new AIService({ apiKey: 'gem-key', mode: 'gemini', model: 'gem-model', verbose: false });
704
+ await geminiService.generateCommitMessage('my-diff');
705
+
706
+ expect(generateContentStream).toHaveBeenCalledWith(
707
+ expect.objectContaining({
708
+ model: 'gem-model',
709
+ contents: [{ role: 'user', parts: [{ text: 'Git diff:\nmy-diff' }] }],
710
+ config: expect.objectContaining({
711
+ systemInstruction: expect.stringContaining('Git diff will be provided separately in the user message.'),
712
+ maxOutputTokens: 1000
713
+ })
714
+ })
715
+ );
716
+ });
717
+
718
+ it('should consume usage metadata when available in gemini stream', async () => {
719
+ const { GoogleGenAI } = require('@google/genai') as { GoogleGenAI: jest.Mock };
720
+ const generateContentStream = jest.fn().mockResolvedValue(
721
+ createGeminiMockStream([
722
+ { text: 'feat: done', usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 20, totalTokenCount: 30 } }
723
+ ])
724
+ );
725
+ GoogleGenAI.mockImplementation(() => ({ models: { generateContentStream } }));
726
+
727
+ const geminiService = new AIService({ apiKey: 'gem-key', mode: 'gemini', verbose: false });
728
+ const result = await geminiService.generateCommitMessage('diff');
729
+
730
+ expect(result).toEqual({ success: true, message: 'feat: done' });
731
+ expect(generateContentStream).toHaveBeenCalledTimes(1);
732
+ });
733
+
734
+ it('createStreamingCompletion should dispatch to gemini implementation', async () => {
735
+ const { GoogleGenAI } = require('@google/genai') as { GoogleGenAI: jest.Mock };
736
+ const generateContentStream = jest.fn().mockResolvedValue(createGeminiMockStream([{ text: 'ok' }]));
737
+ GoogleGenAI.mockImplementation(() => ({ models: { generateContentStream } }));
738
+
739
+ const geminiService = new AIService({ apiKey: 'gem-key', mode: 'gemini', verbose: false });
740
+ const geminiSpy = jest.spyOn(geminiService as any, 'createGeminiStreamingCompletion').mockResolvedValue('gemini-result');
741
+
742
+ const result = await (geminiService as any).createStreamingCompletion({
743
+ model: 'gemini-2.0-flash',
744
+ messages: [{ role: 'user', content: 'hello' }],
745
+ max_completion_tokens: 123
746
+ });
747
+
748
+ expect(result).toBe('gemini-result');
749
+ expect(geminiSpy).toHaveBeenCalled();
750
+ geminiSpy.mockRestore();
751
+ });
752
+ });
274
753
  });
@@ -2,6 +2,7 @@ import { CommitCommand } from '../commands/commit';
2
2
  import { GitService } from '../commands/git';
3
3
  import { AIService } from '../commands/ai';
4
4
  import { ConfigService } from '../commands/config';
5
+ import readline from 'readline';
5
6
 
6
7
  const mockGenerateCommitMessage = jest.fn();
7
8
 
@@ -131,6 +132,82 @@ describe('CommitCommand', () => {
131
132
  expect(exitSpy).toHaveBeenCalledWith(1);
132
133
  });
133
134
 
135
+ it('should exit when staged diff fails', async () => {
136
+ (GitService.getStagedDiff as jest.Mock).mockResolvedValue({
137
+ success: false,
138
+ error: 'No staged changes found'
139
+ });
140
+ (GitService.createCommit as jest.Mock).mockResolvedValue(true);
141
+
142
+ const command = createCommand();
143
+ jest.spyOn(command as any, 'confirmCommit').mockResolvedValue(true);
144
+
145
+ await (command as any).handleCommit({});
146
+
147
+ expect(exitSpy).toHaveBeenCalledWith(1);
148
+ });
149
+ it('should exit when AI generation fails', async () => {
150
+ mockGenerateCommitMessage.mockResolvedValue({
151
+ success: false,
152
+ error: 'AI failed'
153
+ });
154
+ (GitService.createCommit as jest.Mock).mockResolvedValue(true);
155
+
156
+ const command = createCommand();
157
+ jest.spyOn(command as any, 'confirmCommit').mockResolvedValue(true);
158
+
159
+ await (command as any).handleCommit({});
160
+
161
+ expect(exitSpy).toHaveBeenCalledWith(1);
162
+ });
163
+ it('should exit when AI returns non-string message', async () => {
164
+ mockGenerateCommitMessage.mockResolvedValue({
165
+ success: true,
166
+ message: undefined
167
+ });
168
+ (GitService.createCommit as jest.Mock).mockResolvedValue(true);
169
+
170
+ const command = createCommand();
171
+ jest.spyOn(command as any, 'confirmCommit').mockResolvedValue(true);
172
+
173
+ await (command as any).handleCommit({});
174
+
175
+ expect(exitSpy).toHaveBeenCalledWith(1);
176
+ });
177
+
178
+ it('should append co-author trailer when configured', async () => {
179
+ (ConfigService.getConfig as jest.Mock).mockReturnValue({
180
+ apiKey: 'env-key',
181
+ baseURL: 'https://api.test',
182
+ model: 'test-model',
183
+ language: 'ko',
184
+ autoPush: false,
185
+ coAuthor: 'Bob <bob@example.com>'
186
+ });
187
+ (GitService.createCommit as jest.Mock).mockResolvedValue(true);
188
+
189
+ const command = createCommand();
190
+ jest.spyOn(command as any, 'confirmCommit').mockResolvedValue(true);
191
+
192
+ await (command as any).handleCommit({});
193
+
194
+ expect(GitService.createCommit).toHaveBeenCalledWith(
195
+ 'feat: test commit\n\nCo-authored-by: Bob <bob@example.com>'
196
+ );
197
+ });
198
+
199
+ it('should exit when push fails', async () => {
200
+ (GitService.createCommit as jest.Mock).mockResolvedValue(true);
201
+ (GitService.push as jest.Mock).mockResolvedValue(false);
202
+
203
+ const command = createCommand();
204
+ jest.spyOn(command as any, 'confirmCommit').mockResolvedValue(true);
205
+
206
+ await (command as any).handleCommit({ push: true });
207
+
208
+ expect(exitSpy).toHaveBeenCalledWith(1);
209
+ });
210
+
134
211
  it('should push automatically when autoPush is enabled in config', async () => {
135
212
  (GitService.createCommit as jest.Mock).mockResolvedValue(true);
136
213
  (GitService.push as jest.Mock).mockResolvedValue(true);
@@ -152,4 +229,38 @@ describe('CommitCommand', () => {
152
229
  expect(GitService.push).toHaveBeenCalledTimes(1);
153
230
  expect(exitSpy).not.toHaveBeenCalled();
154
231
  });
232
+
233
+ describe('confirmCommit', () => {
234
+ it('returns true for y', async () => {
235
+ const createInterfaceSpy = jest.spyOn(readline, 'createInterface').mockReturnValue({
236
+ question: jest.fn((_prompt, callback) => callback('y')),
237
+ close: jest.fn()
238
+ } as any);
239
+
240
+ try {
241
+ const command = createCommand();
242
+ const result = await (command as any).confirmCommit();
243
+
244
+ expect(result).toBe(true);
245
+ } finally {
246
+ createInterfaceSpy.mockRestore();
247
+ }
248
+ });
249
+
250
+ it('returns false for n', async () => {
251
+ const createInterfaceSpy = jest.spyOn(readline, 'createInterface').mockReturnValue({
252
+ question: jest.fn((_prompt, callback) => callback('n')),
253
+ close: jest.fn()
254
+ } as any);
255
+
256
+ try {
257
+ const command = createCommand();
258
+ const result = await (command as any).confirmCommit();
259
+
260
+ expect(result).toBe(false);
261
+ } finally {
262
+ createInterfaceSpy.mockRestore();
263
+ }
264
+ });
265
+ });
155
266
  });