@ksw8954/git-ai-commit 1.0.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 (77) hide show
  1. package/AGENTS.md +38 -0
  2. package/CRUSH.md +28 -0
  3. package/Makefile +32 -0
  4. package/README.md +145 -0
  5. package/dist/commands/ai.d.ts +35 -0
  6. package/dist/commands/ai.d.ts.map +1 -0
  7. package/dist/commands/ai.js +206 -0
  8. package/dist/commands/ai.js.map +1 -0
  9. package/dist/commands/commit.d.ts +17 -0
  10. package/dist/commands/commit.d.ts.map +1 -0
  11. package/dist/commands/commit.js +126 -0
  12. package/dist/commands/commit.js.map +1 -0
  13. package/dist/commands/config.d.ts +33 -0
  14. package/dist/commands/config.d.ts.map +1 -0
  15. package/dist/commands/config.js +141 -0
  16. package/dist/commands/config.js.map +1 -0
  17. package/dist/commands/configCommand.d.ts +20 -0
  18. package/dist/commands/configCommand.d.ts.map +1 -0
  19. package/dist/commands/configCommand.js +108 -0
  20. package/dist/commands/configCommand.js.map +1 -0
  21. package/dist/commands/git.d.ts +26 -0
  22. package/dist/commands/git.d.ts.map +1 -0
  23. package/dist/commands/git.js +150 -0
  24. package/dist/commands/git.js.map +1 -0
  25. package/dist/commands/loadEnv.d.ts +2 -0
  26. package/dist/commands/loadEnv.d.ts.map +1 -0
  27. package/dist/commands/loadEnv.js +11 -0
  28. package/dist/commands/loadEnv.js.map +1 -0
  29. package/dist/commands/prCommand.d.ts +16 -0
  30. package/dist/commands/prCommand.d.ts.map +1 -0
  31. package/dist/commands/prCommand.js +61 -0
  32. package/dist/commands/prCommand.js.map +1 -0
  33. package/dist/commands/tag.d.ts +17 -0
  34. package/dist/commands/tag.d.ts.map +1 -0
  35. package/dist/commands/tag.js +127 -0
  36. package/dist/commands/tag.js.map +1 -0
  37. package/dist/index.d.ts +3 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +23 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/prompts/commit.d.ts +3 -0
  42. package/dist/prompts/commit.d.ts.map +1 -0
  43. package/dist/prompts/commit.js +101 -0
  44. package/dist/prompts/commit.js.map +1 -0
  45. package/dist/prompts/pr.d.ts +3 -0
  46. package/dist/prompts/pr.d.ts.map +1 -0
  47. package/dist/prompts/pr.js +58 -0
  48. package/dist/prompts/pr.js.map +1 -0
  49. package/dist/prompts/tag.d.ts +3 -0
  50. package/dist/prompts/tag.d.ts.map +1 -0
  51. package/dist/prompts/tag.js +42 -0
  52. package/dist/prompts/tag.js.map +1 -0
  53. package/eslint.config.js +35 -0
  54. package/jest.config.js +16 -0
  55. package/package.json +51 -0
  56. package/src/__tests__/ai.test.ts +185 -0
  57. package/src/__tests__/commitCommand.test.ts +155 -0
  58. package/src/__tests__/config.test.ts +238 -0
  59. package/src/__tests__/git.test.ts +88 -0
  60. package/src/__tests__/integration.test.ts +138 -0
  61. package/src/__tests__/prCommand.test.ts +121 -0
  62. package/src/__tests__/tagCommand.test.ts +197 -0
  63. package/src/commands/ai.ts +266 -0
  64. package/src/commands/commit.ts +215 -0
  65. package/src/commands/config.ts +182 -0
  66. package/src/commands/configCommand.ts +139 -0
  67. package/src/commands/git.ts +174 -0
  68. package/src/commands/history.ts +82 -0
  69. package/src/commands/loadEnv.ts +5 -0
  70. package/src/commands/log.ts +71 -0
  71. package/src/commands/prCommand.ts +108 -0
  72. package/src/commands/tag.ts +230 -0
  73. package/src/index.ts +29 -0
  74. package/src/prompts/commit.ts +105 -0
  75. package/src/prompts/pr.ts +64 -0
  76. package/src/prompts/tag.ts +48 -0
  77. package/tsconfig.json +19 -0
@@ -0,0 +1,185 @@
1
+ import { AIService } from '../commands/ai';
2
+ import { generateCommitPrompt } from '../prompts/commit';
3
+ import { generatePullRequestPrompt } from '../prompts/pr';
4
+ import OpenAI from 'openai';
5
+
6
+ jest.mock('openai');
7
+ const MockedOpenAI = OpenAI as jest.MockedClass<typeof OpenAI>;
8
+
9
+ describe('AIService', () => {
10
+ let aiService: AIService;
11
+ let mockOpenai: jest.Mocked<OpenAI>;
12
+
13
+ beforeEach(() => {
14
+ jest.clearAllMocks();
15
+
16
+ mockOpenai = {
17
+ chat: {
18
+ completions: {
19
+ create: jest.fn()
20
+ }
21
+ }
22
+ } as any;
23
+
24
+ MockedOpenAI.mockImplementation(() => mockOpenai);
25
+
26
+ aiService = new AIService({
27
+ apiKey: 'test-api-key',
28
+ baseURL: 'https://api.test.com',
29
+ model: 'gpt-4'
30
+ });
31
+ });
32
+
33
+ describe('generateCommitMessage', () => {
34
+ it('should return success with commit message when API call succeeds', async () => {
35
+ const mockResponse = {
36
+ choices: [{
37
+ message: {
38
+ content: 'feat: add new feature'
39
+ }
40
+ }]
41
+ };
42
+
43
+ (mockOpenai.chat.completions.create as jest.Mock).mockResolvedValue(mockResponse);
44
+
45
+ const diff = 'diff --git a/file.txt b/file.txt\nnew file mode 100644';
46
+ const result = await aiService.generateCommitMessage(diff);
47
+
48
+ expect(result).toEqual({
49
+ success: true,
50
+ message: 'feat: add new feature'
51
+ });
52
+
53
+ expect(mockOpenai.chat.completions.create).toHaveBeenCalledWith({
54
+ model: 'gpt-4',
55
+ messages: [
56
+ {
57
+ role: 'system',
58
+ content: generateCommitPrompt(
59
+ '',
60
+ 'Git diff will be provided separately in the user message.',
61
+ 'ko'
62
+ )
63
+ },
64
+ {
65
+ role: 'user',
66
+ content: `Git diff:\n${diff}`
67
+ }
68
+ ],
69
+ max_tokens: 3000,
70
+ temperature: 0.1
71
+ });
72
+ });
73
+
74
+ it('should return error when API returns no message', async () => {
75
+ const mockResponse = {
76
+ choices: [{
77
+ message: {
78
+ content: null
79
+ }
80
+ }]
81
+ };
82
+
83
+ (mockOpenai.chat.completions.create as jest.Mock).mockResolvedValue(mockResponse);
84
+
85
+ const diff = 'diff --git a/file.txt b/file.txt\nnew file mode 100644';
86
+ const result = await aiService.generateCommitMessage(diff);
87
+
88
+ expect(result).toEqual({
89
+ success: false,
90
+ error: 'No commit message generated'
91
+ });
92
+ });
93
+
94
+ it('should return error when API call fails', async () => {
95
+ const error = new Error('API call failed');
96
+ (mockOpenai.chat.completions.create as jest.Mock).mockRejectedValue(error);
97
+
98
+ const diff = 'diff --git a/file.txt b/file.txt\nnew file mode 100644';
99
+ const result = await aiService.generateCommitMessage(diff);
100
+
101
+ expect(result).toEqual({
102
+ success: false,
103
+ error: 'API call failed'
104
+ });
105
+ });
106
+
107
+ it('should use default model when not specified', () => {
108
+ const aiServiceWithDefaults = new AIService({
109
+ apiKey: 'test-api-key'
110
+ });
111
+
112
+ expect((aiServiceWithDefaults as any).model).toBe('zai-org/GLM-4.5-FP8');
113
+ });
114
+ });
115
+
116
+ describe('generatePullRequestMessage', () => {
117
+ it('should return success with PR message when API call succeeds', async () => {
118
+ const mockResponse = {
119
+ choices: [{
120
+ message: {
121
+ content: 'Add caching layer\n\n## Summary\n- cache expensive queries to reduce latency\n\n## Testing\n- npm run test'
122
+ }
123
+ }]
124
+ };
125
+
126
+ (mockOpenai.chat.completions.create as jest.Mock).mockResolvedValue(mockResponse);
127
+
128
+ const diff = 'diff --git a/app.ts b/app.ts\nindex 123..456 100644';
129
+ const result = await aiService.generatePullRequestMessage('main', 'feature/cache', diff);
130
+
131
+ expect(result).toEqual({
132
+ success: true,
133
+ message: mockResponse.choices[0].message.content
134
+ });
135
+
136
+ expect(mockOpenai.chat.completions.create).toHaveBeenCalledWith({
137
+ model: 'gpt-4',
138
+ messages: [
139
+ {
140
+ role: 'system',
141
+ content: generatePullRequestPrompt('main', 'feature/cache', '', 'ko')
142
+ },
143
+ {
144
+ role: 'user',
145
+ content: `Git diff between main and feature/cache:\n${diff}`
146
+ }
147
+ ],
148
+ max_tokens: 4000,
149
+ temperature: 0.2
150
+ });
151
+ });
152
+
153
+ it('should return error when API returns no content', async () => {
154
+ const mockResponse = {
155
+ choices: [{
156
+ message: {
157
+ content: '',
158
+ reasoning_content: ''
159
+ }
160
+ }]
161
+ };
162
+
163
+ (mockOpenai.chat.completions.create as jest.Mock).mockResolvedValue(mockResponse);
164
+
165
+ const result = await aiService.generatePullRequestMessage('main', 'feature/cache', 'diff');
166
+
167
+ expect(result).toEqual({
168
+ success: false,
169
+ error: 'No pull request message generated'
170
+ });
171
+ });
172
+
173
+ it('should return error when API call fails', async () => {
174
+ const error = new Error('API failure');
175
+ (mockOpenai.chat.completions.create as jest.Mock).mockRejectedValue(error);
176
+
177
+ const result = await aiService.generatePullRequestMessage('main', 'feature/cache', 'diff');
178
+
179
+ expect(result).toEqual({
180
+ success: false,
181
+ error: 'API failure'
182
+ });
183
+ });
184
+ });
185
+ });
@@ -0,0 +1,155 @@
1
+ import { CommitCommand } from '../commands/commit';
2
+ import { GitService } from '../commands/git';
3
+ import { AIService } from '../commands/ai';
4
+ import { ConfigService } from '../commands/config';
5
+
6
+ const mockGenerateCommitMessage = jest.fn();
7
+
8
+ jest.mock('../commands/git', () => ({
9
+ GitService: {
10
+ getStagedDiff: jest.fn(),
11
+ createCommit: jest.fn(),
12
+ push: jest.fn()
13
+ }
14
+ }));
15
+
16
+ jest.mock('../commands/ai', () => ({
17
+ AIService: jest.fn().mockImplementation(() => ({
18
+ generateCommitMessage: mockGenerateCommitMessage
19
+ }))
20
+ }));
21
+
22
+ jest.mock('../commands/config', () => ({
23
+ ConfigService: {
24
+ getConfig: jest.fn(),
25
+ validateConfig: jest.fn()
26
+ }
27
+ }));
28
+
29
+ describe('CommitCommand', () => {
30
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as any);
31
+
32
+ beforeEach(() => {
33
+ jest.clearAllMocks();
34
+
35
+ (ConfigService.getConfig as jest.Mock).mockReturnValue({
36
+ apiKey: 'env-key',
37
+ baseURL: 'https://api.test',
38
+ model: 'test-model',
39
+ language: 'ko',
40
+ autoPush: false
41
+ });
42
+
43
+ (ConfigService.validateConfig as jest.Mock).mockReturnValue(undefined);
44
+
45
+ (GitService.getStagedDiff as jest.Mock).mockResolvedValue({
46
+ success: true,
47
+ diff: 'diff --git a/file b/file'
48
+ });
49
+
50
+ mockGenerateCommitMessage.mockResolvedValue({
51
+ success: true,
52
+ message: 'feat: test commit'
53
+ });
54
+ });
55
+
56
+ afterAll(() => {
57
+ exitSpy.mockRestore();
58
+ });
59
+
60
+ const createCommand = () => new CommitCommand();
61
+
62
+ it('should create commit after user confirmation', async () => {
63
+ (GitService.createCommit as jest.Mock).mockResolvedValue(true);
64
+
65
+ const command = createCommand();
66
+ const confirmSpy = jest
67
+ .spyOn(command as any, 'confirmCommit')
68
+ .mockResolvedValue(true);
69
+
70
+ await (command as any).handleCommit({});
71
+
72
+ expect(confirmSpy).toHaveBeenCalled();
73
+ expect(GitService.createCommit).toHaveBeenCalledWith('feat: test commit');
74
+ expect(GitService.push).not.toHaveBeenCalled();
75
+ expect(exitSpy).not.toHaveBeenCalled();
76
+ });
77
+
78
+ it('should skip commit when user declines confirmation', async () => {
79
+ const command = createCommand();
80
+ jest.spyOn(command as any, 'confirmCommit').mockResolvedValue(false);
81
+
82
+ await (command as any).handleCommit({});
83
+
84
+ expect(GitService.createCommit).not.toHaveBeenCalled();
85
+ expect(GitService.push).not.toHaveBeenCalled();
86
+ expect(exitSpy).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it('should output message only without git actions when message-only option is set', async () => {
90
+ const command = createCommand();
91
+ const confirmSpy = jest.spyOn(command as any, 'confirmCommit');
92
+ const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
93
+
94
+ try {
95
+ await (command as any).handleCommit({ messageOnly: true });
96
+
97
+ expect(confirmSpy).not.toHaveBeenCalled();
98
+ expect(GitService.createCommit).not.toHaveBeenCalled();
99
+ expect(GitService.push).not.toHaveBeenCalled();
100
+ expect(logSpy).toHaveBeenCalledTimes(1);
101
+ expect(logSpy).toHaveBeenCalledWith('feat: test commit');
102
+ expect(exitSpy).not.toHaveBeenCalled();
103
+ } finally {
104
+ logSpy.mockRestore();
105
+ }
106
+ });
107
+
108
+ it('should commit and push when push option is provided', async () => {
109
+ (GitService.createCommit as jest.Mock).mockResolvedValue(true);
110
+ (GitService.push as jest.Mock).mockResolvedValue(true);
111
+
112
+ const command = createCommand();
113
+ jest.spyOn(command as any, 'confirmCommit').mockResolvedValue(true);
114
+
115
+ await (command as any).handleCommit({ push: true });
116
+
117
+ expect(GitService.createCommit).toHaveBeenCalledWith('feat: test commit');
118
+ expect(GitService.push).toHaveBeenCalled();
119
+ expect(exitSpy).not.toHaveBeenCalled();
120
+ });
121
+
122
+ it('should exit when commit creation fails', async () => {
123
+ (GitService.createCommit as jest.Mock).mockResolvedValue(false);
124
+
125
+ const command = createCommand();
126
+ jest.spyOn(command as any, 'confirmCommit').mockResolvedValue(true);
127
+
128
+ await (command as any).handleCommit({});
129
+
130
+ expect(GitService.createCommit).toHaveBeenCalledWith('feat: test commit');
131
+ expect(exitSpy).toHaveBeenCalledWith(1);
132
+ });
133
+
134
+ it('should push automatically when autoPush is enabled in config', async () => {
135
+ (GitService.createCommit as jest.Mock).mockResolvedValue(true);
136
+ (GitService.push as jest.Mock).mockResolvedValue(true);
137
+
138
+ (ConfigService.getConfig as jest.Mock).mockReturnValueOnce({
139
+ apiKey: 'env-key',
140
+ baseURL: 'https://api.test',
141
+ model: 'test-model',
142
+ language: 'ko',
143
+ autoPush: true
144
+ });
145
+
146
+ const command = createCommand();
147
+ jest.spyOn(command as any, 'confirmCommit').mockResolvedValue(true);
148
+
149
+ await (command as any).handleCommit({});
150
+
151
+ expect(GitService.createCommit).toHaveBeenCalledWith('feat: test commit');
152
+ expect(GitService.push).toHaveBeenCalledTimes(1);
153
+ expect(exitSpy).not.toHaveBeenCalled();
154
+ });
155
+ });
@@ -0,0 +1,238 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { ConfigService } from '../commands/config';
5
+
6
+ describe('ConfigService', () => {
7
+ const originalEnv = process.env;
8
+ let tempDir: string;
9
+ let configPath: string;
10
+
11
+ beforeEach(() => {
12
+ jest.resetModules();
13
+ process.env = {
14
+ PATH: originalEnv.PATH,
15
+ NODE_ENV: 'test'
16
+ } as NodeJS.ProcessEnv;
17
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-ai-config-'));
18
+ configPath = path.join(tempDir, 'config.json');
19
+ process.env.GIT_AI_COMMIT_CONFIG_PATH = configPath;
20
+ if (fs.existsSync(configPath)) {
21
+ fs.rmSync(configPath, { force: true });
22
+ }
23
+ });
24
+
25
+ afterEach(() => {
26
+ if (fs.existsSync(configPath)) {
27
+ fs.rmSync(configPath, { force: true });
28
+ }
29
+ fs.rmSync(tempDir, { recursive: true, force: true });
30
+ process.env = originalEnv;
31
+ });
32
+
33
+ describe('getConfig', () => {
34
+
35
+ it('uses OPENAI_API_KEY values when present', () => {
36
+ process.env.OPENAI_API_KEY = 'test-api-key';
37
+ process.env.OPENAI_BASE_URL = 'https://api.test.com';
38
+ process.env.OPENAI_MODEL = 'gpt-4';
39
+
40
+ const config = ConfigService.getConfig();
41
+
42
+ expect(config).toEqual({
43
+ apiKey: 'test-api-key',
44
+ baseURL: 'https://api.test.com',
45
+ model: 'gpt-4',
46
+ mode: 'custom',
47
+ language: 'ko',
48
+ autoPush: false
49
+ });
50
+ });
51
+
52
+ it('falls back to AI_API_KEY when others are missing', () => {
53
+ process.env.AI_API_KEY = 'fallback-api-key';
54
+ process.env.AI_BASE_URL = 'https://fallback.test.com';
55
+ process.env.AI_MODEL = 'claude-3';
56
+
57
+ const config = ConfigService.getConfig();
58
+
59
+ expect(config).toEqual({
60
+ apiKey: 'fallback-api-key',
61
+ baseURL: 'https://fallback.test.com',
62
+ model: 'claude-3',
63
+ mode: 'custom',
64
+ language: 'ko',
65
+ autoPush: false
66
+ });
67
+ });
68
+
69
+ it('prefers AI_* variables over others in custom mode', () => {
70
+ process.env.AI_API_KEY = 'ai-key';
71
+ process.env.OPENAI_API_KEY = 'openai-key';
72
+ process.env.AI_BASE_URL = 'ai-url';
73
+ process.env.OPENAI_BASE_URL = 'openai-url';
74
+
75
+ const config = ConfigService.getConfig();
76
+
77
+ expect(config).toEqual({
78
+ apiKey: 'ai-key',
79
+ baseURL: 'ai-url',
80
+ model: 'zai-org/GLM-4.5-FP8',
81
+ mode: 'custom',
82
+ language: 'ko',
83
+ autoPush: false
84
+ });
85
+ });
86
+
87
+ it('uses OPENAI_* variables when mode is openai', () => {
88
+ process.env.AI_MODE = 'openai';
89
+ process.env.OPENAI_API_KEY = 'openai-key';
90
+ process.env.AI_API_KEY = 'ai-key';
91
+ process.env.OPENAI_BASE_URL = 'openai-url';
92
+ process.env.AI_BASE_URL = 'ai-url';
93
+ process.env.OPENAI_MODEL = 'gpt-4';
94
+ process.env.AI_MODEL = 'claude-3';
95
+
96
+ const config = ConfigService.getConfig();
97
+
98
+ expect(config).toEqual({
99
+ apiKey: 'openai-key',
100
+ baseURL: 'openai-url',
101
+ model: 'gpt-4',
102
+ mode: 'openai',
103
+ language: 'ko',
104
+ autoPush: false
105
+ });
106
+ });
107
+
108
+ it('falls back to AI_* variables in openai mode when OPENAI_* missing', () => {
109
+ process.env.AI_MODE = 'openai';
110
+ process.env.AI_API_KEY = 'ai-key';
111
+ process.env.AI_BASE_URL = 'ai-url';
112
+ process.env.AI_MODEL = 'claude-3';
113
+
114
+ const config = ConfigService.getConfig();
115
+
116
+ expect(config).toEqual({
117
+ apiKey: 'ai-key',
118
+ baseURL: 'ai-url',
119
+ model: 'claude-3',
120
+ mode: 'openai',
121
+ language: 'ko',
122
+ autoPush: false
123
+ });
124
+ });
125
+
126
+ it('uses default model when none provided', () => {
127
+ process.env.OPENAI_API_KEY = 'test-key';
128
+ delete process.env.OPENAI_MODEL;
129
+ delete process.env.AI_MODEL;
130
+
131
+ const config = ConfigService.getConfig();
132
+
133
+ expect(config.model).toBe('zai-org/GLM-4.5-FP8');
134
+ expect(config.mode).toBe('custom');
135
+ });
136
+
137
+ it('allows configuration file to override environment values', async () => {
138
+ process.env.AI_API_KEY = 'env-key';
139
+ process.env.AI_BASE_URL = 'https://env.example';
140
+ process.env.AI_MODEL = 'env-model';
141
+
142
+ await ConfigService.updateConfig({
143
+ apiKey: 'file-key',
144
+ baseURL: 'https://file.example',
145
+ model: 'file-model',
146
+ language: 'en',
147
+ autoPush: true,
148
+ mode: 'openai'
149
+ });
150
+
151
+ const config = ConfigService.getConfig();
152
+
153
+ expect(config).toEqual({
154
+ apiKey: 'file-key',
155
+ baseURL: 'https://file.example',
156
+ model: 'file-model',
157
+ mode: 'openai',
158
+ language: 'en',
159
+ autoPush: true
160
+ });
161
+ });
162
+
163
+ it('persists mode and model updates from config file', async () => {
164
+ await ConfigService.updateConfig({
165
+ apiKey: 'file-key',
166
+ baseURL: 'https://file.example',
167
+ model: 'gpt-4o-mini',
168
+ mode: 'openai'
169
+ });
170
+
171
+ const config = ConfigService.getConfig();
172
+
173
+ expect(config.model).toBe('gpt-4o-mini');
174
+ expect(config.mode).toBe('openai');
175
+ });
176
+
177
+ it('normalizes invalid mode values to the default', async () => {
178
+ const filePath = process.env.GIT_AI_COMMIT_CONFIG_PATH!;
179
+ fs.writeFileSync(filePath, JSON.stringify({ mode: 'invalid-mode' }));
180
+
181
+ const config = ConfigService.getConfig();
182
+
183
+ expect(config.mode).toBe('custom');
184
+ });
185
+
186
+ it('removes stored mode when resetting to the default', async () => {
187
+ await ConfigService.updateConfig({ mode: 'openai' });
188
+
189
+ let raw = fs.readFileSync(process.env.GIT_AI_COMMIT_CONFIG_PATH!, 'utf-8');
190
+ let parsed = JSON.parse(raw);
191
+ expect(parsed.mode).toBe('openai');
192
+
193
+ await ConfigService.updateConfig({ mode: 'custom' });
194
+
195
+ raw = fs.readFileSync(process.env.GIT_AI_COMMIT_CONFIG_PATH!, 'utf-8');
196
+ parsed = JSON.parse(raw);
197
+ expect(parsed.mode).toBeUndefined();
198
+ });
199
+ });
200
+
201
+ describe('validateConfig', () => {
202
+ it('does not throw for valid configuration', () => {
203
+ const config = {
204
+ apiKey: 'test-key',
205
+ language: 'ko'
206
+ };
207
+
208
+ expect(() => ConfigService.validateConfig(config)).not.toThrow();
209
+ });
210
+
211
+ it('throws when API key is missing', () => {
212
+ const config = {
213
+ apiKey: '',
214
+ language: 'en'
215
+ };
216
+
217
+ expect(() => ConfigService.validateConfig(config)).toThrow('API key is required');
218
+ });
219
+
220
+ it('throws when language is invalid', () => {
221
+ const config = {
222
+ apiKey: 'test',
223
+ language: 'jp'
224
+ } as any;
225
+
226
+ expect(() => ConfigService.validateConfig(config)).toThrow('Unsupported language. Use "ko" or "en".');
227
+ });
228
+
229
+ it('throws when merged configuration lacks API key', () => {
230
+ delete process.env.OPENAI_API_KEY;
231
+ delete process.env.AI_API_KEY;
232
+
233
+ const config = ConfigService.getConfig();
234
+
235
+ expect(() => ConfigService.validateConfig(config)).toThrow('API key is required');
236
+ });
237
+ });
238
+ });
@@ -0,0 +1,88 @@
1
+ import { GitService } from '../commands/git';
2
+
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'
8
+ });
9
+ const createCommit = jest.fn().mockResolvedValue(true);
10
+
11
+ return {
12
+ GitService: {
13
+ getStagedDiff,
14
+ createCommit
15
+ }
16
+ };
17
+ });
18
+
19
+ describe('GitService', () => {
20
+ beforeEach(() => {
21
+ jest.clearAllMocks();
22
+ });
23
+
24
+ describe('getStagedDiff', () => {
25
+ it('should return success with diff when staged changes exist', async () => {
26
+ const mockDiff = 'diff --git a/file.txt b/file.txt\nnew file mode 100644';
27
+
28
+ // Mock the implementation for this specific test
29
+ (GitService.getStagedDiff as jest.Mock).mockResolvedValueOnce({
30
+ success: true,
31
+ diff: mockDiff
32
+ });
33
+
34
+ const result = await GitService.getStagedDiff();
35
+
36
+ expect(result).toEqual({
37
+ success: true,
38
+ diff: mockDiff
39
+ });
40
+ });
41
+
42
+ it('should return error when no staged changes', async () => {
43
+ (GitService.getStagedDiff as jest.Mock).mockResolvedValueOnce({
44
+ success: false,
45
+ error: 'No staged changes found. Please stage your changes first.'
46
+ });
47
+
48
+ const result = await GitService.getStagedDiff();
49
+
50
+ expect(result).toEqual({
51
+ success: false,
52
+ error: 'No staged changes found. Please stage your changes first.'
53
+ });
54
+ });
55
+
56
+ it('should return error when git command fails', async () => {
57
+ (GitService.getStagedDiff as jest.Mock).mockResolvedValueOnce({
58
+ success: false,
59
+ error: 'Git command failed'
60
+ });
61
+
62
+ const result = await GitService.getStagedDiff();
63
+
64
+ expect(result).toEqual({
65
+ success: false,
66
+ error: 'Git command failed'
67
+ });
68
+ });
69
+ });
70
+
71
+ describe('createCommit', () => {
72
+ it('should return true when commit is successful', async () => {
73
+ (GitService.createCommit as jest.Mock).mockResolvedValueOnce(true);
74
+
75
+ const result = await GitService.createCommit('feat: add new feature');
76
+
77
+ expect(result).toBe(true);
78
+ });
79
+
80
+ it('should return false when commit fails', async () => {
81
+ (GitService.createCommit as jest.Mock).mockResolvedValueOnce(false);
82
+
83
+ const result = await GitService.createCommit('feat: add new feature');
84
+
85
+ expect(result).toBe(false);
86
+ });
87
+ });
88
+ });