@ksw8954/git-ai-commit 1.1.5 → 1.1.7

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 (39) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/commands/ai.d.ts +9 -1
  3. package/dist/commands/ai.d.ts.map +1 -1
  4. package/dist/commands/ai.js +104 -46
  5. package/dist/commands/ai.js.map +1 -1
  6. package/dist/commands/commit.d.ts.map +1 -1
  7. package/dist/commands/commit.js +2 -0
  8. package/dist/commands/commit.js.map +1 -1
  9. package/dist/commands/completion.js +1 -1
  10. package/dist/commands/config.d.ts +6 -0
  11. package/dist/commands/config.d.ts.map +1 -1
  12. package/dist/commands/config.js +13 -0
  13. package/dist/commands/config.js.map +1 -1
  14. package/dist/commands/configCommand.d.ts +2 -0
  15. package/dist/commands/configCommand.d.ts.map +1 -1
  16. package/dist/commands/configCommand.js +16 -0
  17. package/dist/commands/configCommand.js.map +1 -1
  18. package/dist/commands/git.d.ts +2 -0
  19. package/dist/commands/git.d.ts.map +1 -1
  20. package/dist/commands/git.js +30 -0
  21. package/dist/commands/git.js.map +1 -1
  22. package/dist/commands/prCommand.d.ts.map +1 -1
  23. package/dist/commands/prCommand.js +2 -0
  24. package/dist/commands/prCommand.js.map +1 -1
  25. package/dist/commands/tag.d.ts +4 -0
  26. package/dist/commands/tag.d.ts.map +1 -1
  27. package/dist/commands/tag.js +128 -2
  28. package/dist/commands/tag.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/__tests__/ai.test.ts +45 -68
  31. package/src/__tests__/tagCommand.test.ts +9 -2
  32. package/src/commands/ai.ts +131 -53
  33. package/src/commands/commit.ts +2 -0
  34. package/src/commands/completion.ts +1 -1
  35. package/src/commands/config.ts +19 -0
  36. package/src/commands/configCommand.ts +22 -0
  37. package/src/commands/git.ts +35 -0
  38. package/src/commands/prCommand.ts +2 -0
  39. package/src/commands/tag.ts +157 -2
@@ -15,7 +15,10 @@ jest.mock('../commands/git', () => ({
15
15
  deleteLocalTag: jest.fn(),
16
16
  deleteRemoteTag: jest.fn(),
17
17
  forcePushTag: jest.fn(),
18
- getRemotes: jest.fn()
18
+ getRemotes: jest.fn(),
19
+ getTagMessage: jest.fn(),
20
+ getTagBefore: jest.fn(),
21
+ getRecentTags: jest.fn()
19
22
  }
20
23
  }));
21
24
 
@@ -55,6 +58,10 @@ describe('TagCommand', () => {
55
58
  (GitService.tagExists as jest.Mock).mockResolvedValue(false);
56
59
  (GitService.remoteTagExists as jest.Mock).mockResolvedValue(false);
57
60
 
61
+ (GitService.getTagMessage as jest.Mock).mockResolvedValue(null);
62
+ (GitService.getTagBefore as jest.Mock).mockResolvedValue({ success: false, error: 'No earlier tag found.' });
63
+ (GitService.getRecentTags as jest.Mock).mockResolvedValue([]);
64
+
58
65
  // Default: no remotes configured (skip push flow)
59
66
  (GitService.getRemotes as jest.Mock).mockResolvedValue([]);
60
67
 
@@ -120,7 +127,7 @@ describe('TagCommand', () => {
120
127
  model: 'gpt-test',
121
128
  language: 'ko'
122
129
  });
123
- expect(mockGenerateTagNotes).toHaveBeenCalledWith('v1.3.0', '- feat: add feature\n- fix: bug fix', undefined);
130
+ expect(mockGenerateTagNotes).toHaveBeenCalledWith('v1.3.0', '- feat: add feature\n- fix: bug fix', undefined, null, null);
124
131
  expect(GitService.createAnnotatedTag).toHaveBeenCalledWith('v1.3.0', '- Added feature\n- Fixed bug');
125
132
  expect(GitService.pushTag).not.toHaveBeenCalled();
126
133
  });
@@ -1,13 +1,18 @@
1
1
  import OpenAI from 'openai';
2
+ import { type ChatCompletionCreateParamsNonStreaming } from 'openai/resources/chat/completions';
2
3
  import { generateCommitPrompt } from '../prompts/commit';
3
4
  import { generateTagPrompt } from '../prompts/tag';
4
5
  import { generatePullRequestPrompt } from '../prompts/pr';
5
6
  import { SupportedLanguage } from './config';
6
7
 
8
+ export type ReasoningEffort = 'minimal' | 'low' | 'medium' | 'high';
9
+
7
10
  export interface AIServiceConfig {
8
11
  apiKey: string;
9
12
  baseURL?: string;
10
13
  model?: string;
14
+ fallbackModel?: string;
15
+ reasoningEffort?: ReasoningEffort;
11
16
  language?: SupportedLanguage;
12
17
  verbose?: boolean;
13
18
  }
@@ -33,6 +38,8 @@ export interface PullRequestGenerationResult {
33
38
  export class AIService {
34
39
  private openai: OpenAI;
35
40
  private model: string;
41
+ private fallbackModel?: string;
42
+ private reasoningEffort?: ReasoningEffort;
36
43
  private language: SupportedLanguage;
37
44
  private verbose: boolean;
38
45
 
@@ -42,6 +49,8 @@ export class AIService {
42
49
  baseURL: config.baseURL
43
50
  });
44
51
  this.model = config.model || 'zai-org/GLM-4.5-FP8';
52
+ this.fallbackModel = config.fallbackModel;
53
+ this.reasoningEffort = config.reasoningEffort;
45
54
  this.language = config.language || 'ko';
46
55
  this.verbose = config.verbose ?? true;
47
56
  }
@@ -52,6 +61,12 @@ export class AIService {
52
61
  }
53
62
  }
54
63
 
64
+ private isRateLimitError(error: unknown): boolean {
65
+ if (!error || typeof error !== 'object') return false;
66
+ const status = (error as { status?: number }).status;
67
+ return status === 429;
68
+ }
69
+
55
70
  private isUnsupportedTokenParamError(
56
71
  error: unknown,
57
72
  param: 'max_tokens' | 'max_completion_tokens'
@@ -134,29 +149,132 @@ export class AIService {
134
149
  return { ...rest };
135
150
  }
136
151
 
137
- private async createChatCompletion(
138
- request: OpenAI.ChatCompletionCreateParamsNonStreaming,
152
+ private readonly spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
153
+
154
+ private formatElapsed(ms: number): string {
155
+ const seconds = Math.floor(ms / 1000);
156
+ if (seconds < 1) return '0s';
157
+ return `${seconds}s`;
158
+ }
159
+
160
+ private async createStreamingCompletion(
161
+ request: ChatCompletionCreateParamsNonStreaming,
139
162
  attempt = 0
140
- ): Promise<OpenAI.ChatCompletion> {
163
+ ): Promise<string> {
164
+ let waitingTimer: ReturnType<typeof setInterval> | null = null;
165
+
141
166
  try {
142
- return await this.openai.chat.completions.create(request);
167
+ const startTime = Date.now();
168
+ let frameIndex = 0;
169
+
170
+ if (this.verbose) {
171
+ waitingTimer = setInterval(() => {
172
+ const frame = this.spinnerFrames[frameIndex++ % this.spinnerFrames.length];
173
+ const elapsed = this.formatElapsed(Date.now() - startTime);
174
+ process.stdout.write(`\r${frame} Waiting for response... (${elapsed})`);
175
+ }, 100);
176
+ }
177
+
178
+ const streamParams = {
179
+ ...request,
180
+ stream: true as const,
181
+ ...(this.reasoningEffort ? { reasoning_effort: this.reasoningEffort } : {})
182
+ };
183
+
184
+ const stream = await this.openai.chat.completions.create(streamParams);
185
+
186
+ const contentChunks: string[] = [];
187
+ let reasoningTokens = 0;
188
+ let contentTokens = 0;
189
+ let phase: 'waiting' | 'thinking' | 'content' = 'waiting';
190
+
191
+ for await (const chunk of stream) {
192
+ const delta = chunk.choices[0]?.delta;
193
+ const content = delta?.content;
194
+ const reasoning = (delta as Record<string, unknown>)?.reasoning_content as string | undefined;
195
+
196
+ if (reasoning) {
197
+ reasoningTokens++;
198
+
199
+ if (phase === 'waiting' && waitingTimer) {
200
+ clearInterval(waitingTimer);
201
+ waitingTimer = null;
202
+ phase = 'thinking';
203
+ }
204
+
205
+ if (this.verbose && phase === 'thinking') {
206
+ const frame = this.spinnerFrames[frameIndex++ % this.spinnerFrames.length];
207
+ const elapsed = this.formatElapsed(Date.now() - startTime);
208
+ process.stdout.write(`\r${frame} Thinking... (${reasoningTokens} tokens, ${elapsed})`);
209
+ }
210
+ }
211
+
212
+ if (content) {
213
+ contentChunks.push(content);
214
+ contentTokens++;
215
+
216
+ if (phase !== 'content') {
217
+ if (waitingTimer) {
218
+ clearInterval(waitingTimer);
219
+ waitingTimer = null;
220
+ }
221
+ phase = 'content';
222
+ }
223
+
224
+ if (this.verbose) {
225
+ const frame = this.spinnerFrames[frameIndex++ % this.spinnerFrames.length];
226
+ const elapsed = this.formatElapsed(Date.now() - startTime);
227
+ process.stdout.write(`\r${frame} Streaming response... (${contentTokens} tokens, ${elapsed})`);
228
+ }
229
+ }
230
+ }
231
+
232
+ if (waitingTimer) {
233
+ clearInterval(waitingTimer);
234
+ waitingTimer = null;
235
+ }
236
+
237
+ if (this.verbose) {
238
+ const totalTokens = reasoningTokens + contentTokens;
239
+ const elapsed = this.formatElapsed(Date.now() - startTime);
240
+ const detail = reasoningTokens > 0
241
+ ? `${totalTokens} tokens (thinking: ${reasoningTokens}, response: ${contentTokens}), ${elapsed}`
242
+ : `${contentTokens} tokens, ${elapsed}`;
243
+ process.stdout.write(`\r✅ Complete (${detail})\n`);
244
+ }
245
+
246
+ return contentChunks.join('');
143
247
  } catch (error) {
248
+ if (waitingTimer) {
249
+ clearInterval(waitingTimer);
250
+ waitingTimer = null;
251
+ }
252
+ if (this.verbose) {
253
+ process.stdout.write('\n');
254
+ }
255
+
144
256
  if (attempt < 3 && this.isUnsupportedValueError(error, 'temperature')) {
145
257
  const fallbackRequest = this.removeTemperature(request);
146
258
  this.debugLog('Retrying without temperature due to unsupported value error.');
147
- return await this.createChatCompletion(fallbackRequest, attempt + 1);
259
+ return await this.createStreamingCompletion(fallbackRequest, attempt + 1);
148
260
  }
149
261
 
150
262
  if (this.isUnsupportedTokenParamError(error, 'max_completion_tokens')) {
151
263
  const fallbackRequest = this.swapTokenParam(request, 'max_tokens');
152
264
  this.debugLog('Retrying with max_tokens due to unsupported max_completion_tokens error.');
153
- return await this.createChatCompletion(fallbackRequest, attempt + 1);
265
+ return await this.createStreamingCompletion(fallbackRequest, attempt + 1);
154
266
  }
155
267
 
156
268
  if (this.isUnsupportedTokenParamError(error, 'max_tokens')) {
157
269
  const fallbackRequest = this.swapTokenParam(request, 'max_completion_tokens');
158
270
  this.debugLog('Retrying with max_completion_tokens due to unsupported max_tokens error.');
159
- return await this.createChatCompletion(fallbackRequest, attempt + 1);
271
+ return await this.createStreamingCompletion(fallbackRequest, attempt + 1);
272
+ }
273
+
274
+ if (this.isRateLimitError(error) && this.fallbackModel && request.model !== this.fallbackModel) {
275
+ this.debugLog(`Rate limited (429). Retrying with fallback model: ${this.fallbackModel}`);
276
+ const fallbackRequest = { ...request, model: this.fallbackModel };
277
+ return await this.createStreamingCompletion(fallbackRequest, attempt + 1);
160
278
  }
161
279
 
162
280
  throw error;
@@ -205,7 +323,7 @@ export class AIService {
205
323
  ? `Git diff will be provided separately in the user message.\n\n## Additional User Instructions\n${extraInstructions.trim()}`
206
324
  : 'Git diff will be provided separately in the user message.';
207
325
 
208
- const response = await this.createChatCompletion({
326
+ const content = await this.createStreamingCompletion({
209
327
  model: this.model,
210
328
  messages: [
211
329
  {
@@ -224,36 +342,8 @@ export class AIService {
224
342
  max_completion_tokens: 3000
225
343
  });
226
344
 
227
- this.debugLog('API Response received:', JSON.stringify(response, null, 2));
345
+ let finalMessage = content.trim() || null;
228
346
 
229
- const choice = response.choices[0];
230
- const message = choice?.message?.content?.trim();
231
-
232
- // Handle reasoning content if available (type assertion for custom API response)
233
- const messageAny = choice?.message as any;
234
- const reasoningMessage = messageAny?.reasoning_content?.trim();
235
-
236
- // Try to extract commit message from reasoning content if regular content is null
237
- let finalMessage = message;
238
- if (!finalMessage && reasoningMessage) {
239
- // Look for commit message pattern in reasoning content
240
- const commitMatch = reasoningMessage.match(/(?:feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert): .+/);
241
- if (commitMatch) {
242
- finalMessage = commitMatch[0].trim();
243
- } else {
244
- // Look for any line that starts with conventional commit types
245
- const typeMatch = reasoningMessage.match(/(?:feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)[^:]*: .+/);
246
- if (typeMatch) {
247
- finalMessage = typeMatch[0].trim();
248
- } else {
249
- // Try to find a short descriptive line
250
- const lines = reasoningMessage.split('\n').filter((line: string) => line.trim().length > 0);
251
- const shortLine = lines.find((line: string) => line.length < 100 && line.includes('version'));
252
- finalMessage = shortLine ? `chore: ${shortLine.trim()}` : `chore: update files`;
253
- }
254
- }
255
- }
256
-
257
347
  if (!finalMessage) {
258
348
  this.debugLog('No message found in response');
259
349
  return {
@@ -381,7 +471,7 @@ export class AIService {
381
471
  userContent += `\n\n---\nPrevious release notes for this tag (improve upon this):\n${previousMessage}`;
382
472
  }
383
473
 
384
- const response = await this.createChatCompletion({
474
+ const content = await this.createStreamingCompletion({
385
475
  model: this.model,
386
476
  messages: [
387
477
  {
@@ -396,13 +486,7 @@ export class AIService {
396
486
  max_completion_tokens: 3000
397
487
  });
398
488
 
399
- const choice = response.choices[0];
400
- const message = choice?.message?.content?.trim();
401
-
402
- const messageAny = choice?.message as any;
403
- const reasoningMessage = messageAny?.reasoning_content?.trim();
404
-
405
- const finalNotes = message || reasoningMessage;
489
+ const finalNotes = content.trim() || null;
406
490
 
407
491
  if (!finalNotes) {
408
492
  this.debugLog('No notes found in response');
@@ -435,7 +519,7 @@ export class AIService {
435
519
  this.debugLog('Model:', this.model);
436
520
  this.debugLog('Base URL:', this.openai.baseURL);
437
521
 
438
- const response = await this.createChatCompletion({
522
+ const content = await this.createStreamingCompletion({
439
523
  model: this.model,
440
524
  messages: [
441
525
  {
@@ -455,13 +539,7 @@ export class AIService {
455
539
  max_completion_tokens: 4000
456
540
  });
457
541
 
458
- const choice = response.choices[0];
459
- const message = choice?.message?.content?.trim();
460
-
461
- const messageAny = choice?.message as any;
462
- const reasoningMessage = messageAny?.reasoning_content?.trim();
463
-
464
- const finalMessage = message || reasoningMessage;
542
+ const finalMessage = content.trim() || null;
465
543
 
466
544
  if (!finalMessage) {
467
545
  return {
@@ -161,6 +161,8 @@ export class CommitCommand {
161
161
  apiKey: mergedApiKey!,
162
162
  baseURL: mergedBaseURL,
163
163
  model: mergedModel,
164
+ fallbackModel: existingConfig.fallbackModel,
165
+ reasoningEffort: existingConfig.reasoningEffort,
164
166
  language: existingConfig.language,
165
167
  verbose: !messageOnly,
166
168
  };
@@ -40,7 +40,7 @@ _git_ai_commit() {
40
40
 
41
41
  # Command-specific options
42
42
  local commit_opts="-k --api-key -b --base-url --model -m --message-only -p --push --prompt --no-verify"
43
- local config_opts="-s --show -l --language --auto-push --no-auto-push -k --api-key -b --base-url -m --model --mode"
43
+ local config_opts="-s --show -l --language --auto-push --no-auto-push -k --api-key -b --base-url -m --model --fallback-model --reasoning-effort --mode"
44
44
  local pr_opts="--base --compare -k --api-key -b --base-url --model"
45
45
  local tag_opts="-k --api-key --base-url -m --model --message -t --base-tag --prompt"
46
46
  local history_opts="-l --limit --json --clear"
@@ -5,10 +5,14 @@ import path from 'path';
5
5
 
6
6
  export type SupportedLanguage = 'ko' | 'en';
7
7
 
8
+ export type ReasoningEffort = 'minimal' | 'low' | 'medium' | 'high';
9
+
8
10
  export interface EnvironmentConfig {
9
11
  apiKey?: string;
10
12
  baseURL?: string;
11
13
  model?: string;
14
+ fallbackModel?: string;
15
+ reasoningEffort?: ReasoningEffort;
12
16
  mode: 'custom' | 'openai';
13
17
  language: SupportedLanguage;
14
18
  autoPush: boolean;
@@ -18,6 +22,8 @@ interface StoredConfig {
18
22
  apiKey?: string;
19
23
  baseURL?: string;
20
24
  model?: string;
25
+ fallbackModel?: string;
26
+ reasoningEffort?: ReasoningEffort | string;
21
27
  mode?: 'custom' | 'openai';
22
28
  language?: SupportedLanguage | string;
23
29
  autoPush?: boolean;
@@ -68,6 +74,15 @@ export class ConfigService {
68
74
  return normalized === 'en' ? 'en' : 'ko';
69
75
  }
70
76
 
77
+ private static normalizeReasoningEffort(effort?: string): ReasoningEffort | undefined {
78
+ if (!effort) return undefined;
79
+ const normalized = effort.toLowerCase();
80
+ if (['minimal', 'low', 'medium', 'high'].includes(normalized)) {
81
+ return normalized as ReasoningEffort;
82
+ }
83
+ return undefined;
84
+ }
85
+
71
86
  private static normalizeMode(mode?: string): 'custom' | 'openai' {
72
87
  if (!mode) {
73
88
  return DEFAULT_MODE;
@@ -111,6 +126,8 @@ export class ConfigService {
111
126
  const apiKey = fileConfig.apiKey ?? envConfig.apiKey;
112
127
  const baseURL = fileConfig.baseURL ?? envConfig.baseURL;
113
128
  const model = fileConfig.model ?? envConfig.model ?? DEFAULT_MODEL;
129
+ const fallbackModel = fileConfig.fallbackModel;
130
+ const reasoningEffort = this.normalizeReasoningEffort(fileConfig.reasoningEffort);
114
131
  const language = this.normalizeLanguage(fileConfig.language ?? envConfig.language);
115
132
  const autoPush = typeof fileConfig.autoPush === 'boolean' ? fileConfig.autoPush : envConfig.autoPush;
116
133
 
@@ -118,6 +135,8 @@ export class ConfigService {
118
135
  apiKey,
119
136
  baseURL,
120
137
  model,
138
+ fallbackModel,
139
+ reasoningEffort,
121
140
  mode,
122
141
  language,
123
142
  autoPush
@@ -8,6 +8,8 @@ export interface ConfigOptions {
8
8
  apiKey?: string;
9
9
  baseUrl?: string;
10
10
  model?: string;
11
+ fallbackModel?: string;
12
+ reasoningEffort?: string;
11
13
  mode?: 'custom' | 'openai';
12
14
  }
13
15
 
@@ -24,6 +26,8 @@ export class ConfigCommand {
24
26
  .option('-k, --api-key <key>', 'Persist API key for AI requests (overrides environment variables)')
25
27
  .option('-b, --base-url <url>', 'Persist API base URL (overrides environment variables)')
26
28
  .option('-m, --model <model>', 'Persist default AI model')
29
+ .option('--fallback-model <model>', 'Persist fallback model for rate limit (429) retry')
30
+ .option('--reasoning-effort <level>', 'Thinking effort for reasoning models (minimal | low | medium | high)')
27
31
  .option('--mode <mode>', 'Persist AI mode (custom | openai)')
28
32
  .action(this.handleConfig.bind(this));
29
33
  }
@@ -62,6 +66,8 @@ export class ConfigCommand {
62
66
  apiKey?: string;
63
67
  baseURL?: string;
64
68
  model?: string;
69
+ fallbackModel?: string;
70
+ reasoningEffort?: string;
65
71
  mode?: 'custom' | 'openai';
66
72
  language?: SupportedLanguage;
67
73
  autoPush?: boolean;
@@ -87,6 +93,19 @@ export class ConfigCommand {
87
93
  updates.model = this.sanitizeStringValue(options.model);
88
94
  }
89
95
 
96
+ if (options.fallbackModel !== undefined) {
97
+ updates.fallbackModel = this.sanitizeStringValue(options.fallbackModel);
98
+ }
99
+
100
+ if (options.reasoningEffort !== undefined) {
101
+ const effort = options.reasoningEffort.toLowerCase();
102
+ if (!['minimal', 'low', 'medium', 'high'].includes(effort)) {
103
+ console.error('Reasoning effort must be one of: minimal, low, medium, high');
104
+ process.exit(1);
105
+ }
106
+ updates.reasoningEffort = effort;
107
+ }
108
+
90
109
  if (options.mode) {
91
110
  updates.mode = this.validateMode(options.mode);
92
111
  }
@@ -102,6 +121,7 @@ export class ConfigCommand {
102
121
  console.log(' git-ai-commit config -b https://api.test # Persist custom API base URL');
103
122
  console.log(' git-ai-commit config --mode openai # Use OpenAI-compatible environment defaults');
104
123
  console.log(' git-ai-commit config --model gpt-4o-mini # Persist preferred AI model');
124
+ console.log(' git-ai-commit config --fallback-model glm-4-flash # Fallback model for 429 retry');
105
125
  return;
106
126
  }
107
127
 
@@ -125,6 +145,8 @@ export class ConfigCommand {
125
145
  console.log(`API Key: ${config.apiKey ? '***' + config.apiKey.slice(-4) : 'Not set'}`);
126
146
  console.log(`Base URL: ${config.baseURL || 'Not set (using provider default)'}`);
127
147
  console.log(`Model: ${config.model || 'zai-org/GLM-4.5-FP8 (default)'}`);
148
+ console.log(`Fallback Model: ${config.fallbackModel || 'Not set'}`);
149
+ console.log(`Reasoning Effort: ${config.reasoningEffort || 'Not set (model default)'}`);
128
150
  console.log(`Mode: ${config.mode || 'custom (default)'}`);
129
151
  } catch (error) {
130
152
  console.error('Error reading configuration:', error instanceof Error ? error.message : error);
@@ -210,6 +210,41 @@ export class GitService {
210
210
  }
211
211
  }
212
212
 
213
+ static async getRecentTags(count = 10): Promise<string[]> {
214
+ try {
215
+ const { stdout } = await execAsync(
216
+ `git tag --sort=-creatordate | head -n ${count}`
217
+ );
218
+ return stdout
219
+ .split('\n')
220
+ .map(line => line.trim())
221
+ .filter(line => line.length > 0);
222
+ } catch {
223
+ return [];
224
+ }
225
+ }
226
+
227
+ static async getTagBefore(tagName: string): Promise<GitTagResult> {
228
+ try {
229
+ const { stdout: tagCommit } = await execAsync(`git rev-list -n 1 ${tagName}`);
230
+ const commit = tagCommit.trim();
231
+ if (!commit) {
232
+ return { success: false, error: 'Could not resolve tag to a commit.' };
233
+ }
234
+
235
+ const { stdout } = await execAsync(`git describe --tags --abbrev=0 ${commit}~1`);
236
+ const tag = stdout.trim();
237
+
238
+ if (!tag) {
239
+ return { success: false, error: 'No earlier tag found.' };
240
+ }
241
+
242
+ return { success: true, tag };
243
+ } catch {
244
+ return { success: false, error: 'No earlier tag found.' };
245
+ }
246
+ }
247
+
213
248
  static async getCommitSummariesSince(tag?: string): Promise<GitLogResult> {
214
249
  try {
215
250
  const logCommand = tag
@@ -61,6 +61,8 @@ export class PullRequestCommand {
61
61
  apiKey: mergedApiKey!,
62
62
  baseURL: mergedBaseURL,
63
63
  model: mergedModel,
64
+ fallbackModel: existingConfig.fallbackModel,
65
+ reasoningEffort: existingConfig.reasoningEffort,
64
66
  language: existingConfig.language,
65
67
  verbose: false
66
68
  });