@ksw8954/git-ai-commit 1.1.8 → 1.2.1

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 +31 -5
  2. package/CHANGELOG.md +16 -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 +108 -11
  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 +9 -3
  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 +128 -13
  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 +10 -4
  52. package/src/index.ts +3 -0
  53. package/src/schema/config.schema.json +72 -0
@@ -2,6 +2,8 @@ import { TagCommand } from '../commands/tag';
2
2
  import { GitService } from '../commands/git';
3
3
  import { AIService } from '../commands/ai';
4
4
  import { ConfigService } from '../commands/config';
5
+ import { Command } from 'commander';
6
+ import readline from 'readline';
5
7
 
6
8
  jest.mock('../commands/git', () => ({
7
9
  GitService: {
@@ -38,11 +40,16 @@ jest.mock('../commands/config', () => ({
38
40
  }));
39
41
 
40
42
  describe('TagCommand', () => {
41
- const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as any);
43
+ let exitSpy: jest.SpyInstance;
42
44
  let confirmSpy: jest.SpyInstance;
43
45
 
44
46
  beforeEach(() => {
45
47
  jest.clearAllMocks();
48
+ (Object.values(GitService) as jest.Mock[]).forEach(mockFn => mockFn.mockReset());
49
+ (ConfigService.getConfig as jest.Mock).mockReset();
50
+ (ConfigService.validateConfig as jest.Mock).mockReset();
51
+ mockGenerateTagNotes.mockReset();
52
+ exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
46
53
 
47
54
  (ConfigService.getConfig as jest.Mock).mockReturnValue({
48
55
  apiKey: 'env-key',
@@ -57,10 +64,16 @@ describe('TagCommand', () => {
57
64
  // Tag doesn't exist by default
58
65
  (GitService.tagExists as jest.Mock).mockResolvedValue(false);
59
66
  (GitService.remoteTagExists as jest.Mock).mockResolvedValue(false);
67
+ (GitService.createAnnotatedTag as jest.Mock).mockResolvedValue(true);
68
+ (GitService.pushTagToRemote as jest.Mock).mockResolvedValue(true);
69
+ (GitService.deleteLocalTag as jest.Mock).mockResolvedValue(true);
70
+ (GitService.deleteRemoteTag as jest.Mock).mockResolvedValue(true);
71
+ (GitService.forcePushTag as jest.Mock).mockResolvedValue(true);
60
72
 
61
73
  (GitService.getTagMessage as jest.Mock).mockResolvedValue(null);
62
74
  (GitService.getTagBefore as jest.Mock).mockResolvedValue({ success: false, error: 'No earlier tag found.' });
63
75
  (GitService.getRecentTags as jest.Mock).mockResolvedValue([]);
76
+ (GitService.getLatestTag as jest.Mock).mockResolvedValue({ success: true, tag: 'v1.0.0' });
64
77
 
65
78
  // Default: no remotes configured (skip push flow)
66
79
  (GitService.getRemotes as jest.Mock).mockResolvedValue([]);
@@ -77,13 +90,8 @@ describe('TagCommand', () => {
77
90
  });
78
91
 
79
92
  afterEach(() => {
80
- confirmSpy.mockRestore();
93
+ jest.restoreAllMocks();
81
94
  });
82
-
83
- afterAll(() => {
84
- exitSpy.mockRestore();
85
- });
86
-
87
95
  const getTagCommand = () => new TagCommand();
88
96
 
89
97
  it('should create tag with provided message without invoking AI', async () => {
@@ -203,19 +211,38 @@ describe('TagCommand', () => {
203
211
  expect(exitSpy).not.toHaveBeenCalled();
204
212
  });
205
213
 
206
- it('should exit when tag push fails after confirmation', async () => {
214
+ it('should ask for force push when normal push fails and user declines', async () => {
207
215
  (GitService.createAnnotatedTag as jest.Mock).mockResolvedValue(true);
208
216
  (GitService.pushTagToRemote as jest.Mock).mockResolvedValue(false);
209
217
  (GitService.getRemotes as jest.Mock).mockResolvedValue(['origin']);
210
218
  confirmSpy.mockResolvedValueOnce(['origin']);
219
+ jest
220
+ .spyOn(TagCommand.prototype as any, 'confirmForcePush')
221
+ .mockResolvedValueOnce(false);
211
222
 
212
223
  const command = getTagCommand();
213
224
 
214
225
  await (command as any).handleTag('v3.0.0', { message: 'Release notes' });
215
-
216
226
  expect(GitService.createAnnotatedTag).toHaveBeenCalledWith('v3.0.0', 'Release notes');
217
227
  expect(GitService.pushTagToRemote).toHaveBeenCalledWith('v3.0.0', 'origin');
218
- // Note: Now the command doesn't exit on push failure, it just logs and continues
228
+ expect(GitService.forcePushTag).not.toHaveBeenCalled();
229
+ });
230
+
231
+ it('should force push when normal push fails and user confirms', async () => {
232
+ (GitService.createAnnotatedTag as jest.Mock).mockResolvedValue(true);
233
+ (GitService.pushTagToRemote as jest.Mock).mockResolvedValue(false);
234
+ (GitService.forcePushTag as jest.Mock).mockResolvedValue(true);
235
+ (GitService.getRemotes as jest.Mock).mockResolvedValue(['origin']);
236
+ confirmSpy.mockResolvedValueOnce(['origin']);
237
+ jest
238
+ .spyOn(TagCommand.prototype as any, 'confirmForcePush')
239
+ .mockResolvedValueOnce(true);
240
+
241
+ const command = getTagCommand();
242
+
243
+ await (command as any).handleTag('v3.0.0', { message: 'Release notes' });
244
+ expect(GitService.pushTagToRemote).toHaveBeenCalledWith('v3.0.0', 'origin');
245
+ expect(GitService.forcePushTag).toHaveBeenCalledWith('v3.0.0', 'origin');
219
246
  });
220
247
 
221
248
  it('should cancel when user declines to replace existing local tag', async () => {
@@ -318,15 +345,17 @@ describe('TagCommand', () => {
318
345
 
319
346
  it('should force push when tag was replaced and user confirms', async () => {
320
347
  (GitService.tagExists as jest.Mock).mockResolvedValueOnce(true);
321
- (GitService.remoteTagExists as jest.Mock).mockResolvedValueOnce(false);
348
+ (GitService.remoteTagExists as jest.Mock).mockResolvedValueOnce(true);
322
349
  (GitService.deleteLocalTag as jest.Mock).mockResolvedValueOnce(true);
323
350
  (GitService.createAnnotatedTag as jest.Mock).mockResolvedValueOnce(true);
324
351
  (GitService.forcePushTag as jest.Mock).mockResolvedValueOnce(true);
325
352
  (GitService.getRemotes as jest.Mock).mockResolvedValue(['origin']);
326
-
327
353
  jest
328
354
  .spyOn(TagCommand.prototype as any, 'confirmTagDelete')
329
355
  .mockResolvedValueOnce(true);
356
+ jest
357
+ .spyOn(TagCommand.prototype as any, 'confirmRemoteTagDelete')
358
+ .mockResolvedValueOnce(false);
330
359
  confirmSpy.mockResolvedValueOnce(['origin']);
331
360
  jest
332
361
  .spyOn(TagCommand.prototype as any, 'confirmForcePush')
@@ -335,7 +364,6 @@ describe('TagCommand', () => {
335
364
  const command = getTagCommand();
336
365
 
337
366
  await (command as any).handleTag('v1.0.0', { message: 'Release notes' });
338
-
339
367
  expect(GitService.deleteLocalTag).toHaveBeenCalledWith('v1.0.0');
340
368
  expect(GitService.createAnnotatedTag).toHaveBeenCalledWith('v1.0.0', 'Release notes');
341
369
  expect(GitService.forcePushTag).toHaveBeenCalledWith('v1.0.0', 'origin');
@@ -343,23 +371,23 @@ describe('TagCommand', () => {
343
371
 
344
372
  it('should cancel push when user declines force push', async () => {
345
373
  (GitService.tagExists as jest.Mock).mockResolvedValueOnce(true);
346
- (GitService.remoteTagExists as jest.Mock).mockResolvedValueOnce(false);
374
+ (GitService.remoteTagExists as jest.Mock).mockResolvedValueOnce(true);
347
375
  (GitService.deleteLocalTag as jest.Mock).mockResolvedValueOnce(true);
348
376
  (GitService.createAnnotatedTag as jest.Mock).mockResolvedValueOnce(true);
349
377
  (GitService.getRemotes as jest.Mock).mockResolvedValue(['origin']);
350
-
351
378
  jest
352
379
  .spyOn(TagCommand.prototype as any, 'confirmTagDelete')
353
380
  .mockResolvedValueOnce(true);
381
+ jest
382
+ .spyOn(TagCommand.prototype as any, 'confirmRemoteTagDelete')
383
+ .mockResolvedValueOnce(false);
354
384
  confirmSpy.mockResolvedValueOnce(['origin']);
355
385
  jest
356
386
  .spyOn(TagCommand.prototype as any, 'confirmForcePush')
357
387
  .mockResolvedValueOnce(false);
358
-
359
388
  const command = getTagCommand();
360
389
 
361
390
  await (command as any).handleTag('v1.0.0', { message: 'Release notes' });
362
-
363
391
  expect(GitService.createAnnotatedTag).toHaveBeenCalledWith('v1.0.0', 'Release notes');
364
392
  expect(GitService.forcePushTag).not.toHaveBeenCalled();
365
393
  expect(GitService.pushTagToRemote).not.toHaveBeenCalled();
@@ -407,4 +435,469 @@ describe('TagCommand', () => {
407
435
  expect(GitService.pushTagToRemote).toHaveBeenCalledWith('v2.0.0', 'origin');
408
436
  expect(GitService.pushTagToRemote).toHaveBeenCalledWith('v2.0.0', 'upstream');
409
437
  });
438
+
439
+ it('should return commander command from getCommand', () => {
440
+ const command = getTagCommand();
441
+
442
+ const program = command.getCommand();
443
+
444
+ expect(program).toBeInstanceOf(Command);
445
+ expect(program.name()).toBe('tag');
446
+ });
447
+
448
+ it('should auto-increment patch from latest tag when name is omitted (v-prefixed)', async () => {
449
+ (GitService.getLatestTag as jest.Mock).mockResolvedValueOnce({ success: true, tag: 'v1.2.3' });
450
+ (GitService.createAnnotatedTag as jest.Mock).mockResolvedValueOnce(true);
451
+
452
+ const command = getTagCommand();
453
+
454
+ await (command as any).handleTag(undefined, { message: 'Release notes' });
455
+
456
+ expect(GitService.createAnnotatedTag).toHaveBeenCalledWith('v1.2.4', 'Release notes');
457
+ expect(exitSpy).not.toHaveBeenCalled();
458
+ });
459
+
460
+ it('should auto-increment patch from latest tag when name is omitted (no v prefix)', async () => {
461
+ (GitService.getLatestTag as jest.Mock).mockResolvedValueOnce({ success: true, tag: '1.2.3' });
462
+ (GitService.createAnnotatedTag as jest.Mock).mockResolvedValueOnce(true);
463
+
464
+ const command = getTagCommand();
465
+
466
+ await (command as any).handleTag(undefined, { message: 'Release notes' });
467
+
468
+ expect(GitService.createAnnotatedTag).toHaveBeenCalledWith('1.2.4', 'Release notes');
469
+ expect(exitSpy).not.toHaveBeenCalled();
470
+ });
471
+
472
+ it('should auto-increment patch from latest tag when name is omitted (prefixed version)', async () => {
473
+ (GitService.getLatestTag as jest.Mock).mockResolvedValueOnce({ success: true, tag: 'release-v1.2.3' });
474
+ (GitService.createAnnotatedTag as jest.Mock).mockResolvedValueOnce(true);
475
+
476
+ const command = getTagCommand();
477
+
478
+ await (command as any).handleTag(undefined, { message: 'Release notes' });
479
+
480
+ expect(GitService.createAnnotatedTag).toHaveBeenCalledWith('release-v1.2.4', 'Release notes');
481
+ expect(exitSpy).not.toHaveBeenCalled();
482
+ });
483
+
484
+ it('should exit when no existing tags are found for auto-increment', async () => {
485
+ (GitService.getLatestTag as jest.Mock).mockResolvedValueOnce({ success: false, error: 'No tags' });
486
+
487
+ const command = getTagCommand();
488
+
489
+ await (command as any).handleTag(undefined, { message: 'Release notes' });
490
+
491
+ expect(exitSpy).toHaveBeenCalledWith(1);
492
+ expect(GitService.createAnnotatedTag).not.toHaveBeenCalled();
493
+ });
494
+
495
+ it('should exit when latest tag cannot be parsed for auto-increment', async () => {
496
+ (GitService.getLatestTag as jest.Mock).mockResolvedValueOnce({ success: true, tag: 'not-semver' });
497
+
498
+ const command = getTagCommand();
499
+
500
+ await (command as any).handleTag(undefined, { message: 'Release notes' });
501
+
502
+ expect(exitSpy).toHaveBeenCalledWith(1);
503
+ expect(GitService.createAnnotatedTag).not.toHaveBeenCalled();
504
+ });
505
+
506
+ it('should proceed when tag style mismatches and user confirms', async () => {
507
+ (GitService.getRecentTags as jest.Mock).mockResolvedValueOnce(['v1.0.0', 'v1.0.1', 'v1.1.0']);
508
+ (GitService.createAnnotatedTag as jest.Mock).mockResolvedValueOnce(true);
509
+ const mismatchSpy = jest
510
+ .spyOn(TagCommand.prototype as any, 'confirmStyleMismatch')
511
+ .mockResolvedValueOnce(true);
512
+
513
+ const command = getTagCommand();
514
+
515
+ await (command as any).handleTag('release-1.2.0', { message: 'Release notes' });
516
+
517
+ expect(mismatchSpy).toHaveBeenCalledTimes(1);
518
+ expect(GitService.createAnnotatedTag).toHaveBeenCalledWith('release-1.2.0', 'Release notes');
519
+ });
520
+
521
+ it('should cancel when tag style mismatches and user declines', async () => {
522
+ (GitService.getRecentTags as jest.Mock).mockResolvedValueOnce(['v1.0.0', 'v1.0.1']);
523
+ const mismatchSpy = jest
524
+ .spyOn(TagCommand.prototype as any, 'confirmStyleMismatch')
525
+ .mockResolvedValueOnce(false);
526
+
527
+ const command = getTagCommand();
528
+
529
+ await (command as any).handleTag('release-1.2.0', { message: 'Release notes' });
530
+
531
+ expect(mismatchSpy).toHaveBeenCalledTimes(1);
532
+ expect(GitService.createAnnotatedTag).not.toHaveBeenCalled();
533
+ });
534
+
535
+ it('should not warn for style mismatch when pattern matches or there are not enough tags', async () => {
536
+ const mismatchSpy = jest.spyOn(TagCommand.prototype as any, 'confirmStyleMismatch');
537
+ (GitService.createAnnotatedTag as jest.Mock).mockResolvedValue(true);
538
+
539
+ (GitService.getRecentTags as jest.Mock).mockResolvedValueOnce(['v1.0.0', 'v1.0.1']);
540
+ const commandA = getTagCommand();
541
+ await (commandA as any).handleTag('v1.0.2', { message: 'Release notes' });
542
+
543
+ (GitService.getRecentTags as jest.Mock).mockResolvedValueOnce(['v1.0.0']);
544
+ const commandB = getTagCommand();
545
+ await (commandB as any).handleTag('release-1.0.1', { message: 'Release notes' });
546
+
547
+ expect(mismatchSpy).not.toHaveBeenCalled();
548
+ expect(GitService.createAnnotatedTag).toHaveBeenCalledTimes(2);
549
+ });
550
+
551
+ it('should delete remote tag when it exists remotely but not locally and user confirms', async () => {
552
+ (GitService.tagExists as jest.Mock).mockResolvedValueOnce(false);
553
+ (GitService.remoteTagExists as jest.Mock).mockResolvedValueOnce(true);
554
+ (GitService.deleteRemoteTag as jest.Mock).mockResolvedValueOnce(true);
555
+ (GitService.createAnnotatedTag as jest.Mock).mockResolvedValueOnce(true);
556
+ jest
557
+ .spyOn(TagCommand.prototype as any, 'confirmRemoteTagDelete')
558
+ .mockResolvedValueOnce(true);
559
+
560
+ const command = getTagCommand();
561
+
562
+ await (command as any).handleTag('v4.0.0', { message: 'Release notes' });
563
+
564
+ expect(GitService.deleteRemoteTag).toHaveBeenCalledWith('v4.0.0');
565
+ expect(GitService.createAnnotatedTag).toHaveBeenCalledWith('v4.0.0', 'Release notes');
566
+ });
567
+
568
+ it('should proceed without deleting remote when remote-only tag exists and user declines deletion', async () => {
569
+ (GitService.tagExists as jest.Mock).mockResolvedValueOnce(false);
570
+ (GitService.remoteTagExists as jest.Mock).mockResolvedValueOnce(true);
571
+ (GitService.createAnnotatedTag as jest.Mock).mockResolvedValueOnce(true);
572
+ jest
573
+ .spyOn(TagCommand.prototype as any, 'confirmRemoteTagDelete')
574
+ .mockResolvedValueOnce(false);
575
+
576
+ const command = getTagCommand();
577
+
578
+ await (command as any).handleTag('v4.0.0', { message: 'Release notes' });
579
+
580
+ expect(GitService.deleteRemoteTag).not.toHaveBeenCalled();
581
+ expect(GitService.createAnnotatedTag).toHaveBeenCalledWith('v4.0.0', 'Release notes');
582
+ });
583
+
584
+ it('should delete base tag and use older base when no commits since base tag', async () => {
585
+ (GitService.getCommitSummariesSince as jest.Mock)
586
+ .mockResolvedValueOnce({ success: false, error: 'No commits found' })
587
+ .mockResolvedValueOnce({ success: true, log: '- fix: hotfix' });
588
+ (GitService.getTagBefore as jest.Mock).mockResolvedValueOnce({ success: true, tag: 'v1.9.0' });
589
+ (GitService.tagExists as jest.Mock)
590
+ .mockResolvedValueOnce(false)
591
+ .mockResolvedValueOnce(true);
592
+ (GitService.remoteTagExists as jest.Mock)
593
+ .mockResolvedValueOnce(false)
594
+ .mockResolvedValueOnce(true);
595
+ (GitService.deleteLocalTag as jest.Mock).mockResolvedValueOnce(true);
596
+ (GitService.deleteRemoteTag as jest.Mock).mockResolvedValueOnce(true);
597
+ (GitService.getTagMessage as jest.Mock)
598
+ .mockResolvedValueOnce(null)
599
+ .mockResolvedValueOnce('older style message');
600
+ (GitService.createAnnotatedTag as jest.Mock).mockResolvedValueOnce(true);
601
+ mockGenerateTagNotes.mockResolvedValueOnce({ success: true, notes: '- tag notes' });
602
+
603
+ jest
604
+ .spyOn(TagCommand.prototype as any, 'confirmBaseTagDelete')
605
+ .mockResolvedValueOnce(true);
606
+ jest
607
+ .spyOn(TagCommand.prototype as any, 'confirmRemoteTagDelete')
608
+ .mockResolvedValueOnce(true);
609
+
610
+ const command = getTagCommand();
611
+
612
+ await (command as any).handleTag('v2.0.0', { baseTag: 'v2.0.0-base' });
613
+
614
+ expect(GitService.deleteLocalTag).toHaveBeenCalledWith('v2.0.0-base');
615
+ expect(GitService.deleteRemoteTag).toHaveBeenCalledWith('v2.0.0-base');
616
+ expect(GitService.getCommitSummariesSince).toHaveBeenLastCalledWith('v1.9.0');
617
+ expect(mockGenerateTagNotes).toHaveBeenCalledWith('v2.0.0', '- fix: hotfix', undefined, null, 'older style message');
618
+ });
619
+
620
+ it('should exit when no commits since base tag and user declines deleting base tag', async () => {
621
+ (GitService.getCommitSummariesSince as jest.Mock).mockResolvedValueOnce({
622
+ success: false,
623
+ error: 'No commits found'
624
+ });
625
+ (GitService.tagExists as jest.Mock).mockResolvedValueOnce(false);
626
+ (GitService.remoteTagExists as jest.Mock).mockResolvedValueOnce(false);
627
+ jest
628
+ .spyOn(TagCommand.prototype as any, 'confirmBaseTagDelete')
629
+ .mockResolvedValueOnce(false);
630
+
631
+ const command = getTagCommand();
632
+
633
+ await (command as any).handleTag('v2.0.0', { baseTag: 'v2.0.0-base' });
634
+
635
+ expect(exitSpy).toHaveBeenCalledWith(1);
636
+ expect(GitService.getTagBefore).not.toHaveBeenCalled();
637
+ expect(GitService.createAnnotatedTag).not.toHaveBeenCalled();
638
+ });
639
+
640
+ it('should pass base tag message as style reference to AI when available', async () => {
641
+ (GitService.getCommitSummariesSince as jest.Mock).mockResolvedValueOnce({
642
+ success: true,
643
+ log: '- feat: improve ux'
644
+ });
645
+ (GitService.getTagMessage as jest.Mock).mockResolvedValueOnce('release style reference');
646
+ mockGenerateTagNotes.mockResolvedValueOnce({ success: true, notes: '- notes' });
647
+ (GitService.createAnnotatedTag as jest.Mock).mockResolvedValueOnce(true);
648
+
649
+ const command = getTagCommand();
650
+
651
+ await (command as any).handleTag('v2.1.0', { baseTag: 'v2.0.0' });
652
+
653
+ expect(GitService.getTagMessage).toHaveBeenCalledWith('v2.0.0');
654
+ expect(mockGenerateTagNotes).toHaveBeenCalledWith(
655
+ 'v2.1.0',
656
+ '- feat: improve ux',
657
+ undefined,
658
+ null,
659
+ 'release style reference'
660
+ );
661
+ });
662
+
663
+ it('should continue with full history when base tag deletion local delete fails and no older tag exists', async () => {
664
+ (GitService.getCommitSummariesSince as jest.Mock)
665
+ .mockResolvedValueOnce({ success: false, error: 'No commits found' })
666
+ .mockResolvedValueOnce({ success: true, log: '- feat: fallback history' });
667
+ (GitService.getTagBefore as jest.Mock).mockResolvedValueOnce({ success: false, error: 'No earlier tag found.' });
668
+ (GitService.tagExists as jest.Mock)
669
+ .mockResolvedValueOnce(false)
670
+ .mockResolvedValueOnce(true);
671
+ (GitService.deleteLocalTag as jest.Mock).mockResolvedValueOnce(false);
672
+ (GitService.remoteTagExists as jest.Mock)
673
+ .mockResolvedValueOnce(false)
674
+ .mockResolvedValueOnce(false);
675
+ (GitService.getTagMessage as jest.Mock)
676
+ .mockResolvedValueOnce('base style')
677
+ .mockResolvedValueOnce(null);
678
+ mockGenerateTagNotes.mockResolvedValueOnce({ success: true, notes: '- generated notes' });
679
+ (GitService.createAnnotatedTag as jest.Mock).mockResolvedValueOnce(true);
680
+
681
+ jest
682
+ .spyOn(TagCommand.prototype as any, 'confirmBaseTagDelete')
683
+ .mockResolvedValueOnce(true);
684
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
685
+
686
+ const command = getTagCommand();
687
+
688
+ await (command as any).handleTag('v2.4.0', { baseTag: 'v2.3.0' });
689
+
690
+ expect(errorSpy).toHaveBeenCalledWith('❌ Failed to delete local tag v2.3.0');
691
+ expect(GitService.getCommitSummariesSince).toHaveBeenLastCalledWith(undefined);
692
+ expect(mockGenerateTagNotes).toHaveBeenCalledWith(
693
+ 'v2.4.0',
694
+ '- feat: fallback history',
695
+ undefined,
696
+ null,
697
+ null
698
+ );
699
+ expect(GitService.createAnnotatedTag).toHaveBeenCalledWith('v2.4.0', '- generated notes');
700
+ });
701
+
702
+ it('should log error when base tag remote deletion fails and continue generating notes', async () => {
703
+ (GitService.getCommitSummariesSince as jest.Mock)
704
+ .mockResolvedValueOnce({ success: false, error: 'No commits found' })
705
+ .mockResolvedValueOnce({ success: true, log: '- fix: recovered' });
706
+ (GitService.getTagBefore as jest.Mock).mockResolvedValueOnce({ success: true, tag: 'v2.2.0' });
707
+ (GitService.tagExists as jest.Mock)
708
+ .mockResolvedValueOnce(false)
709
+ .mockResolvedValueOnce(false);
710
+ (GitService.remoteTagExists as jest.Mock)
711
+ .mockResolvedValueOnce(false)
712
+ .mockResolvedValueOnce(true);
713
+ (GitService.deleteRemoteTag as jest.Mock).mockResolvedValueOnce(false);
714
+ (GitService.getTagMessage as jest.Mock)
715
+ .mockResolvedValueOnce(null)
716
+ .mockResolvedValueOnce('older style');
717
+ mockGenerateTagNotes.mockResolvedValueOnce({ success: true, notes: '- generated notes' });
718
+ (GitService.createAnnotatedTag as jest.Mock).mockResolvedValueOnce(true);
719
+
720
+ jest
721
+ .spyOn(TagCommand.prototype as any, 'confirmBaseTagDelete')
722
+ .mockResolvedValueOnce(true);
723
+ jest
724
+ .spyOn(TagCommand.prototype as any, 'confirmRemoteTagDelete')
725
+ .mockResolvedValueOnce(true);
726
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
727
+
728
+ const command = getTagCommand();
729
+
730
+ await (command as any).handleTag('v2.5.0', { baseTag: 'v2.4.0' });
731
+
732
+ expect(errorSpy).toHaveBeenCalledWith('❌ Failed to delete remote tag v2.4.0');
733
+ expect(GitService.getCommitSummariesSince).toHaveBeenLastCalledWith('v2.2.0');
734
+ expect(GitService.createAnnotatedTag).toHaveBeenCalledWith('v2.5.0', '- generated notes');
735
+ });
736
+
737
+ it('should exit when resolveAIConfig throws', async () => {
738
+ (GitService.getCommitSummariesSince as jest.Mock).mockResolvedValueOnce({
739
+ success: true,
740
+ log: '- feat: improve ux'
741
+ });
742
+ jest
743
+ .spyOn(TagCommand.prototype as any, 'resolveAIConfig')
744
+ .mockImplementationOnce(() => {
745
+ throw new Error('Missing API key');
746
+ });
747
+
748
+ const command = getTagCommand();
749
+
750
+ await (command as any).handleTag('v2.2.0', {});
751
+
752
+ expect(exitSpy).toHaveBeenCalledWith(1);
753
+ expect(AIService).not.toHaveBeenCalled();
754
+ expect(GitService.createAnnotatedTag).not.toHaveBeenCalled();
755
+ });
756
+
757
+ it('should exit when AI note generation fails', async () => {
758
+ (GitService.getCommitSummariesSince as jest.Mock).mockResolvedValueOnce({
759
+ success: true,
760
+ log: '- feat: improve ux'
761
+ });
762
+ mockGenerateTagNotes.mockResolvedValueOnce({ success: false, error: 'AI failed' });
763
+
764
+ const command = getTagCommand();
765
+
766
+ await (command as any).handleTag('v2.3.0', {});
767
+
768
+ expect(exitSpy).toHaveBeenCalledWith(1);
769
+ expect(GitService.createAnnotatedTag).not.toHaveBeenCalled();
770
+ });
771
+
772
+ it('should cancel when user declines final tag creation confirmation', async () => {
773
+ (GitService.createAnnotatedTag as jest.Mock).mockResolvedValueOnce(true);
774
+ jest
775
+ .spyOn(TagCommand.prototype as any, 'confirmTagCreate')
776
+ .mockResolvedValueOnce(false);
777
+
778
+ const command = getTagCommand();
779
+
780
+ await (command as any).handleTag('v5.0.0', { message: 'Release notes' });
781
+
782
+ expect(GitService.createAnnotatedTag).not.toHaveBeenCalled();
783
+ });
784
+
785
+ it('should exit when annotated tag creation fails', async () => {
786
+ (GitService.createAnnotatedTag as jest.Mock).mockResolvedValue(false);
787
+
788
+ const command = getTagCommand();
789
+
790
+ await (command as any).handleTag('v5.1.0', { message: 'Release notes' });
791
+
792
+ expect(exitSpy).toHaveBeenCalledWith(1);
793
+ });
794
+
795
+ it('should log error when force push fails in needsForcePush flow', async () => {
796
+ (GitService.tagExists as jest.Mock).mockResolvedValueOnce(true);
797
+ (GitService.remoteTagExists as jest.Mock).mockResolvedValueOnce(true);
798
+ (GitService.deleteLocalTag as jest.Mock).mockResolvedValueOnce(true);
799
+ (GitService.createAnnotatedTag as jest.Mock).mockResolvedValueOnce(true);
800
+ (GitService.getRemotes as jest.Mock).mockResolvedValueOnce(['origin']);
801
+ (GitService.forcePushTag as jest.Mock).mockResolvedValueOnce(false);
802
+ jest
803
+ .spyOn(TagCommand.prototype as any, 'confirmTagDelete')
804
+ .mockResolvedValueOnce(true);
805
+ jest
806
+ .spyOn(TagCommand.prototype as any, 'confirmRemoteTagDelete')
807
+ .mockResolvedValueOnce(false);
808
+ jest
809
+ .spyOn(TagCommand.prototype as any, 'confirmForcePush')
810
+ .mockResolvedValueOnce(true);
811
+ confirmSpy.mockResolvedValueOnce(['origin']);
812
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
813
+
814
+ const command = getTagCommand();
815
+
816
+ await (command as any).handleTag('v5.2.0', { message: 'Release notes' });
817
+
818
+ expect(GitService.forcePushTag).toHaveBeenCalledWith('v5.2.0', 'origin');
819
+ expect(errorSpy).toHaveBeenCalledWith('❌ Failed to force push tag to origin');
820
+ errorSpy.mockRestore();
821
+ });
822
+ });
823
+
824
+ describe('TagCommand confirmation methods (readline)', () => {
825
+ const questionMock = jest.fn<void, [string, (answer: string) => void]>();
826
+ const closeMock = jest.fn();
827
+
828
+ beforeEach(() => {
829
+ jest.clearAllMocks();
830
+ jest.spyOn(readline, 'createInterface').mockReturnValue({
831
+ question: questionMock,
832
+ close: closeMock
833
+ } as unknown as readline.Interface);
834
+ });
835
+
836
+ afterEach(() => {
837
+ jest.restoreAllMocks();
838
+ });
839
+
840
+ it('confirmTagCreate returns true for yes-like answers and false for no', async () => {
841
+ questionMock.mockImplementationOnce((_prompt, callback) => callback(' yes '));
842
+ const command = new TagCommand();
843
+
844
+ const yesResult = await (command as any).confirmTagCreate('v1.0.0');
845
+ expect(yesResult).toBe(true);
846
+
847
+ questionMock.mockImplementationOnce((_prompt, callback) => callback('n'));
848
+ const noResult = await (command as any).confirmTagCreate('v1.0.1');
849
+ expect(noResult).toBe(false);
850
+ expect(closeMock).toHaveBeenCalledTimes(2);
851
+ });
852
+
853
+ it('confirmTagDelete, confirmRemoteTagDelete, confirmBaseTagDelete, and confirmForcePush parse answers', async () => {
854
+ questionMock
855
+ .mockImplementationOnce((_prompt, callback) => callback('y'))
856
+ .mockImplementationOnce((_prompt, callback) => callback(' no '))
857
+ .mockImplementationOnce((_prompt, callback) => callback('yes'))
858
+ .mockImplementationOnce((_prompt, callback) => callback('0'));
859
+
860
+ const command = new TagCommand();
861
+
862
+ await expect((command as any).confirmTagDelete('v1.0.0')).resolves.toBe(true);
863
+ await expect((command as any).confirmRemoteTagDelete('v1.0.0')).resolves.toBe(false);
864
+ await expect((command as any).confirmBaseTagDelete('v1.0.0')).resolves.toBe(true);
865
+ await expect((command as any).confirmForcePush('v1.0.0')).resolves.toBe(false);
866
+ expect(closeMock).toHaveBeenCalledTimes(4);
867
+ });
868
+
869
+ it('confirmStyleMismatch returns true for y and false for n', async () => {
870
+ questionMock
871
+ .mockImplementationOnce((_prompt, callback) => callback('y'))
872
+ .mockImplementationOnce((_prompt, callback) => callback('n'));
873
+
874
+ const command = new TagCommand();
875
+ const mismatch = {
876
+ newTag: 'release-1.2.0',
877
+ newPattern: 'release-{n}.{n}.{n}',
878
+ dominantPattern: 'v{n}.{n}.{n}',
879
+ examples: ['v1.0.0', 'v1.0.1', 'v1.1.0']
880
+ };
881
+
882
+ await expect((command as any).confirmStyleMismatch(mismatch)).resolves.toBe(true);
883
+ await expect((command as any).confirmStyleMismatch(mismatch)).resolves.toBe(false);
884
+ expect(closeMock).toHaveBeenCalledTimes(2);
885
+ });
886
+
887
+ it('selectRemotesForPush handles all, numbered, deduped, and invalid selections', async () => {
888
+ questionMock
889
+ .mockImplementationOnce((_prompt, callback) => callback('all'))
890
+ .mockImplementationOnce((_prompt, callback) => callback('2,1,2'))
891
+ .mockImplementationOnce((_prompt, callback) => callback(''))
892
+ .mockImplementationOnce((_prompt, callback) => callback('7'));
893
+
894
+ const command = new TagCommand();
895
+ const remotes = ['origin', 'upstream', 'mirror'];
896
+
897
+ await expect((command as any).selectRemotesForPush('v1.0.0', remotes)).resolves.toEqual(remotes);
898
+ await expect((command as any).selectRemotesForPush('v1.0.0', remotes)).resolves.toEqual(['upstream', 'origin']);
899
+ await expect((command as any).selectRemotesForPush('v1.0.0', remotes)).resolves.toBeNull();
900
+ await expect((command as any).selectRemotesForPush('v1.0.0', remotes)).resolves.toBeNull();
901
+ expect(closeMock).toHaveBeenCalledTimes(4);
902
+ });
410
903
  });