@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.
- package/.github/workflows/publish.yml +31 -5
- package/CHANGELOG.md +16 -0
- package/README.md +25 -4
- package/dist/commands/ai.d.ts +7 -1
- package/dist/commands/ai.d.ts.map +1 -1
- package/dist/commands/ai.js +108 -11
- package/dist/commands/ai.js.map +1 -1
- package/dist/commands/commit.d.ts +1 -0
- package/dist/commands/commit.d.ts.map +1 -1
- package/dist/commands/commit.js +37 -34
- package/dist/commands/commit.js.map +1 -1
- package/dist/commands/completion.d.ts.map +1 -1
- package/dist/commands/completion.js +15 -5
- package/dist/commands/completion.js.map +1 -1
- package/dist/commands/config.d.ts +7 -2
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +37 -15
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/configCommand.d.ts +5 -1
- package/dist/commands/configCommand.d.ts.map +1 -1
- package/dist/commands/configCommand.js +30 -3
- package/dist/commands/configCommand.js.map +1 -1
- package/dist/commands/git.js +3 -3
- package/dist/commands/hookCommand.d.ts +14 -0
- package/dist/commands/hookCommand.d.ts.map +1 -0
- package/dist/commands/hookCommand.js +180 -0
- package/dist/commands/hookCommand.js.map +1 -0
- package/dist/commands/prCommand.d.ts.map +1 -1
- package/dist/commands/prCommand.js +3 -1
- package/dist/commands/prCommand.js.map +1 -1
- package/dist/commands/tag.d.ts.map +1 -1
- package/dist/commands/tag.js +9 -3
- package/dist/commands/tag.js.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/__tests__/ai.test.ts +486 -7
- package/src/__tests__/commitCommand.test.ts +111 -0
- package/src/__tests__/config.test.ts +24 -6
- package/src/__tests__/git.test.ts +421 -98
- package/src/__tests__/preCommit.test.ts +19 -0
- package/src/__tests__/tagCommand.test.ts +510 -17
- package/src/commands/ai.ts +128 -13
- package/src/commands/commit.ts +40 -34
- package/src/commands/completion.ts +15 -5
- package/src/commands/config.ts +46 -23
- package/src/commands/configCommand.ts +41 -8
- package/src/commands/git.ts +3 -3
- package/src/commands/hookCommand.ts +193 -0
- package/src/commands/prCommand.ts +3 -1
- package/src/commands/tag.ts +10 -4
- package/src/index.ts +3 -0
- 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
|
-
|
|
4
|
-
jest.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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('
|
|
36
|
-
|
|
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:
|
|
78
|
+
diff: 'diff --git a/a.ts b/a.ts\n+ok\n'
|
|
49
79
|
});
|
|
50
80
|
});
|
|
51
81
|
|
|
52
|
-
it('
|
|
53
|
-
(
|
|
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: '
|
|
100
|
+
error: 'Staged diff is too large to process. Try committing in smaller batches.'
|
|
63
101
|
});
|
|
64
102
|
});
|
|
65
103
|
|
|
66
|
-
it('
|
|
67
|
-
(
|
|
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: '
|
|
186
|
+
error: 'No differences found between main and feature.'
|
|
70
187
|
});
|
|
188
|
+
});
|
|
71
189
|
|
|
72
|
-
|
|
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: '
|
|
197
|
+
error: 'Diff is too large to process. Try narrowing the compare range.'
|
|
77
198
|
});
|
|
78
199
|
});
|
|
79
|
-
});
|
|
80
200
|
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
204
|
+
const result = await GitService.getBranchDiff('main', 'missing');
|
|
86
205
|
|
|
87
|
-
expect(result).
|
|
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('
|
|
91
|
-
(
|
|
212
|
+
it('returns raw command error when not mapped', async () => {
|
|
213
|
+
rejectExecFile('fatal: unexpected failure');
|
|
92
214
|
|
|
93
|
-
const result = await GitService.
|
|
215
|
+
const result = await GitService.getBranchDiff('main', 'feature');
|
|
94
216
|
|
|
95
|
-
expect(result).
|
|
217
|
+
expect(result).toEqual({ success: false, error: 'fatal: unexpected failure' });
|
|
96
218
|
});
|
|
97
219
|
});
|
|
98
220
|
|
|
99
|
-
describe('
|
|
100
|
-
it('
|
|
101
|
-
(
|
|
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
|
-
|
|
228
|
+
it('createCommit fails', async () => {
|
|
229
|
+
rejectExecFile('commit failed');
|
|
230
|
+
await expect(GitService.createCommit('feat: message')).resolves.toBe(false);
|
|
231
|
+
});
|
|
104
232
|
|
|
105
|
-
|
|
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('
|
|
109
|
-
(
|
|
239
|
+
it('push fails', async () => {
|
|
240
|
+
rejectExecFile('push failed');
|
|
241
|
+
await expect(GitService.push()).resolves.toBe(false);
|
|
242
|
+
});
|
|
110
243
|
|
|
111
|
-
|
|
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
|
-
|
|
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('
|
|
118
|
-
it('
|
|
119
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
127
|
-
(
|
|
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
|
-
|
|
290
|
+
it('getTagBefore returns earlier tag', async () => {
|
|
291
|
+
resolveExec('abc123\n');
|
|
292
|
+
resolveExec('v1.9.0\n');
|
|
130
293
|
|
|
131
|
-
expect(
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
(GitService.
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
145
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
154
|
-
it('
|
|
155
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
163
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
(GitService.
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
181
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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({
|