@ksw8954/git-ai-commit 1.1.8 → 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 +3 -0
  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
@@ -1,188 +1,511 @@
1
+ import { exec, execFile } from 'child_process';
1
2
  import { GitService } from '../commands/git';
2
3
 
3
- // Mock the entire module at the top level
4
- jest.mock('../commands/git', () => {
5
- const getStagedDiff = jest.fn().mockResolvedValue({
6
- success: true,
7
- diff: 'mock diff'
4
+ jest.mock('child_process', () => ({
5
+ exec: jest.fn(),
6
+ execFile: jest.fn()
7
+ }));
8
+
9
+ type ExecResult = { stdout: string; stderr: string };
10
+ type MockCallback = (error: Error | null, result?: ExecResult) => void;
11
+
12
+ const mockExec = exec as unknown as jest.Mock;
13
+ const mockExecFile = execFile as unknown as jest.Mock;
14
+
15
+ const callbackFrom = (args: unknown[]): MockCallback => {
16
+ const callback = args[args.length - 1];
17
+ return callback as MockCallback;
18
+ };
19
+
20
+ const resolveExec = (stdout = ''): void => {
21
+ mockExec.mockImplementationOnce((...args: unknown[]) => {
22
+ callbackFrom(args)(null, { stdout, stderr: '' });
8
23
  });
9
- const createCommit = jest.fn().mockResolvedValue(true);
10
- const tagExists = jest.fn().mockResolvedValue(false);
11
- const remoteTagExists = jest.fn().mockResolvedValue(false);
12
- const deleteLocalTag = jest.fn().mockResolvedValue(true);
13
- const deleteRemoteTag = jest.fn().mockResolvedValue(true);
14
- const forcePushTag = jest.fn().mockResolvedValue(true);
15
-
16
- return {
17
- GitService: {
18
- getStagedDiff,
19
- createCommit,
20
- tagExists,
21
- remoteTagExists,
22
- deleteLocalTag,
23
- deleteRemoteTag,
24
- forcePushTag
25
- }
26
- };
27
- });
24
+ };
25
+
26
+ const rejectExec = (message: string): void => {
27
+ mockExec.mockImplementationOnce((...args: unknown[]) => {
28
+ callbackFrom(args)(new Error(message));
29
+ });
30
+ };
31
+
32
+ const resolveExecFile = (stdout = ''): void => {
33
+ mockExecFile.mockImplementationOnce((...args: unknown[]) => {
34
+ callbackFrom(args)(null, { stdout, stderr: '' });
35
+ });
36
+ };
37
+
38
+ const rejectExecFile = (message: string): void => {
39
+ mockExecFile.mockImplementationOnce((...args: unknown[]) => {
40
+ callbackFrom(args)(new Error(message));
41
+ });
42
+ };
43
+
44
+ const buildSection = (name: string, bodyLines: number, newFile = false): string => {
45
+ const header = [
46
+ `diff --git a/${name} b/${name}`,
47
+ 'index 1111111..2222222 100644',
48
+ newFile ? 'new file mode 100644' : '--- a/file',
49
+ newFile ? '--- /dev/null' : '+++ b/file',
50
+ newFile ? '+++ b/file' : '@@ -1,1 +1,1 @@'
51
+ ];
52
+
53
+ const body = Array.from({ length: bodyLines }, (_, index) => `+line-${index + 1}`);
54
+ return [...header, ...body].join('\n');
55
+ };
28
56
 
29
57
  describe('GitService', () => {
30
58
  beforeEach(() => {
31
59
  jest.clearAllMocks();
60
+ jest.spyOn(console, 'error').mockImplementation(() => undefined);
61
+ });
62
+
63
+ afterEach(() => {
64
+ jest.restoreAllMocks();
32
65
  });
33
66
 
34
67
  describe('getStagedDiff', () => {
35
- it('should return success with diff when staged changes exist', async () => {
36
- const mockDiff = 'diff --git a/file.txt b/file.txt\nnew file mode 100644';
37
-
38
- // Mock the implementation for this specific test
39
- (GitService.getStagedDiff as jest.Mock).mockResolvedValueOnce({
40
- success: true,
41
- diff: mockDiff
42
- });
68
+ it('returns success with diff when staged changes exist', async () => {
69
+ resolveExecFile('diff --git a/a.ts b/a.ts\n+ok\n');
43
70
 
44
71
  const result = await GitService.getStagedDiff();
45
72
 
73
+ expect(mockExecFile).toHaveBeenCalledWith('git', ['diff', '--staged'], {
74
+ maxBuffer: 50 * 1024 * 1024
75
+ }, expect.any(Function));
46
76
  expect(result).toEqual({
47
77
  success: true,
48
- diff: mockDiff
78
+ diff: 'diff --git a/a.ts b/a.ts\n+ok\n'
49
79
  });
50
80
  });
51
81
 
52
- it('should return error when no staged changes', async () => {
53
- (GitService.getStagedDiff as jest.Mock).mockResolvedValueOnce({
82
+ it('returns error when staged diff is empty', async () => {
83
+ resolveExecFile(' \n');
84
+
85
+ const result = await GitService.getStagedDiff();
86
+
87
+ expect(result).toEqual({
54
88
  success: false,
55
89
  error: 'No staged changes found. Please stage your changes first.'
56
90
  });
91
+ });
92
+
93
+ it('returns maxBuffer friendly error', async () => {
94
+ rejectExecFile('stdout maxBuffer length exceeded');
57
95
 
58
96
  const result = await GitService.getStagedDiff();
59
97
 
60
98
  expect(result).toEqual({
61
99
  success: false,
62
- error: 'No staged changes found. Please stage your changes first.'
100
+ error: 'Staged diff is too large to process. Try committing in smaller batches.'
63
101
  });
64
102
  });
65
103
 
66
- it('should return error when git command fails', async () => {
67
- (GitService.getStagedDiff as jest.Mock).mockResolvedValueOnce({
104
+ it('returns command error message', async () => {
105
+ rejectExecFile('fatal: bad object');
106
+
107
+ const result = await GitService.getStagedDiff();
108
+
109
+ expect(result).toEqual({ success: false, error: 'fatal: bad object' });
110
+ });
111
+
112
+ it('splits and truncates each section independently for multiple files', async () => {
113
+ const firstSection = buildSection('a.ts', 500, false);
114
+ const secondSection = buildSection('b.ts', 10, false);
115
+ resolveExecFile(`${firstSection}\n${secondSection}`);
116
+
117
+ const result = await GitService.getStagedDiff();
118
+
119
+ expect(result.success).toBe(true);
120
+ expect(result.diff).toContain('diff --git a/a.ts b/a.ts');
121
+ expect(result.diff).toContain('diff --git a/b.ts b/b.ts');
122
+ expect(result.diff).toContain('... (truncated');
123
+ expect(result.diff).toContain('+line-10');
124
+ expect(result.diff).not.toContain('+line-500');
125
+ });
126
+
127
+ it('treats a single section without diff header as one section', async () => {
128
+ const singleSection = Array.from({ length: 450 }, (_, index) => `line-${index + 1}`).join('\n');
129
+ resolveExecFile(singleSection);
130
+
131
+ const result = await GitService.getStagedDiff();
132
+
133
+ expect(result.success).toBe(true);
134
+ expect(result.diff).toContain('... (truncated');
135
+ expect(result.diff).not.toContain('line-450');
136
+ });
137
+
138
+ it('applies stricter new-file truncation limit', async () => {
139
+ resolveExecFile(buildSection('new-file.ts', 260, true));
140
+
141
+ const result = await GitService.getStagedDiff();
142
+
143
+ expect(result.success).toBe(true);
144
+ expect(result.diff).toContain('... (truncated');
145
+ expect(result.diff).not.toContain('+line-260');
146
+ });
147
+
148
+ it('applies total diff truncation when overall chars exceed limit', async () => {
149
+ const longLine = `+${'x'.repeat(120)}`;
150
+ const sections = Array.from({ length: 1200 }, (_, index) => {
151
+ return [
152
+ `diff --git a/file-${index}.ts b/file-${index}.ts`,
153
+ '@@ -1,1 +1,1 @@',
154
+ longLine
155
+ ].join('\n');
156
+ }).join('\n');
157
+ resolveExecFile(sections);
158
+
159
+ const result = await GitService.getStagedDiff();
160
+
161
+ expect(result.success).toBe(true);
162
+ expect(result.diff).toContain('... (truncated remaining diff to stay under 25000 tokens)');
163
+ expect(result.diff!.length).toBeLessThan(sections.length);
164
+ });
165
+ });
166
+
167
+ describe('getBranchDiff', () => {
168
+ it('returns success with branch diff', async () => {
169
+ resolveExecFile('diff --git a/a.ts b/a.ts\n+change');
170
+
171
+ const result = await GitService.getBranchDiff('main', 'feature');
172
+
173
+ expect(mockExecFile).toHaveBeenCalledWith('git', ['diff', 'main...feature'], {
174
+ maxBuffer: 50 * 1024 * 1024
175
+ }, expect.any(Function));
176
+ expect(result).toEqual({ success: true, diff: 'diff --git a/a.ts b/a.ts\n+change' });
177
+ });
178
+
179
+ it('returns error when no differences are found', async () => {
180
+ resolveExecFile('\n');
181
+
182
+ const result = await GitService.getBranchDiff('main', 'feature');
183
+
184
+ expect(result).toEqual({
68
185
  success: false,
69
- error: 'Git command failed'
186
+ error: 'No differences found between main and feature.'
70
187
  });
188
+ });
71
189
 
72
- const result = await GitService.getStagedDiff();
190
+ it('returns maxBuffer friendly error', async () => {
191
+ rejectExecFile('maxBuffer exceeded while reading');
192
+
193
+ const result = await GitService.getBranchDiff('main', 'feature');
73
194
 
74
195
  expect(result).toEqual({
75
196
  success: false,
76
- error: 'Git command failed'
197
+ error: 'Diff is too large to process. Try narrowing the compare range.'
77
198
  });
78
199
  });
79
- });
80
200
 
81
- describe('createCommit', () => {
82
- it('should return true when commit is successful', async () => {
83
- (GitService.createCommit as jest.Mock).mockResolvedValueOnce(true);
201
+ it('returns unknown revision friendly error', async () => {
202
+ rejectExecFile('fatal: unknown revision or path not in the working tree');
84
203
 
85
- const result = await GitService.createCommit('feat: add new feature');
204
+ const result = await GitService.getBranchDiff('main', 'missing');
86
205
 
87
- expect(result).toBe(true);
206
+ expect(result).toEqual({
207
+ success: false,
208
+ error: 'Unable to resolve one of the branches: main or missing.'
209
+ });
88
210
  });
89
211
 
90
- it('should return false when commit fails', async () => {
91
- (GitService.createCommit as jest.Mock).mockResolvedValueOnce(false);
212
+ it('returns raw command error when not mapped', async () => {
213
+ rejectExecFile('fatal: unexpected failure');
92
214
 
93
- const result = await GitService.createCommit('feat: add new feature');
215
+ const result = await GitService.getBranchDiff('main', 'feature');
94
216
 
95
- expect(result).toBe(false);
217
+ expect(result).toEqual({ success: false, error: 'fatal: unexpected failure' });
96
218
  });
97
219
  });
98
220
 
99
- describe('tagExists', () => {
100
- it('should return true when tag exists locally', async () => {
101
- (GitService.tagExists as jest.Mock).mockResolvedValueOnce(true);
221
+ describe('commit and push operations', () => {
222
+ it('createCommit succeeds', async () => {
223
+ resolveExecFile();
224
+ await expect(GitService.createCommit('feat: message')).resolves.toBe(true);
225
+ expect(mockExecFile).toHaveBeenCalledWith('git', ['commit', '-m', 'feat: message'], expect.any(Function));
226
+ });
102
227
 
103
- const result = await GitService.tagExists('v1.0.0');
228
+ it('createCommit fails', async () => {
229
+ rejectExecFile('commit failed');
230
+ await expect(GitService.createCommit('feat: message')).resolves.toBe(false);
231
+ });
104
232
 
105
- expect(result).toBe(true);
233
+ it('push succeeds', async () => {
234
+ resolveExecFile();
235
+ await expect(GitService.push()).resolves.toBe(true);
236
+ expect(mockExecFile).toHaveBeenCalledWith('git', ['push'], expect.any(Function));
106
237
  });
107
238
 
108
- it('should return false when tag does not exist locally', async () => {
109
- (GitService.tagExists as jest.Mock).mockResolvedValueOnce(false);
239
+ it('push fails', async () => {
240
+ rejectExecFile('push failed');
241
+ await expect(GitService.push()).resolves.toBe(false);
242
+ });
110
243
 
111
- const result = await GitService.tagExists('v1.0.0');
244
+ it('pushTag succeeds', async () => {
245
+ resolveExecFile();
246
+ await expect(GitService.pushTag('v1.0.0')).resolves.toBe(true);
247
+ expect(mockExecFile).toHaveBeenCalledWith('git', ['push', 'origin', 'v1.0.0'], expect.any(Function));
248
+ });
112
249
 
113
- expect(result).toBe(false);
250
+ it('pushTag fails', async () => {
251
+ rejectExecFile('push tag failed');
252
+ await expect(GitService.pushTag('v1.0.0')).resolves.toBe(false);
114
253
  });
115
254
  });
116
255
 
117
- describe('remoteTagExists', () => {
118
- it('should return true when tag exists on remote', async () => {
119
- (GitService.remoteTagExists as jest.Mock).mockResolvedValueOnce(true);
256
+ describe('tag discovery and commit history', () => {
257
+ it('getLatestTag succeeds with a tag', async () => {
258
+ resolveExec('v2.3.4\n');
259
+ await expect(GitService.getLatestTag()).resolves.toEqual({ success: true, tag: 'v2.3.4' });
260
+ expect(mockExec).toHaveBeenCalledWith('git describe --tags --abbrev=0', expect.any(Function));
261
+ });
262
+
263
+ it('getLatestTag returns empty tag error', async () => {
264
+ resolveExec('\n');
265
+ await expect(GitService.getLatestTag()).resolves.toEqual({
266
+ success: false,
267
+ error: 'No tags found in the repository.'
268
+ });
269
+ });
120
270
 
121
- const result = await GitService.remoteTagExists('v1.0.0');
271
+ it('getLatestTag returns fallback error on failure', async () => {
272
+ rejectExec('describe failed');
273
+ await expect(GitService.getLatestTag()).resolves.toEqual({
274
+ success: false,
275
+ error: 'Failed to determine the latest tag. Provide a base tag explicitly using --base-tag.'
276
+ });
277
+ });
122
278
 
123
- expect(result).toBe(true);
279
+ it('getRecentTags parses output', async () => {
280
+ resolveExec('v3.0.0\n\nv2.9.0\n');
281
+ await expect(GitService.getRecentTags(2)).resolves.toEqual(['v3.0.0', 'v2.9.0']);
282
+ expect(mockExec).toHaveBeenCalledWith('git tag --sort=-creatordate | head -n 2', expect.any(Function));
124
283
  });
125
284
 
126
- it('should return false when tag does not exist on remote', async () => {
127
- (GitService.remoteTagExists as jest.Mock).mockResolvedValueOnce(false);
285
+ it('getRecentTags returns empty array on error', async () => {
286
+ rejectExec('recent tags failed');
287
+ await expect(GitService.getRecentTags()).resolves.toEqual([]);
288
+ });
128
289
 
129
- const result = await GitService.remoteTagExists('v1.0.0');
290
+ it('getTagBefore returns earlier tag', async () => {
291
+ resolveExec('abc123\n');
292
+ resolveExec('v1.9.0\n');
130
293
 
131
- expect(result).toBe(false);
294
+ await expect(GitService.getTagBefore('v2.0.0')).resolves.toEqual({ success: true, tag: 'v1.9.0' });
295
+ expect(mockExec).toHaveBeenNthCalledWith(1, 'git rev-list -n 1 v2.0.0', expect.any(Function));
296
+ expect(mockExec).toHaveBeenNthCalledWith(2, 'git describe --tags --abbrev=0 abc123~1', expect.any(Function));
132
297
  });
133
- });
134
298
 
135
- describe('deleteLocalTag', () => {
136
- it('should return true when local tag is deleted successfully', async () => {
137
- (GitService.deleteLocalTag as jest.Mock).mockResolvedValueOnce(true);
299
+ it('getTagBefore returns error when commit is empty', async () => {
300
+ resolveExec(' \n');
301
+ await expect(GitService.getTagBefore('v2.0.0')).resolves.toEqual({
302
+ success: false,
303
+ error: 'Could not resolve tag to a commit.'
304
+ });
305
+ });
138
306
 
139
- const result = await GitService.deleteLocalTag('v1.0.0');
307
+ it('getTagBefore returns error when earlier tag is empty', async () => {
308
+ resolveExec('abc123\n');
309
+ resolveExec('\n');
310
+ await expect(GitService.getTagBefore('v2.0.0')).resolves.toEqual({
311
+ success: false,
312
+ error: 'No earlier tag found.'
313
+ });
314
+ });
140
315
 
141
- expect(result).toBe(true);
316
+ it('getTagBefore returns fallback error on exception', async () => {
317
+ rejectExec('rev-list failed');
318
+ await expect(GitService.getTagBefore('v2.0.0')).resolves.toEqual({
319
+ success: false,
320
+ error: 'No earlier tag found.'
321
+ });
142
322
  });
143
323
 
144
- it('should return false when local tag deletion fails', async () => {
145
- (GitService.deleteLocalTag as jest.Mock).mockResolvedValueOnce(false);
324
+ it('getCommitSummariesSince with tag succeeds', async () => {
325
+ resolveExec('fix: issue\nfeat: new\n');
326
+ await expect(GitService.getCommitSummariesSince('v1.0.0')).resolves.toEqual({
327
+ success: true,
328
+ log: '- fix: issue\n- feat: new'
329
+ });
330
+ expect(mockExec).toHaveBeenCalledWith('git log v1.0.0..HEAD --pretty=format:%s', expect.any(Function));
331
+ });
146
332
 
147
- const result = await GitService.deleteLocalTag('v1.0.0');
333
+ it('getCommitSummariesSince without tag succeeds', async () => {
334
+ resolveExec('chore: update\n');
335
+ await expect(GitService.getCommitSummariesSince()).resolves.toEqual({
336
+ success: true,
337
+ log: '- chore: update'
338
+ });
339
+ expect(mockExec).toHaveBeenCalledWith('git log --pretty=format:%s', expect.any(Function));
340
+ });
148
341
 
149
- expect(result).toBe(false);
342
+ it('getCommitSummariesSince returns empty result error with tag', async () => {
343
+ resolveExec('\n\n');
344
+ await expect(GitService.getCommitSummariesSince('v1.0.0')).resolves.toEqual({
345
+ success: false,
346
+ error: 'No commits found since tag v1.0.0.'
347
+ });
348
+ });
349
+
350
+ it('getCommitSummariesSince returns empty result error without tag', async () => {
351
+ resolveExec(' \n');
352
+ await expect(GitService.getCommitSummariesSince()).resolves.toEqual({
353
+ success: false,
354
+ error: 'No commits found in the repository.'
355
+ });
356
+ });
357
+
358
+ it('getCommitSummariesSince returns thrown error message', async () => {
359
+ rejectExec('log failed');
360
+ await expect(GitService.getCommitSummariesSince('v1.0.0')).resolves.toEqual({
361
+ success: false,
362
+ error: 'log failed'
363
+ });
150
364
  });
151
365
  });
152
366
 
153
- describe('deleteRemoteTag', () => {
154
- it('should return true when remote tag is deleted successfully', async () => {
155
- (GitService.deleteRemoteTag as jest.Mock).mockResolvedValueOnce(true);
367
+ describe('tag mutation methods', () => {
368
+ it('createAnnotatedTag succeeds', async () => {
369
+ resolveExecFile();
370
+ await expect(GitService.createAnnotatedTag('v1.0.0', 'release')).resolves.toBe(true);
371
+ expect(mockExecFile).toHaveBeenCalledWith('git', ['tag', '-a', 'v1.0.0', '-m', 'release'], expect.any(Function));
372
+ });
373
+
374
+ it('createAnnotatedTag fails', async () => {
375
+ rejectExecFile('tag failed');
376
+ await expect(GitService.createAnnotatedTag('v1.0.0', 'release')).resolves.toBe(false);
377
+ });
378
+
379
+ it('tagExists returns true when ref exists', async () => {
380
+ resolveExecFile();
381
+ await expect(GitService.tagExists('v1.0.0')).resolves.toBe(true);
382
+ expect(mockExecFile).toHaveBeenCalledWith('git', ['rev-parse', 'refs/tags/v1.0.0'], expect.any(Function));
383
+ });
156
384
 
157
- const result = await GitService.deleteRemoteTag('v1.0.0');
385
+ it('tagExists returns false when ref does not exist', async () => {
386
+ rejectExecFile('not found');
387
+ await expect(GitService.tagExists('v1.0.0')).resolves.toBe(false);
388
+ });
158
389
 
159
- expect(result).toBe(true);
390
+ it('getTagMessage returns cleaned multiline message', async () => {
391
+ resolveExecFile('v1.0.0 Release line 1\n line 2\n line 3\n');
392
+
393
+ await expect(GitService.getTagMessage('v1.0.0')).resolves.toBe('Release line 1\nline 2\nline 3');
394
+ expect(mockExecFile).toHaveBeenCalledWith('git', ['tag', '-l', '-n999', 'v1.0.0'], expect.any(Function));
160
395
  });
161
396
 
162
- it('should return false when remote tag deletion fails', async () => {
163
- (GitService.deleteRemoteTag as jest.Mock).mockResolvedValueOnce(false);
397
+ it('getTagMessage returns null for empty output', async () => {
398
+ resolveExecFile(' \n');
399
+ await expect(GitService.getTagMessage('v1.0.0')).resolves.toBeNull();
400
+ });
164
401
 
165
- const result = await GitService.deleteRemoteTag('v1.0.0');
402
+ it('getTagMessage returns null on error', async () => {
403
+ rejectExecFile('tag message failed');
404
+ await expect(GitService.getTagMessage('v1.0.0')).resolves.toBeNull();
405
+ });
166
406
 
167
- expect(result).toBe(false);
407
+ it('remoteTagExists returns true when found on origin', async () => {
408
+ resolveExec('abc refs/tags/v1.0.0\n');
409
+ await expect(GitService.remoteTagExists('v1.0.0')).resolves.toBe(true);
410
+ expect(mockExec).toHaveBeenCalledWith('git ls-remote --tags origin refs/tags/v1.0.0', expect.any(Function));
411
+ });
412
+
413
+ it('remoteTagExists returns false when missing on origin', async () => {
414
+ resolveExec('\n');
415
+ await expect(GitService.remoteTagExists('v1.0.0')).resolves.toBe(false);
168
416
  });
169
- });
170
417
 
171
- describe('forcePushTag', () => {
172
- it('should return true when force push is successful', async () => {
173
- (GitService.forcePushTag as jest.Mock).mockResolvedValueOnce(true);
418
+ it('remoteTagExists returns false on error', async () => {
419
+ rejectExec('ls-remote failed');
420
+ await expect(GitService.remoteTagExists('v1.0.0')).resolves.toBe(false);
421
+ });
174
422
 
175
- const result = await GitService.forcePushTag('v1.0.0');
423
+ it('deleteLocalTag succeeds', async () => {
424
+ resolveExecFile();
425
+ await expect(GitService.deleteLocalTag('v1.0.0')).resolves.toBe(true);
426
+ expect(mockExecFile).toHaveBeenCalledWith('git', ['tag', '-d', 'v1.0.0'], expect.any(Function));
427
+ });
176
428
 
177
- expect(result).toBe(true);
429
+ it('deleteLocalTag fails', async () => {
430
+ rejectExecFile('delete local failed');
431
+ await expect(GitService.deleteLocalTag('v1.0.0')).resolves.toBe(false);
178
432
  });
179
433
 
180
- it('should return false when force push fails', async () => {
181
- (GitService.forcePushTag as jest.Mock).mockResolvedValueOnce(false);
434
+ it('deleteRemoteTag succeeds', async () => {
435
+ resolveExecFile();
436
+ await expect(GitService.deleteRemoteTag('v1.0.0')).resolves.toBe(true);
437
+ expect(mockExecFile).toHaveBeenCalledWith('git', ['push', 'origin', '--delete', 'v1.0.0'], expect.any(Function));
438
+ });
439
+
440
+ it('deleteRemoteTag fails', async () => {
441
+ rejectExecFile('delete remote failed');
442
+ await expect(GitService.deleteRemoteTag('v1.0.0')).resolves.toBe(false);
443
+ });
444
+
445
+ it('forcePushTag succeeds with default remote', async () => {
446
+ resolveExecFile();
447
+ await expect(GitService.forcePushTag('v1.0.0')).resolves.toBe(true);
448
+ expect(mockExecFile).toHaveBeenCalledWith('git', ['push', 'origin', 'v1.0.0', '--force'], expect.any(Function));
449
+ });
450
+
451
+ it('forcePushTag succeeds with custom remote', async () => {
452
+ resolveExecFile();
453
+ await expect(GitService.forcePushTag('v1.0.0', 'upstream')).resolves.toBe(true);
454
+ expect(mockExecFile).toHaveBeenCalledWith('git', ['push', 'upstream', 'v1.0.0', '--force'], expect.any(Function));
455
+ });
182
456
 
183
- const result = await GitService.forcePushTag('v1.0.0');
457
+ it('forcePushTag fails', async () => {
458
+ rejectExecFile('force push failed');
459
+ await expect(GitService.forcePushTag('v1.0.0')).resolves.toBe(false);
460
+ });
461
+
462
+ it('getRemotes returns remote names', async () => {
463
+ resolveExec('origin\nupstream\n\n');
464
+ await expect(GitService.getRemotes()).resolves.toEqual(['origin', 'upstream']);
465
+ expect(mockExec).toHaveBeenCalledWith('git remote', expect.any(Function));
466
+ });
467
+
468
+ it('getRemotes returns empty array on error', async () => {
469
+ rejectExec('remote failed');
470
+ await expect(GitService.getRemotes()).resolves.toEqual([]);
471
+ });
472
+
473
+ it('pushTagToRemote succeeds', async () => {
474
+ resolveExecFile();
475
+ await expect(GitService.pushTagToRemote('v1.0.0', 'upstream')).resolves.toBe(true);
476
+ expect(mockExecFile).toHaveBeenCalledWith('git', ['push', 'upstream', 'v1.0.0'], expect.any(Function));
477
+ });
478
+
479
+ it('pushTagToRemote fails', async () => {
480
+ rejectExecFile('push to remote failed');
481
+ await expect(GitService.pushTagToRemote('v1.0.0', 'upstream')).resolves.toBe(false);
482
+ });
483
+
484
+ it('deleteRemoteTagFrom succeeds', async () => {
485
+ resolveExecFile();
486
+ await expect(GitService.deleteRemoteTagFrom('v1.0.0', 'upstream')).resolves.toBe(true);
487
+ expect(mockExecFile).toHaveBeenCalledWith('git', ['push', 'upstream', '--delete', 'v1.0.0'], expect.any(Function));
488
+ });
489
+
490
+ it('deleteRemoteTagFrom fails', async () => {
491
+ rejectExecFile('delete from remote failed');
492
+ await expect(GitService.deleteRemoteTagFrom('v1.0.0', 'upstream')).resolves.toBe(false);
493
+ });
494
+
495
+ it('remoteTagExistsOn returns true when tag exists', async () => {
496
+ resolveExec('abc refs/tags/v1.0.0\n');
497
+ await expect(GitService.remoteTagExistsOn('v1.0.0', 'upstream')).resolves.toBe(true);
498
+ expect(mockExec).toHaveBeenCalledWith('git ls-remote --tags upstream refs/tags/v1.0.0', expect.any(Function));
499
+ });
500
+
501
+ it('remoteTagExistsOn returns false when tag does not exist', async () => {
502
+ resolveExec(' \n');
503
+ await expect(GitService.remoteTagExistsOn('v1.0.0', 'upstream')).resolves.toBe(false);
504
+ });
184
505
 
185
- expect(result).toBe(false);
506
+ it('remoteTagExistsOn returns false on error', async () => {
507
+ rejectExec('ls-remote upstream failed');
508
+ await expect(GitService.remoteTagExistsOn('v1.0.0', 'upstream')).resolves.toBe(false);
186
509
  });
187
510
  });
188
511
  });
@@ -84,6 +84,7 @@ describe('Pre-commit Hook Tests', () => {
84
84
 
85
85
  it('should run pre-commit hooks if .pre-commit-config.yaml is present', async () => {
86
86
  mockFsExists.mockImplementation((p: string) => p.endsWith('.pre-commit-config.yaml'));
87
+ jest.spyOn(command as any, 'isCommandAvailable').mockReturnValue(true);
87
88
 
88
89
  jest.spyOn(command as any, 'confirmCommit').mockResolvedValue(true);
89
90
 
@@ -114,6 +115,7 @@ describe('Pre-commit Hook Tests', () => {
114
115
 
115
116
  it('should fail commit if pre-commit hooks fail', async () => {
116
117
  mockFsExists.mockImplementation((p: string) => p.endsWith('.pre-commit-config.yaml'));
118
+ jest.spyOn(command as any, 'isCommandAvailable').mockReturnValue(true);
117
119
 
118
120
  mockSpawn.mockImplementation(() => {
119
121
  const child = new EventEmitter();
@@ -134,6 +136,7 @@ describe('Pre-commit Hook Tests', () => {
134
136
  mockFsRead.mockReturnValue(JSON.stringify({
135
137
  scripts: { 'pre-commit': 'test' }
136
138
  }));
139
+ jest.spyOn(command as any, 'isCommandAvailable').mockReturnValue(true);
137
140
 
138
141
  jest.spyOn(command as any, 'confirmCommit').mockResolvedValue(true);
139
142
 
@@ -144,6 +147,22 @@ describe('Pre-commit Hook Tests', () => {
144
147
  expect(mockSpawn).toHaveBeenCalledWith('pre-commit', ['run'], expect.anything());
145
148
  });
146
149
 
150
+ it('should skip pre-commit hooks when pre-commit is not installed', async () => {
151
+ mockFsExists.mockImplementation((p: string) => p.endsWith('.pre-commit-config.yaml'));
152
+ jest.spyOn(command as any, 'isCommandAvailable').mockReturnValue(false);
153
+
154
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
155
+ jest.spyOn(command as any, 'confirmCommit').mockResolvedValue(true);
156
+
157
+ await (command as any).handleCommit({});
158
+
159
+ expect(mockSpawn).not.toHaveBeenCalledWith('pre-commit', ['run'], expect.anything());
160
+ expect(warnSpy).toHaveBeenCalledWith(
161
+ expect.stringContaining("'pre-commit' is not installed")
162
+ );
163
+ warnSpy.mockRestore();
164
+ });
165
+
147
166
  it('should skip npm pre-commit script when --no-verify is used', async () => {
148
167
  mockFsExists.mockImplementation((p: string) => p.endsWith('package.json'));
149
168
  mockFsRead.mockReturnValue(JSON.stringify({