commitgpt 0.3.9 → 0.3.11
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.
- checksums.yaml +4 -4
- data/README.md +45 -5
- data/lib/commitgpt/cli.rb +5 -0
- data/lib/commitgpt/commit_ai.rb +122 -37
- data/lib/commitgpt/config_manager.rb +22 -0
- data/lib/commitgpt/provider_presets.rb +10 -1
- data/lib/commitgpt/setup_wizard.rb +100 -31
- data/lib/commitgpt/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b8a2b39ee34a81a322ba56a693b002e1a8b29bc6adc432b4f41992c71ff3bd4a
|
|
4
|
+
data.tar.gz: cd4289fee106ee2feb1e89c71b29889b6fe0232c542452588fd0564b4b49593f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 77b3f3fe14333b001537c52482b3424f2b5c679434b7ea52bb06e001e64cd5cc0f0c61782ead4d8416bf01473a7099398ba2d4eea84c268f84a14e6a62e5448b
|
|
7
|
+
data.tar.gz: 073de1a61a918be54e7ca2f2bb750b094aa1f0e1d56e195ad6239a8e4eb0431570ff4dfaedd22255e843d8c994df8c248d74f7cb1d306c6644e145e064c306ca
|
data/README.md
CHANGED
|
@@ -17,9 +17,12 @@ The easiest way to install and keep CommitGPT updated.
|
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
19
|
brew tap ZPVIP/commitgpt https://github.com/ZPVIP/commitgpt
|
|
20
|
+
brew trust zpvip/commitgpt
|
|
20
21
|
brew install commitgpt
|
|
21
22
|
```
|
|
22
23
|
|
|
24
|
+
> **Note**: Recent Homebrew versions refuse to load formulae from third-party taps until you trust them, so `brew trust` is required before installing.
|
|
25
|
+
|
|
23
26
|
**Upgrade:**
|
|
24
27
|
|
|
25
28
|
```bash
|
|
@@ -194,8 +197,41 @@ docs: update API documentation
|
|
|
194
197
|
📝 update API documentation
|
|
195
198
|
```
|
|
196
199
|
|
|
200
|
+
### Choose Commit Message Detail Level
|
|
201
|
+
Select how much the commit message says:
|
|
202
|
+
```bash
|
|
203
|
+
$ aicm -d
|
|
204
|
+
# or
|
|
205
|
+
$ aicm --detail
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
- **Concise** - Subject line only (default)
|
|
209
|
+
- **Detailed** - Subject line, an explanatory paragraph, and a bullet list of notable changes
|
|
210
|
+
|
|
211
|
+
This is independent of the format above: the format decides how the subject line is written, the detail level decides how much follows it. Your selection is saved as `commit_detail` in `~/.config/commitgpt/config.yml`.
|
|
212
|
+
|
|
213
|
+
#### Detail Examples
|
|
214
|
+
|
|
215
|
+
**Concise:**
|
|
216
|
+
```
|
|
217
|
+
feat(dfb): store and expand DFB event and speed logs
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
**Detailed:**
|
|
221
|
+
```
|
|
222
|
+
feat(dfb): store and expand DFB event and speed logs
|
|
223
|
+
|
|
224
|
+
Persist DFB device logs ingested from the proactive `evt` (event log) and
|
|
225
|
+
`lge` (speed stats) topics, one record per message.
|
|
226
|
+
|
|
227
|
+
- Add `dfb_event_logs` and `dfb_speed_stats` tables and models, each with an
|
|
228
|
+
idempotent unique index so re-delivery and overlapping backfills are safe.
|
|
229
|
+
- Associate both log tables on `Product`.
|
|
230
|
+
- Update `README_DFB.md` to document the cloud tables.
|
|
231
|
+
```
|
|
232
|
+
|
|
197
233
|
### Check Configuration
|
|
198
|
-
View your current configuration (Provider, Model, Format, Base URL, Diff Len):
|
|
234
|
+
View your current configuration (Provider, Model, Format, Detail, Base URL, Diff Len):
|
|
199
235
|
```bash
|
|
200
236
|
$ aicm help
|
|
201
237
|
```
|
|
@@ -261,13 +297,17 @@ Get an API key at [https://developer.amd.com.cn/radeon/tokenfactory](https://dev
|
|
|
261
297
|
|
|
262
298
|
> **Note**: Registration requires **both an email address and a Chinese (+86) mobile phone number**. Without a Chinese phone number you cannot complete sign-up, so this provider is only usable if you have one.
|
|
263
299
|
|
|
300
|
+
> **Note**: The gateway in front of this endpoint always answers `GET /models` with 403, so models cannot be discovered automatically. CommitGPT ships the list below and offers it directly during setup — no detection request is made for this provider.
|
|
301
|
+
|
|
264
302
|
```
|
|
265
|
-
DeepSeek-V4-Flash
|
|
266
|
-
Qwen3.6-35B-A3B
|
|
267
|
-
|
|
268
|
-
|
|
303
|
+
DeepSeek-V4-Flash # Max Context Length: 1,000,000 tokens
|
|
304
|
+
Qwen3.6-35B-A3B # Max Context Length: 262,144 tokens
|
|
305
|
+
MiniCPM-V46 # Max Context Length: 262,144 tokens
|
|
306
|
+
MiniCPM5-1B # Max Context Length: 131,072 tokens
|
|
269
307
|
```
|
|
270
308
|
|
|
309
|
+
Setup reports the selected model's context window and suggests a matching `diff_len`, so you don't have to work the number out yourself.
|
|
310
|
+
|
|
271
311
|
## How It Works
|
|
272
312
|
This CLI tool runs a `git diff` command to grab all staged changes, sends this to OpenAI's GPT API (or compatible endpoint), and returns an AI-generated commit message. The tool uses the `/v1/chat/completions` endpoint with optimized prompts/system instructions for generating conventional commit messages.
|
|
273
313
|
|
data/lib/commitgpt/cli.rb
CHANGED
|
@@ -14,6 +14,7 @@ module CommitGpt
|
|
|
14
14
|
method_option :verbose, aliases: '-v', type: :boolean, desc: 'Show git diff being sent to AI'
|
|
15
15
|
method_option :provider, aliases: '-p', type: :boolean, desc: 'Switch active provider'
|
|
16
16
|
method_option :format, aliases: '-f', type: :boolean, desc: 'Choose commit message format'
|
|
17
|
+
method_option :detail, aliases: '-d', type: :boolean, desc: 'Choose commit message detail level'
|
|
17
18
|
def generate
|
|
18
19
|
if options[:provider]
|
|
19
20
|
CommitGpt::SetupWizard.new.switch_provider
|
|
@@ -21,6 +22,8 @@ module CommitGpt
|
|
|
21
22
|
CommitGpt::SetupWizard.new.change_model
|
|
22
23
|
elsif options[:format]
|
|
23
24
|
CommitGpt::SetupWizard.new.choose_format
|
|
25
|
+
elsif options[:detail]
|
|
26
|
+
CommitGpt::SetupWizard.new.choose_detail
|
|
24
27
|
else
|
|
25
28
|
CommitGpt::CommitAi.new.aicm(verbose: options[:verbose])
|
|
26
29
|
end
|
|
@@ -42,6 +45,7 @@ module CommitGpt
|
|
|
42
45
|
shell.say ' -m, --models # Interactive model selection'
|
|
43
46
|
shell.say ' -p, --provider # Switch active provider'
|
|
44
47
|
shell.say ' -f, --format # Choose commit message format'
|
|
48
|
+
shell.say ' -d, --detail # Choose commit message detail level'
|
|
45
49
|
shell.say ' -v, --verbose # Show git diff being sent to AI'
|
|
46
50
|
shell.say ''
|
|
47
51
|
|
|
@@ -61,6 +65,7 @@ module CommitGpt
|
|
|
61
65
|
shell.say " Provider: #{config['name'].green}"
|
|
62
66
|
shell.say " Model: #{config['model'].cyan}"
|
|
63
67
|
shell.say " Format: #{format.capitalize.yellow}"
|
|
68
|
+
shell.say " Detail: #{CommitGpt::ConfigManager.get_commit_detail.capitalize.yellow}"
|
|
64
69
|
shell.say " Base URL: #{config['base_url']}"
|
|
65
70
|
shell.say " Diff Len: #{config['diff_len']}"
|
|
66
71
|
shell.say " Timeout: #{CommitGpt::ConfigManager.get_model_fetch_timeout}s"
|
data/lib/commitgpt/commit_ai.rb
CHANGED
|
@@ -133,6 +133,51 @@ module CommitGpt
|
|
|
133
133
|
end
|
|
134
134
|
|
|
135
135
|
@commit_format = ConfigManager.get_commit_format
|
|
136
|
+
@commit_detail = ConfigManager.get_commit_detail
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# True when the message should carry a body, not just a subject line
|
|
140
|
+
def detailed?
|
|
141
|
+
@commit_detail == 'detailed'
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Streaming cutoff and completion budget, both of which have to grow to fit a body
|
|
145
|
+
def max_content_chars
|
|
146
|
+
detailed? ? 2000 : 300
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def reasoning_disabled_max_tokens
|
|
150
|
+
detailed? ? 1000 : 300
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Header printed before the streamed message starts arriving
|
|
154
|
+
def content_prefix
|
|
155
|
+
detailed? ? "✦ Commit message:\n" : '✦ Commit message: git commit -am "'
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Closing quote that matches content_prefix; a body has no quote to close
|
|
159
|
+
def content_suffix
|
|
160
|
+
detailed? ? '' : '"'
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Editor git will actually use, resolved through its own precedence chain
|
|
164
|
+
def git_editor
|
|
165
|
+
editor = `git var GIT_EDITOR 2>/dev/null`.strip
|
|
166
|
+
editor.empty? ? 'your editor' : editor
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Trim the model output down to what git should receive
|
|
170
|
+
def finalize_message(full_content)
|
|
171
|
+
unless detailed?
|
|
172
|
+
# Take only the first non-empty line to avoid repetition or multi-line garbage
|
|
173
|
+
first_line = full_content.split("\n").map(&:strip).reject(&:empty?).first
|
|
174
|
+
return first_line&.gsub(/\A["']|["']\z/, '') || ''
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
body = full_content.strip.gsub(/\A["']|["']\z/, '').strip
|
|
178
|
+
# Keep the subject/body/bullets structure but drop trailing spaces and
|
|
179
|
+
# collapse the extra blank lines models like to emit between sections
|
|
180
|
+
body.split("\n").map(&:rstrip).join("\n").gsub(/\n{3,}/, "\n\n")
|
|
136
181
|
end
|
|
137
182
|
|
|
138
183
|
def aicm(verbose: false)
|
|
@@ -150,9 +195,10 @@ module CommitGpt
|
|
|
150
195
|
|
|
151
196
|
case action
|
|
152
197
|
when :commit
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
198
|
+
# Pass argv directly: a body with quotes, backticks or newlines must
|
|
199
|
+
# not be re-interpreted by the shell
|
|
200
|
+
puts "\n→ Executing: git commit -m <message>".yellow
|
|
201
|
+
system('git', 'commit', '-m', ai_commit_message)
|
|
156
202
|
puts "\n\n"
|
|
157
203
|
puts `git log -1`
|
|
158
204
|
break
|
|
@@ -160,15 +206,15 @@ module CommitGpt
|
|
|
160
206
|
puts "\n"
|
|
161
207
|
next
|
|
162
208
|
when :edit
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
209
|
+
# Let git open the message in the user's editor: it honours the
|
|
210
|
+
# GIT_EDITOR/core.editor/VISUAL/EDITOR chain, handles multi-line
|
|
211
|
+
# bodies, and aborts by itself if the message is left empty
|
|
212
|
+
puts "\n→ Opening #{git_editor} with the generated message...".yellow
|
|
213
|
+
if system('git', 'commit', '-e', '-m', ai_commit_message)
|
|
214
|
+
puts "\n"
|
|
215
|
+
puts `git log -1`
|
|
170
216
|
else
|
|
171
|
-
|
|
217
|
+
puts '✖ Commit aborted.'.red
|
|
172
218
|
end
|
|
173
219
|
break
|
|
174
220
|
when :exit
|
|
@@ -219,7 +265,7 @@ module CommitGpt
|
|
|
219
265
|
prompt.select('Action:') do |menu|
|
|
220
266
|
menu.choice 'Commit', :commit
|
|
221
267
|
menu.choice 'Regenerate', :regenerate
|
|
222
|
-
menu.choice 'Edit', :edit
|
|
268
|
+
menu.choice 'Edit in editor', :edit
|
|
223
269
|
menu.choice 'Exit without commit', :exit
|
|
224
270
|
end
|
|
225
271
|
rescue TTY::Reader::InputInterrupt, Interrupt
|
|
@@ -279,7 +325,11 @@ module CommitGpt
|
|
|
279
325
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
280
326
|
def generate_commit(diff = '', chunk_label: nil)
|
|
281
327
|
# Build format-specific prompt
|
|
282
|
-
base_prompt =
|
|
328
|
+
base_prompt = if detailed?
|
|
329
|
+
'Generate a git commit message in present tense that precisely describes the key changes in the following code diff. Focus on what was changed and why, not just file names.'
|
|
330
|
+
else
|
|
331
|
+
'Generate a concise git commit message title in present tense that precisely describes the key changes in the following code diff. Focus on what was changed, not just file names. Provide only the title, no description or body.'
|
|
332
|
+
end
|
|
283
333
|
|
|
284
334
|
format_instruction = case @commit_format
|
|
285
335
|
when 'conventional'
|
|
@@ -290,17 +340,38 @@ module CommitGpt
|
|
|
290
340
|
''
|
|
291
341
|
end
|
|
292
342
|
|
|
293
|
-
format_spec =
|
|
343
|
+
format_spec = if detailed?
|
|
344
|
+
"The subject line must be in format:\n#{COMMIT_FORMATS[@commit_format]}"
|
|
345
|
+
else
|
|
346
|
+
"The output response must be in format:\n#{COMMIT_FORMATS[@commit_format]}"
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
rules = if detailed?
|
|
350
|
+
[
|
|
351
|
+
'- The first line is the subject: a maximum of 100 characters, no trailing period.',
|
|
352
|
+
'- Then one blank line, then a body paragraph of 1-3 sentences explaining what changed and why.',
|
|
353
|
+
'- Then one blank line, then a bullet list where each bullet starts with "- " and describes one notable change.',
|
|
354
|
+
'- Wrap body and bullet text at 72 characters.',
|
|
355
|
+
'- Be specific: include concrete details (file, table, method, package names) rather than generic statements.',
|
|
356
|
+
'- Do not use markdown headings, bold, or code fences. Plain text only.',
|
|
357
|
+
'- Exclude anything unnecessary such as translation. Your entire response will be passed directly into git commit.',
|
|
358
|
+
'- Return ONLY the commit message, nothing else. No explanations, no introductions, no surrounding quotes.'
|
|
359
|
+
]
|
|
360
|
+
else
|
|
361
|
+
[
|
|
362
|
+
'- Commit message must be a maximum of 100 characters.',
|
|
363
|
+
'- Exclude anything unnecessary such as translation. Your entire response will be passed directly into git commit.',
|
|
364
|
+
'- IMPORTANT: Do not include any explanations, introductions, or additional text. Do not wrap the commit message in quotes or any other formatting. The commit message must not exceed 100 characters. Respond with ONLY the commit message text.',
|
|
365
|
+
'- Be specific: include concrete details (package names, versions, functionality) rather than generic statements.',
|
|
366
|
+
'- Return ONLY the commit message, nothing else.'
|
|
367
|
+
]
|
|
368
|
+
end
|
|
294
369
|
|
|
295
370
|
system_content = [
|
|
296
371
|
base_prompt,
|
|
297
372
|
'Message language: English.',
|
|
298
373
|
'Rules:',
|
|
299
|
-
|
|
300
|
-
'- Exclude anything unnecessary such as translation. Your entire response will be passed directly into git commit.',
|
|
301
|
-
'- IMPORTANT: Do not include any explanations, introductions, or additional text. Do not wrap the commit message in quotes or any other formatting. The commit message must not exceed 100 characters. Respond with ONLY the commit message text.',
|
|
302
|
-
'- Be specific: include concrete details (package names, versions, functionality) rather than generic statements.',
|
|
303
|
-
'- Return ONLY the commit message, nothing else.',
|
|
374
|
+
*rules,
|
|
304
375
|
format_instruction,
|
|
305
376
|
format_spec
|
|
306
377
|
].reject(&:empty?).join("\n")
|
|
@@ -331,7 +402,7 @@ module CommitGpt
|
|
|
331
402
|
|
|
332
403
|
if can_disable_reasoning
|
|
333
404
|
payload[:disable_reasoning] = true
|
|
334
|
-
payload[:max_tokens] =
|
|
405
|
+
payload[:max_tokens] = reasoning_disabled_max_tokens
|
|
335
406
|
else
|
|
336
407
|
payload[:max_tokens] = configured_max_tokens
|
|
337
408
|
end
|
|
@@ -430,13 +501,13 @@ module CommitGpt
|
|
|
430
501
|
if chunk_label
|
|
431
502
|
print "◆ #{chunk_label}: ".magenta
|
|
432
503
|
else
|
|
433
|
-
print
|
|
504
|
+
print content_prefix.green
|
|
434
505
|
end
|
|
435
506
|
printed_content_prefix = true
|
|
436
507
|
end
|
|
437
508
|
|
|
438
509
|
# Prevent infinite loops/repetitive garbage
|
|
439
|
-
if full_content.length + content_chunk.length >
|
|
510
|
+
if full_content.length + content_chunk.length > max_content_chars
|
|
440
511
|
stop_stream = true
|
|
441
512
|
break
|
|
442
513
|
end
|
|
@@ -460,7 +531,7 @@ module CommitGpt
|
|
|
460
531
|
end
|
|
461
532
|
|
|
462
533
|
# Close the quote
|
|
463
|
-
puts(chunk_label ? '' :
|
|
534
|
+
puts(chunk_label ? '' : content_suffix.green) if printed_content_prefix
|
|
464
535
|
|
|
465
536
|
# Post-processing Logic (Retry if empty content)
|
|
466
537
|
if (full_content.nil? || full_content.strip.empty?) && (full_reasoning && !full_reasoning.strip.empty?)
|
|
@@ -511,9 +582,7 @@ module CommitGpt
|
|
|
511
582
|
# Reset retrying flag
|
|
512
583
|
@is_retrying = false
|
|
513
584
|
|
|
514
|
-
|
|
515
|
-
first_line = full_content.split("\n").map(&:strip).reject(&:empty?).first
|
|
516
|
-
first_line&.gsub(/\A["']|["']\z/, '') || ''
|
|
585
|
+
finalize_message(full_content)
|
|
517
586
|
end
|
|
518
587
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
519
588
|
|
|
@@ -530,14 +599,31 @@ module CommitGpt
|
|
|
530
599
|
''
|
|
531
600
|
end
|
|
532
601
|
|
|
602
|
+
rules = if detailed?
|
|
603
|
+
[
|
|
604
|
+
'- The first line is the subject: a maximum of 100 characters, no trailing period.',
|
|
605
|
+
'- Then one blank line, then a body paragraph of 1-3 sentences explaining what changed and why.',
|
|
606
|
+
'- Then one blank line, then a bullet list where each bullet starts with "- " and describes one notable change.',
|
|
607
|
+
'- Wrap body and bullet text at 72 characters.',
|
|
608
|
+
'- Present tense.',
|
|
609
|
+
'- Be specific: include concrete details rather than generic statements.',
|
|
610
|
+
'- Do not use markdown headings, bold, or code fences. Plain text only.',
|
|
611
|
+
'- Return ONLY the commit message, nothing else. No quotes, no explanations.'
|
|
612
|
+
]
|
|
613
|
+
else
|
|
614
|
+
[
|
|
615
|
+
'- Maximum 100 characters.',
|
|
616
|
+
'- Present tense.',
|
|
617
|
+
'- Be specific: include concrete details rather than generic statements.',
|
|
618
|
+
'- Return ONLY the commit message, nothing else. No quotes, no explanations.'
|
|
619
|
+
]
|
|
620
|
+
end
|
|
621
|
+
|
|
533
622
|
system_content = [
|
|
534
623
|
'You are given multiple commit messages generated from different segments of a single large git diff.',
|
|
535
|
-
|
|
624
|
+
"Synthesize them into ONE unified git commit message that captures the overall change.#{' Keep it concise.' unless detailed?}",
|
|
536
625
|
'Rules:',
|
|
537
|
-
|
|
538
|
-
'- Present tense.',
|
|
539
|
-
'- Be specific: include concrete details rather than generic statements.',
|
|
540
|
-
'- Return ONLY the commit message, nothing else. No quotes, no explanations.',
|
|
626
|
+
*rules,
|
|
541
627
|
format_instruction
|
|
542
628
|
].reject(&:empty?).join("\n")
|
|
543
629
|
|
|
@@ -558,7 +644,7 @@ module CommitGpt
|
|
|
558
644
|
|
|
559
645
|
if can_disable_reasoning
|
|
560
646
|
payload[:disable_reasoning] = true
|
|
561
|
-
payload[:max_tokens] =
|
|
647
|
+
payload[:max_tokens] = reasoning_disabled_max_tokens
|
|
562
648
|
else
|
|
563
649
|
payload[:max_tokens] = provider_config['max_tokens'] || 2000
|
|
564
650
|
end
|
|
@@ -606,11 +692,11 @@ module CommitGpt
|
|
|
606
692
|
content_chunk = delta['content']
|
|
607
693
|
if content_chunk && !content_chunk.empty?
|
|
608
694
|
unless printed_content_prefix
|
|
609
|
-
print "\n
|
|
695
|
+
print "\n#{content_prefix}".green
|
|
610
696
|
printed_content_prefix = true
|
|
611
697
|
end
|
|
612
698
|
|
|
613
|
-
if full_content.length + content_chunk.length >
|
|
699
|
+
if full_content.length + content_chunk.length > max_content_chars
|
|
614
700
|
stop_stream = true
|
|
615
701
|
break
|
|
616
702
|
end
|
|
@@ -630,15 +716,14 @@ module CommitGpt
|
|
|
630
716
|
return nil
|
|
631
717
|
end
|
|
632
718
|
|
|
633
|
-
puts
|
|
719
|
+
puts content_suffix.green if printed_content_prefix
|
|
634
720
|
|
|
635
721
|
if full_content.strip.empty?
|
|
636
722
|
puts '✖ No response from AI during synthesis.'.red
|
|
637
723
|
return nil
|
|
638
724
|
end
|
|
639
725
|
|
|
640
|
-
|
|
641
|
-
first_line&.gsub(/\A["']|["']\z/, '') || ''
|
|
726
|
+
finalize_message(full_content)
|
|
642
727
|
end
|
|
643
728
|
end
|
|
644
729
|
end
|
|
@@ -11,6 +11,11 @@ module CommitGpt
|
|
|
11
11
|
# Seconds to wait when fetching a provider's model list
|
|
12
12
|
DEFAULT_MODEL_FETCH_TIMEOUT = 30
|
|
13
13
|
|
|
14
|
+
# How much the commit message says: a subject line only, or a subject
|
|
15
|
+
# followed by an explanatory body
|
|
16
|
+
COMMIT_DETAILS = %w[concise detailed].freeze
|
|
17
|
+
DEFAULT_COMMIT_DETAIL = 'concise'
|
|
18
|
+
|
|
14
19
|
class << self
|
|
15
20
|
# Get the config directory path
|
|
16
21
|
def config_dir
|
|
@@ -168,6 +173,23 @@ module CommitGpt
|
|
|
168
173
|
save_main_config(main_config)
|
|
169
174
|
end
|
|
170
175
|
|
|
176
|
+
# Get commit detail level ('concise' or 'detailed')
|
|
177
|
+
def get_commit_detail
|
|
178
|
+
return DEFAULT_COMMIT_DETAIL unless config_exists?
|
|
179
|
+
|
|
180
|
+
main_config = YAML.load_file(main_config_path)
|
|
181
|
+
detail = main_config['commit_detail']
|
|
182
|
+
COMMIT_DETAILS.include?(detail) ? detail : DEFAULT_COMMIT_DETAIL
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Set commit detail level
|
|
186
|
+
def set_commit_detail(detail)
|
|
187
|
+
ensure_config_dir
|
|
188
|
+
main_config = config_exists? ? YAML.load_file(main_config_path) : { 'providers' => [], 'active_provider' => '' }
|
|
189
|
+
main_config['commit_detail'] = detail
|
|
190
|
+
save_main_config(main_config)
|
|
191
|
+
end
|
|
192
|
+
|
|
171
193
|
# Get the model list fetch timeout (seconds)
|
|
172
194
|
def get_model_fetch_timeout
|
|
173
195
|
return DEFAULT_MODEL_FETCH_TIMEOUT unless config_exists?
|
|
@@ -3,7 +3,16 @@
|
|
|
3
3
|
module CommitGpt
|
|
4
4
|
# Provider presets for common AI providers
|
|
5
5
|
PROVIDER_PRESETS = [
|
|
6
|
-
|
|
6
|
+
# The Azure gateway in front of this endpoint always answers /models with 403,
|
|
7
|
+
# so the model list is built in and no request is made during setup.
|
|
8
|
+
# Each value is that model's context window in tokens.
|
|
9
|
+
{ label: 'AMD Radeon (China)', value: 'amd', base_url: 'https://developer.amd.com.cn/radeon/api/v1',
|
|
10
|
+
models: {
|
|
11
|
+
'DeepSeek-V4-Flash' => 1_000_000,
|
|
12
|
+
'MiniCPM-V46' => 262_144,
|
|
13
|
+
'MiniCPM5-1B' => 131_072,
|
|
14
|
+
'Qwen3.6-35B-A3B' => 262_144
|
|
15
|
+
} },
|
|
7
16
|
{ label: 'Anthropic Claude', value: 'anthropic', base_url: 'https://api.anthropic.com/v1' },
|
|
8
17
|
{ label: 'Cerebras', value: 'cerebras', base_url: 'https://api.cerebras.ai/v1' },
|
|
9
18
|
{ label: 'DeepSeek', value: 'deepseek', base_url: 'https://api.deepseek.com' },
|
|
@@ -10,6 +10,18 @@ require_relative 'string'
|
|
|
10
10
|
module CommitGpt
|
|
11
11
|
# Interactive setup wizard for configuring AI providers
|
|
12
12
|
class SetupWizard
|
|
13
|
+
# Diff bytes per context token. Code and CJK pack more bytes into a token
|
|
14
|
+
# than English prose, so this stays deliberately conservative.
|
|
15
|
+
DIFF_BYTES_PER_TOKEN = 2
|
|
16
|
+
|
|
17
|
+
# Ceiling for the suggested diff length. Past this a single request gets
|
|
18
|
+
# slow and one commit message stops describing the change usefully, so
|
|
19
|
+
# chunked mode is the better answer than a bigger window.
|
|
20
|
+
MAX_SUGGESTED_DIFF_LEN = 131_072
|
|
21
|
+
|
|
22
|
+
# Used when the model's context window is unknown
|
|
23
|
+
FALLBACK_DIFF_LEN = 32_768
|
|
24
|
+
|
|
13
25
|
def initialize
|
|
14
26
|
@prompt = TTY::Prompt.new
|
|
15
27
|
end
|
|
@@ -45,13 +57,13 @@ module CommitGpt
|
|
|
45
57
|
provider = config['providers'].find { |p| p['name'] == selected }
|
|
46
58
|
|
|
47
59
|
# Fetch models and let user select
|
|
48
|
-
models = fetch_models_with_timeout(provider['base_url'], provider['api_key'])
|
|
60
|
+
models = fetch_models_with_timeout(provider['base_url'], provider['api_key'], preset_models(selected))
|
|
49
61
|
return if models.nil?
|
|
50
62
|
|
|
51
63
|
model = select_model(models, provider['model'])
|
|
52
64
|
|
|
53
65
|
# Prompt for diff length
|
|
54
|
-
diff_len = prompt_diff_len(provider['diff_len']
|
|
66
|
+
diff_len = prompt_diff_len(default_diff_len(selected, model, provider['diff_len']))
|
|
55
67
|
|
|
56
68
|
# Update config
|
|
57
69
|
ConfigManager.update_provider(selected, { 'model' => model, 'diff_len' => diff_len })
|
|
@@ -75,7 +87,8 @@ module CommitGpt
|
|
|
75
87
|
end
|
|
76
88
|
|
|
77
89
|
# Fetch models and let user select
|
|
78
|
-
models = fetch_models_with_timeout(provider_config['base_url'], provider_config['api_key']
|
|
90
|
+
models = fetch_models_with_timeout(provider_config['base_url'], provider_config['api_key'],
|
|
91
|
+
preset_models(provider_config['name']))
|
|
79
92
|
return if models.nil?
|
|
80
93
|
|
|
81
94
|
model = select_model(models, provider_config['model'])
|
|
@@ -103,8 +116,26 @@ module CommitGpt
|
|
|
103
116
|
puts "\n✔ Commit format set to: #{format}".green
|
|
104
117
|
end
|
|
105
118
|
|
|
119
|
+
# Choose how much the commit message says
|
|
120
|
+
def choose_detail
|
|
121
|
+
puts "\n→ Choose git commit message detail level:\n".green
|
|
122
|
+
|
|
123
|
+
detail = @prompt.select('Select detail level:', default: default_detail_index) do |menu|
|
|
124
|
+
menu.choice 'Concise - Subject line only', 'concise'
|
|
125
|
+
menu.choice 'Detailed - Subject line, explanatory paragraph, and bullet list of changes', 'detailed'
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
ConfigManager.set_commit_detail(detail)
|
|
129
|
+
puts "\n✔ Commit detail set to: #{detail}".green
|
|
130
|
+
end
|
|
131
|
+
|
|
106
132
|
private
|
|
107
133
|
|
|
134
|
+
# Menu position of the currently configured detail level (1-based)
|
|
135
|
+
def default_detail_index
|
|
136
|
+
ConfigManager::COMMIT_DETAILS.index(ConfigManager.get_commit_detail).to_i + 1
|
|
137
|
+
end
|
|
138
|
+
|
|
108
139
|
# Select provider from list
|
|
109
140
|
def select_provider
|
|
110
141
|
config = ConfigManager.load_config
|
|
@@ -148,14 +179,14 @@ module CommitGpt
|
|
|
148
179
|
return if api_key.nil? # User cancelled
|
|
149
180
|
|
|
150
181
|
# Fetch models with timeout
|
|
151
|
-
models = fetch_models_with_timeout(base_url, api_key)
|
|
182
|
+
models = fetch_models_with_timeout(base_url, api_key, preset_models(provider_name))
|
|
152
183
|
return if models.nil?
|
|
153
184
|
|
|
154
185
|
# Let user select model
|
|
155
186
|
model = select_model(models, existing_provider&.dig('model'))
|
|
156
187
|
|
|
157
188
|
# Prompt for diff length
|
|
158
|
-
diff_len = prompt_diff_len(existing_provider&.dig('diff_len')
|
|
189
|
+
diff_len = prompt_diff_len(default_diff_len(provider_name, model, existing_provider&.dig('diff_len')))
|
|
159
190
|
|
|
160
191
|
# Save configuration
|
|
161
192
|
ConfigManager.update_provider(
|
|
@@ -181,14 +212,14 @@ module CommitGpt
|
|
|
181
212
|
q.default 'http://localhost:8080/v1'
|
|
182
213
|
end
|
|
183
214
|
|
|
184
|
-
api_key = @prompt.mask('Enter your API key (optional):') { |q| q.echo false }
|
|
215
|
+
api_key = @prompt.mask('Enter your API key (optional):') { |q| q.echo false }.to_s
|
|
185
216
|
|
|
186
217
|
# Fetch models
|
|
187
218
|
models = fetch_models_with_timeout(base_url, api_key)
|
|
188
219
|
return if models.nil?
|
|
189
220
|
|
|
190
221
|
model = select_model(models)
|
|
191
|
-
diff_len = prompt_diff_len(
|
|
222
|
+
diff_len = prompt_diff_len(FALLBACK_DIFF_LEN)
|
|
192
223
|
|
|
193
224
|
# Add to presets dynamically (just for this session)
|
|
194
225
|
# Save to config
|
|
@@ -230,20 +261,19 @@ module CommitGpt
|
|
|
230
261
|
|
|
231
262
|
# Prompt for API key
|
|
232
263
|
def prompt_api_key(_provider_name, existing_key)
|
|
233
|
-
|
|
264
|
+
has_existing = existing_key && !existing_key.empty?
|
|
265
|
+
|
|
266
|
+
message = if has_existing
|
|
234
267
|
'Enter your API key (press Enter to keep existing):'
|
|
235
268
|
else
|
|
236
269
|
'Enter your API key:'
|
|
237
270
|
end
|
|
238
271
|
|
|
239
|
-
|
|
272
|
+
# mask returns nil, not '', when the input is empty
|
|
273
|
+
key = @prompt.mask(message) { |q| q.echo false }.to_s
|
|
240
274
|
|
|
241
275
|
# If user pressed Enter and there's an existing key, use it
|
|
242
|
-
|
|
243
|
-
existing_key
|
|
244
|
-
else
|
|
245
|
-
key
|
|
246
|
-
end
|
|
276
|
+
key.empty? && has_existing ? existing_key : key
|
|
247
277
|
end
|
|
248
278
|
|
|
249
279
|
# Ask how long to wait when fetching the model list, and persist it
|
|
@@ -262,12 +292,56 @@ module CommitGpt
|
|
|
262
292
|
seconds
|
|
263
293
|
end
|
|
264
294
|
|
|
265
|
-
#
|
|
266
|
-
def
|
|
295
|
+
# Built-in models a preset declares, as { model => context tokens }
|
|
296
|
+
def preset_model_table(provider_name)
|
|
297
|
+
models = PROVIDER_PRESETS.find { |p| p[:value] == provider_name }&.dig(:models)
|
|
298
|
+
models unless models.nil? || models.empty?
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Built-in model list for a preset, if it declares one
|
|
302
|
+
def preset_models(provider_name)
|
|
303
|
+
preset_model_table(provider_name)&.keys
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Context window of a built-in model, in tokens
|
|
307
|
+
def model_context_tokens(provider_name, model)
|
|
308
|
+
preset_model_table(provider_name)&.dig(model)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Default diff length for a model, derived from its context window when
|
|
312
|
+
# known, and reported so the number is not a mystery. Numbers are printed
|
|
313
|
+
# undelimited because they get typed straight into the next prompt.
|
|
314
|
+
def default_diff_len(provider_name, model, existing)
|
|
315
|
+
context_tokens = model_context_tokens(provider_name, model)
|
|
316
|
+
return existing || FALLBACK_DIFF_LEN if context_tokens.nil?
|
|
317
|
+
|
|
318
|
+
fits = context_tokens * DIFF_BYTES_PER_TOKEN
|
|
319
|
+
suggested = [fits, MAX_SUGGESTED_DIFF_LEN].min
|
|
320
|
+
capped = ' (capped; larger diffs use chunked mode)' if suggested < fits
|
|
321
|
+
|
|
322
|
+
puts "\n#{model} context window: #{context_tokens} tokens (~#{fits} bytes of diff)".gray
|
|
323
|
+
puts " Suggested max diff length: #{suggested} bytes#{capped}".gray
|
|
324
|
+
|
|
325
|
+
# Only a value the user actually chose outranks the suggestion
|
|
326
|
+
customized = existing && existing != FALLBACK_DIFF_LEN
|
|
327
|
+
customized ? existing : suggested
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Fetch models from provider with timeout.
|
|
331
|
+
# A preset that declares :models has no usable /models endpoint, so its
|
|
332
|
+
# built-in list is offered directly and no request is made.
|
|
333
|
+
def fetch_models_with_timeout(base_url, api_key, builtin_models = nil)
|
|
334
|
+
if builtin_models
|
|
335
|
+
puts 'Using the built-in model list (this provider does not expose /models).'.gray
|
|
336
|
+
return builtin_models
|
|
337
|
+
end
|
|
338
|
+
|
|
267
339
|
timeout = ConfigManager.get_model_fetch_timeout
|
|
268
340
|
puts 'Fetching available models...'.gray
|
|
269
341
|
|
|
270
342
|
models = nil
|
|
343
|
+
failure = nil
|
|
344
|
+
|
|
271
345
|
begin
|
|
272
346
|
Timeout.timeout(timeout) do
|
|
273
347
|
headers = {
|
|
@@ -279,28 +353,23 @@ module CommitGpt
|
|
|
279
353
|
response = HTTParty.get("#{base_url}/models", headers: headers)
|
|
280
354
|
|
|
281
355
|
if response.code == 200
|
|
282
|
-
models = response['data'] || []
|
|
283
|
-
models = models.map { |m| m['id'] }.compact.sort
|
|
356
|
+
models = (response['data'] || []).map { |m| m['id'] }.compact.sort
|
|
284
357
|
else
|
|
285
|
-
|
|
286
|
-
return nil
|
|
358
|
+
failure = "Failed to fetch models: HTTP #{response.code}"
|
|
287
359
|
end
|
|
288
360
|
end
|
|
289
361
|
rescue Timeout::Error
|
|
290
|
-
|
|
291
|
-
puts " Run 'aicm setup' again to raise the timeout.".gray
|
|
292
|
-
exit(0)
|
|
362
|
+
failure = "Connection timeout (#{timeout}s). Please check your network, base_url, and api_key."
|
|
293
363
|
rescue StandardError => e
|
|
294
|
-
|
|
295
|
-
exit(0)
|
|
364
|
+
failure = "Error fetching models: #{e.message}"
|
|
296
365
|
end
|
|
297
366
|
|
|
298
|
-
if models.nil? || models.empty?
|
|
299
|
-
|
|
300
|
-
exit(0)
|
|
301
|
-
end
|
|
367
|
+
failure ||= 'No models found. Please check your configuration.' if models.nil? || models.empty?
|
|
368
|
+
return models if failure.nil?
|
|
302
369
|
|
|
303
|
-
|
|
370
|
+
puts "✖ #{failure}".red
|
|
371
|
+
puts " Run 'aicm setup' again to raise the timeout.".gray if failure.start_with?('Connection timeout')
|
|
372
|
+
exit(0)
|
|
304
373
|
end
|
|
305
374
|
|
|
306
375
|
# Let user select a model
|
|
@@ -328,7 +397,7 @@ module CommitGpt
|
|
|
328
397
|
end
|
|
329
398
|
|
|
330
399
|
# Prompt for diff length
|
|
331
|
-
def prompt_diff_len(default =
|
|
400
|
+
def prompt_diff_len(default = FALLBACK_DIFF_LEN)
|
|
332
401
|
answer = @prompt.ask('Set the maximum diff length (Bytes) for generating commit message:') do |q|
|
|
333
402
|
q.default default.to_s
|
|
334
403
|
q.convert :int
|
data/lib/commitgpt/version.rb
CHANGED