commitgpt 0.3.4 → 0.3.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de7ab665c77f18d08715d16d56b7f4cd0a3b7e677bdbeabb80b25e96afcc8f47
4
- data.tar.gz: f3cddebeaf3d1f479155e85a9039f47a14c7cc61d0390968c47fb0e810ca6ce7
3
+ metadata.gz: a1e1fcc474c778dff2c10f370b5a291483793d0ded5c8d43beaab1b7e377b4ff
4
+ data.tar.gz: 17ec9d1daaf3ad085adbd8566173411db284995188b4c97d5eaf4d69a634763d
5
5
  SHA512:
6
- metadata.gz: 9576ab5271ad85fb136eab275b987787cac5bae9af6c3458563089dd4e782c92ce57b48630379ad7c8ebbd11a816a0f2d6b69d302557dc8f5e3f69b482d2b889
7
- data.tar.gz: e882ab4a80976721e87fcee2d5935021c2063ddf23c61e1cb26a45c7e8ead43da33610fb1f18cbd21fbc17d540cbdbdc7d9f52cc7641c666d589ce2fd41e15b2
6
+ metadata.gz: 91ab99f89df2861a5731a3692e88d989b733a5e88edbd55ccdb654e109778b778f02dfc2af547ee6d12dcb8da8858ade012fd3150df381a6a771eecc036b04f1
7
+ data.tar.gz: aee42f5617f25c523aa9ed3c134eaae5b92c3e9d1e7d19cb9e7de089d79c010d792c4eb12c836db138160076f787dbe30e4219c104476e0c806f2de8135085bb
data/README.md CHANGED
@@ -155,8 +155,44 @@ $ aicm -m
155
155
  $ aicm --models
156
156
  ```
157
157
 
158
+ ### Choose Commit Message Format
159
+ Select your preferred commit message format:
160
+ ```bash
161
+ $ aicm -f
162
+ # or
163
+ $ aicm --format
164
+ ```
165
+
166
+ CommitGPT supports three commit message formats:
167
+ - **Simple** - Concise commit message (default)
168
+ - **Conventional** - Follow [Conventional Commits](https://www.conventionalcommits.org/) specification
169
+ - **Gitmoji** - Use [Gitmoji](https://gitmoji.dev/) emoji standard
170
+
171
+ Your selection will be saved in `~/.config/commitgpt/config.yml` and used for all future commits until changed.
172
+
173
+ #### Format Examples
174
+
175
+ **Simple:**
176
+ ```
177
+ Add user authentication feature
178
+ ```
179
+
180
+ **Conventional:**
181
+ ```
182
+ feat: add user authentication feature
183
+ fix: resolve login timeout issue
184
+ docs: update API documentation
185
+ ```
186
+
187
+ **Gitmoji:**
188
+ ```
189
+ ✨ add user authentication feature
190
+ šŸ› resolve login timeout issue
191
+ šŸ“ update API documentation
192
+ ```
193
+
158
194
  ### Check Configuration
159
- View your current configuration (Provider, Model, Base URL, Diff Len):
195
+ View your current configuration (Provider, Model, Format, Base URL, Diff Len):
160
196
  ```bash
161
197
  $ aicm help
162
198
  ```
@@ -180,6 +216,7 @@ $ gem update commitgpt
180
216
  We support any OpenAI-compatible API. Presets available for:
181
217
  - **Cerebras** (Fast & Recommended)
182
218
  - **OpenAI** (Official)
219
+ - **Apple** (Local via apple-to-openai)
183
220
  - **Ollama** (Local)
184
221
  - **Groq**
185
222
  - **DeepSeek**
@@ -210,6 +247,10 @@ llama-3.3-70b-versatile
210
247
  llama-3.1-8b-instant
211
248
  ```
212
249
 
250
+ **Apple Local Models** (via [apple-to-openai](https://github.com/ZPVIP/apple-to-openai))
251
+
252
+ > **Note**: Due to the context window limits of Apple's local models, it is highly recommended to set your max diff length (`diff_len`) to `10000` during setup. When prompted for large diffs, select the **Smart chunked mode** to avoid errors.
253
+
213
254
  ## How It Works
214
255
  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.
215
256
 
data/lib/commitgpt/cli.rb CHANGED
@@ -13,11 +13,14 @@ module CommitGpt
13
13
  method_option :models, aliases: '-m', type: :boolean, desc: 'List/Select available models'
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
+ method_option :format, aliases: '-f', type: :boolean, desc: 'Choose commit message format'
16
17
  def generate
17
18
  if options[:provider]
18
19
  CommitGpt::SetupWizard.new.switch_provider
19
20
  elsif options[:models]
20
21
  CommitGpt::SetupWizard.new.change_model
22
+ elsif options[:format]
23
+ CommitGpt::SetupWizard.new.choose_format
21
24
  else
22
25
  CommitGpt::CommitAi.new.aicm(verbose: options[:verbose])
23
26
  end
@@ -38,6 +41,7 @@ module CommitGpt
38
41
  shell.say 'Options:'
39
42
  shell.say ' -m, --models # Interactive model selection'
40
43
  shell.say ' -p, --provider # Switch active provider'
44
+ shell.say ' -f, --format # Choose commit message format'
41
45
  shell.say ' -v, --verbose # Show git diff being sent to AI'
42
46
  shell.say ''
43
47
 
@@ -52,9 +56,11 @@ module CommitGpt
52
56
  shell.say "Bin Path: #{File.realpath($PROGRAM_NAME)}".gray
53
57
  shell.say ''
54
58
 
59
+ format = CommitGpt::ConfigManager.get_commit_format
55
60
  shell.say 'Current Configuration:'
56
61
  shell.say " Provider: #{config['name'].green}"
57
62
  shell.say " Model: #{config['model'].cyan}"
63
+ shell.say " Format: #{format.capitalize.yellow}"
58
64
  shell.say " Base URL: #{config['base_url']}"
59
65
  shell.say " Diff Len: #{config['diff_len']}"
60
66
  shell.say ''
@@ -13,10 +13,109 @@ require_relative 'diff_helpers'
13
13
  # CommitGpt based on GPT-3
14
14
  module CommitGpt
15
15
  # Commit AI roboter based on GPT-3
16
- class CommitAi
16
+ class CommitAi # rubocop:disable Metrics/ClassLength
17
17
  include DiffHelpers
18
18
 
19
- attr_reader :api_key, :base_url, :model, :diff_len
19
+ attr_reader :api_key, :base_url, :model, :diff_len, :commit_format
20
+
21
+ # Commit format templates
22
+ COMMIT_FORMATS = {
23
+ 'simple' => '<commit message>',
24
+ 'conventional' => '<type>[optional (<scope>)]: <commit message>',
25
+ 'gitmoji' => ':emoji: <commit message>'
26
+ }.freeze
27
+
28
+ # Conventional commit types based on aicommits implementation
29
+ CONVENTIONAL_TYPES = {
30
+ 'docs' => 'Documentation only changes',
31
+ 'style' => 'Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)',
32
+ 'refactor' => 'A code change that improves code structure without changing functionality (renaming, restructuring classes/methods, extracting functions, etc)',
33
+ 'perf' => 'A code change that improves performance',
34
+ 'test' => 'Adding missing tests or correcting existing tests',
35
+ 'build' => 'Changes that affect the build system or external dependencies',
36
+ 'ci' => 'Changes to our CI configuration files and scripts',
37
+ 'chore' => "Other changes that don't modify src or test files",
38
+ 'revert' => 'Reverts a previous commit',
39
+ 'feat' => 'A new feature',
40
+ 'fix' => 'A bug fix'
41
+ }.freeze
42
+
43
+ # Gitmoji mappings based on gitmoji.dev
44
+ GITMOJI_TYPES = {
45
+ 'šŸŽØ' => 'Improve structure / format of the code',
46
+ '⚔' => 'Improve performance',
47
+ 'šŸ”„' => 'Remove code or files',
48
+ 'šŸ›' => 'Fix a bug',
49
+ 'šŸš‘' => 'Critical hotfix',
50
+ '✨' => 'Introduce new features',
51
+ 'šŸ“' => 'Add or update documentation',
52
+ 'šŸš€' => 'Deploy stuff',
53
+ 'šŸ’„' => 'Add or update the UI and style files',
54
+ 'šŸŽ‰' => 'Begin a project',
55
+ 'āœ…' => 'Add, update, or pass tests',
56
+ 'šŸ”’' => 'Fix security or privacy issues',
57
+ 'šŸ”' => 'Add or update secrets',
58
+ 'šŸ”–' => 'Release / Version tags',
59
+ '🚨' => 'Fix compiler / linter warnings',
60
+ '🚧' => 'Work in progress',
61
+ 'šŸ’š' => 'Fix CI Build',
62
+ 'ā¬‡ļø' => 'Downgrade dependencies',
63
+ 'ā¬†ļø' => 'Upgrade dependencies',
64
+ 'šŸ“Œ' => 'Pin dependencies to specific versions',
65
+ 'šŸ‘·' => 'Add or update CI build system',
66
+ 'šŸ“ˆ' => 'Add or update analytics or track code',
67
+ 'ā™»ļø' => 'Refactor code',
68
+ 'āž•' => 'Add a dependency',
69
+ 'āž–' => 'Remove a dependency',
70
+ 'šŸ”§' => 'Add or update configuration files',
71
+ 'šŸ”Ø' => 'Add or update development scripts',
72
+ '🌐' => 'Internationalization and localization',
73
+ 'āœļø' => 'Fix typos',
74
+ 'šŸ’©' => 'Write bad code that needs to be improved',
75
+ 'āŖ' => 'Revert changes',
76
+ 'šŸ”€' => 'Merge branches',
77
+ 'šŸ“¦' => 'Add or update compiled files or packages',
78
+ 'šŸ‘½' => 'Update code due to external API changes',
79
+ '🚚' => 'Move or rename resources (e.g.: files, paths, routes)',
80
+ 'šŸ“„' => 'Add or update license',
81
+ 'šŸ’„' => 'Introduce breaking changes',
82
+ 'šŸ±' => 'Add or update assets',
83
+ '♿' => 'Improve accessibility',
84
+ 'šŸ’”' => 'Add or update comments in source code',
85
+ 'šŸ»' => 'Write code drunkenly',
86
+ 'šŸ’¬' => 'Add or update text and literals',
87
+ 'šŸ—ƒ' => 'Perform database related changes',
88
+ 'šŸ”Š' => 'Add or update logs',
89
+ 'šŸ”‡' => 'Remove logs',
90
+ 'šŸ‘„' => 'Add or update contributor(s)',
91
+ '🚸' => 'Improve user experience / usability',
92
+ 'šŸ—' => 'Make architectural changes',
93
+ 'šŸ“±' => 'Work on responsive design',
94
+ '🤔' => 'Mock things',
95
+ '🄚' => 'Add or update an easter egg',
96
+ 'šŸ™ˆ' => 'Add or update a .gitignore file',
97
+ 'šŸ“ø' => 'Add or update snapshots',
98
+ 'āš—' => 'Perform experiments',
99
+ 'šŸ”' => 'Improve SEO',
100
+ 'šŸ·' => 'Add or update types',
101
+ '🌱' => 'Add or update seed files',
102
+ '🚩' => 'Add, update, or remove feature flags',
103
+ 'šŸ„…' => 'Catch errors',
104
+ 'šŸ’«' => 'Add or update animations and transitions',
105
+ 'šŸ—‘' => 'Deprecate code that needs to be cleaned up',
106
+ 'šŸ›‚' => 'Work on code related to authorization, roles and permissions',
107
+ '🩹' => 'Simple fix for a non-critical issue',
108
+ '🧐' => 'Data exploration/inspection',
109
+ '⚰' => 'Remove dead code',
110
+ '🧪' => 'Add a failing test',
111
+ 'šŸ‘”' => 'Add or update business logic',
112
+ '🩺' => 'Add or update healthcheck',
113
+ '🧱' => 'Infrastructure related changes',
114
+ 'šŸ§‘ā€šŸ’»' => 'Improve developer experience',
115
+ 'šŸ’ø' => 'Add sponsorships or money related infrastructure',
116
+ '🧵' => 'Add or update code related to multithreading or concurrency',
117
+ '🦺' => 'Add or update code related to validation'
118
+ }.freeze
20
119
 
21
120
  def initialize
22
121
  provider_config = ConfigManager.get_active_provider_config
@@ -32,13 +131,15 @@ module CommitGpt
32
131
  @model = nil
33
132
  @diff_len = 32_768
34
133
  end
134
+
135
+ @commit_format = ConfigManager.get_commit_format
35
136
  end
36
137
 
37
138
  def aicm(verbose: false)
38
139
  exit(1) unless welcome
39
140
  diff = git_diff || exit(1)
40
141
  if verbose
41
- puts "ā–² Git diff (#{diff.length} chars):".cyan
142
+ puts "→ Git diff (#{diff.length} chars):".cyan
42
143
  puts diff
43
144
  puts "\n"
44
145
  end
@@ -50,7 +151,7 @@ module CommitGpt
50
151
  case action
51
152
  when :commit
52
153
  commit_command = "git commit -m \"#{ai_commit_message}\""
53
- puts "\nā–² Executing: #{commit_command}".yellow
154
+ puts "\n→ Executing: #{commit_command}".yellow
54
155
  system(commit_command)
55
156
  puts "\n\n"
56
157
  puts `git log -1`
@@ -67,11 +168,11 @@ module CommitGpt
67
168
  puts "\n"
68
169
  puts `git log -1`
69
170
  else
70
- puts 'ā–² Commit aborted (empty message).'.red
171
+ puts 'āœ– Commit aborted (empty message).'.red
71
172
  end
72
173
  break
73
174
  when :exit
74
- puts 'ā–² Exit without commit.'.yellow
175
+ puts '⚠ Exit without commit.'.yellow
75
176
  break
76
177
  end
77
178
  end
@@ -89,7 +190,7 @@ module CommitGpt
89
190
  models = response['data'] || []
90
191
  models.each { |m| puts m['id'] }
91
192
  rescue StandardError => e
92
- puts "ā–² Failed to list models: #{e.message}".red
193
+ puts "āœ– Failed to list models: #{e.message}".red
93
194
  end
94
195
  end
95
196
 
@@ -105,7 +206,7 @@ module CommitGpt
105
206
  models = response['data'] || []
106
207
  models.each { |m| puts m['id'] }
107
208
  rescue StandardError => e
108
- puts "ā–² Failed to list models: #{e.message}".red
209
+ puts "āœ– Failed to list models: #{e.message}".red
109
210
  end
110
211
  end
111
212
 
@@ -127,52 +228,87 @@ module CommitGpt
127
228
  end
128
229
 
129
230
  def message(diff = nil)
130
- generate_commit(diff)
231
+ return generate_commit(diff) unless @chunked_mode
232
+
233
+ # Reserve space for system prompt overhead (~20% of diff_len)
234
+ chunk_size = [(@diff_len * 0.8).to_i, 4000].max
235
+ chunks = split_diff_by_length(diff, chunk_size)
236
+ segment_messages = []
237
+
238
+ puts "→ Splitting into #{chunks.length} segments (#{chunk_size} chars each)...".cyan
239
+
240
+ chunks.each_with_index do |chunk, idx|
241
+ puts "\nā—† Generating message for segment #{idx + 1}/#{chunks.length}...".magenta
242
+ msg = generate_commit(chunk, chunk_label: "Segment #{idx + 1}/#{chunks.length}")
243
+ return nil if msg.nil?
244
+
245
+ segment_messages << msg
246
+ puts ''
247
+ end
248
+
249
+ puts "\n→ Synthesizing final commit message from #{segment_messages.length} segments...".cyan
250
+ synthesize_commit(segment_messages)
131
251
  end
132
252
 
133
253
  def welcome
134
- puts "\nā–² Welcome to AI Commits!".green
254
+ puts "\n✦ Welcome to AI Commits!".green
135
255
 
136
256
  # Check if config exists
137
257
  unless ConfigManager.config_exists?
138
- puts 'ā–² Configuration not found. Generating default config...'.yellow
258
+ puts '⚠ Configuration not found. Generating default config...'.yellow
139
259
  ConfigManager.generate_default_configs
140
- puts "ā–² Please run 'aicm setup' to configure your provider.".red
260
+ puts "āœ– Please run 'aicm setup' to configure your provider.".red
141
261
  return false
142
262
  end
143
263
 
144
264
  # Check if active provider is configured
145
- if @api_key.nil? || @api_key.empty?
146
- puts "ā–² No active provider configured. Please run 'aicm setup'.".red
147
- return false
148
- end
149
-
150
265
  if @model.nil? || @model.empty?
151
- puts "ā–² No model selected. Please run 'aicm setup'.".red
266
+ puts "āœ– No model selected. Please run 'aicm setup'.".red
152
267
  return false
153
268
  end
154
269
 
155
270
  begin
156
271
  `git rev-parse --is-inside-work-tree`
157
272
  rescue StandardError
158
- puts 'ā–² This is not a git repository'.red
273
+ puts 'āœ– This is not a git repository'.red
159
274
  return false
160
275
  end
161
-
162
276
  true
163
277
  end
164
278
 
165
- def generate_commit(diff = '')
279
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
280
+ def generate_commit(diff = '', chunk_label: nil)
281
+ # Build format-specific prompt
282
+ base_prompt = '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.'
283
+
284
+ format_instruction = case @commit_format
285
+ when 'conventional'
286
+ "Choose a type from the type-to-description JSON below that best describes the git diff:\n#{JSON.pretty_generate(CONVENTIONAL_TYPES)}"
287
+ when 'gitmoji'
288
+ "Choose an emoji from the emoji-to-description JSON below that best describes the git diff:\n#{JSON.pretty_generate(GITMOJI_TYPES)}"
289
+ else
290
+ ''
291
+ end
292
+
293
+ format_spec = "The output response must be in format:\n#{COMMIT_FORMATS[@commit_format]}"
294
+
295
+ system_content = [
296
+ base_prompt,
297
+ 'Message language: English.',
298
+ 'Rules:',
299
+ '- Commit message must be a maximum of 100 characters.',
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.',
304
+ format_instruction,
305
+ format_spec
306
+ ].reject(&:empty?).join("\n")
307
+
166
308
  messages = [
167
309
  {
168
310
  role: 'system',
169
- content: '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. ' \
170
- "Message language: English. Rules:\n" \
171
- "- Commit message must be a maximum of 100 characters.\n" \
172
- "- Exclude anything unnecessary such as translation. Your entire response will be passed directly into git commit.\n" \
173
- "- 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. \n" \
174
- "- Be specific: include concrete details (package names, versions, functionality) rather than generic statements. \n" \
175
- '- Return ONLY the commit message, nothing else.'
311
+ content: system_content
176
312
  },
177
313
  {
178
314
  role: 'user',
@@ -201,7 +337,9 @@ module CommitGpt
201
337
  end
202
338
 
203
339
  # Initial UI feedback (only on first try)
204
- puts '....... Generating your AI commit message ......'.gray unless defined?(@is_retrying) && @is_retrying
340
+ total_chars = system_content.length + diff.to_s.length
341
+ puts " ....... System prompt: #{system_content.length} chars, Diff chunk: #{diff.to_s.length} chars, Total: #{total_chars} chars".gray
342
+ puts ' ....... Generating your AI commit message'.gray unless defined?(@is_retrying) && @is_retrying
205
343
 
206
344
  full_content = ''
207
345
  full_reasoning = ''
@@ -242,12 +380,12 @@ module CommitGpt
242
380
  end
243
381
 
244
382
  if can_disable_reasoning && (error_msg =~ /parameter|reasoning|unsupported/i || response.code == '400')
245
- puts "ā–² Provider does not support 'disable_reasoning'. Updating config and retrying...".yellow
383
+ puts "⚠ Provider does not support 'disable_reasoning'. Updating config and retrying...".yellow
246
384
  ConfigManager.update_provider(provider_config['name'], { 'can_disable_reasoning' => false })
247
385
  @is_retrying = true
248
386
  return generate_commit(diff)
249
387
  else
250
- puts "ā–² API Error: #{error_msg}".red
388
+ puts "āœ– API Error: #{error_msg}".red
251
389
  return nil
252
390
  end
253
391
  end
@@ -289,7 +427,11 @@ module CommitGpt
289
427
  end
290
428
 
291
429
  unless printed_content_prefix
292
- print "\nā–² Commit message: git commit -am \"".green
430
+ if chunk_label
431
+ print "ā—† #{chunk_label}: ".magenta
432
+ else
433
+ print '✦ Commit message: git commit -am "'.green
434
+ end
293
435
  printed_content_prefix = true
294
436
  end
295
437
 
@@ -299,7 +441,7 @@ module CommitGpt
299
441
  break
300
442
  end
301
443
 
302
- print content_chunk.green
444
+ print chunk_label ? content_chunk.magenta : content_chunk.green
303
445
  full_content += content_chunk
304
446
  $stdout.flush
305
447
  end
@@ -313,22 +455,22 @@ module CommitGpt
313
455
  end
314
456
  end
315
457
  rescue StandardError => e
316
- puts "ā–² Error: #{e.message}".red
458
+ puts "āœ– Error: #{e.message}".red
317
459
  return nil
318
460
  end
319
461
 
320
462
  # Close the quote
321
- puts '"'.green if printed_content_prefix
463
+ puts(chunk_label ? '' : '"'.green) if printed_content_prefix
322
464
 
323
465
  # Post-processing Logic (Retry if empty content)
324
466
  if (full_content.nil? || full_content.strip.empty?) && (full_reasoning && !full_reasoning.strip.empty?)
325
467
  if can_disable_reasoning
326
- puts "\nā–² Model returned reasoning despite 'disable_reasoning: true'. Updating config and retrying...".yellow
468
+ puts "\n⚠ Model returned reasoning despite 'disable_reasoning: true'. Updating config and retrying...".yellow
327
469
  ConfigManager.update_provider(provider_config['name'], { 'can_disable_reasoning' => false })
328
470
  @is_retrying = true
329
471
  return generate_commit(diff)
330
472
  else
331
- puts "\nā–² Model output truncated (Reasoning consumed all #{configured_max_tokens} tokens).".red
473
+ puts "\nāœ– Model output truncated (Reasoning consumed all #{configured_max_tokens} tokens).".red
332
474
  prompt = TTY::Prompt.new
333
475
  choice = prompt.select('Choose an action:') do |menu|
334
476
  menu.choice "Double max_tokens to #{configured_max_tokens * 2}", :double
@@ -346,7 +488,7 @@ module CommitGpt
346
488
  end
347
489
 
348
490
  if new_max
349
- puts "ā–² Updating max_tokens to #{new_max} and retrying...".yellow
491
+ puts "→ Updating max_tokens to #{new_max} and retrying...".yellow
350
492
  ConfigManager.update_provider(provider_config['name'], { 'max_tokens' => new_max })
351
493
  @is_retrying = true
352
494
  return generate_commit(diff)
@@ -356,13 +498,13 @@ module CommitGpt
356
498
  end
357
499
 
358
500
  if full_content.empty? && full_reasoning.empty?
359
- puts 'ā–² No response from AI.'.red
501
+ puts 'āœ– No response from AI.'.red
360
502
  return nil
361
503
  end
362
504
 
363
505
  # Print usage info if available (saved from stream or approximated)
364
506
  if defined?(@last_usage) && @last_usage
365
- puts "\n...... Tokens: #{@last_usage['total_tokens']} (Prompt: #{@last_usage['prompt_tokens']}, Completion: #{@last_usage['completion_tokens']})\n\n".gray
507
+ puts " ....... Tokens: #{@last_usage['total_tokens']} (Prompt: #{@last_usage['prompt_tokens']}, Completion: #{@last_usage['completion_tokens']})".gray
366
508
  @last_usage = nil
367
509
  end
368
510
 
@@ -373,5 +515,130 @@ module CommitGpt
373
515
  first_line = full_content.split("\n").map(&:strip).reject(&:empty?).first
374
516
  first_line&.gsub(/\A["']|["']\z/, '') || ''
375
517
  end
518
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
519
+
520
+ # Synthesize a final commit message from multiple segment messages
521
+ def synthesize_commit(segment_messages)
522
+ numbered = segment_messages.each_with_index.map { |msg, i| "#{i + 1}. #{msg}" }.join("\n")
523
+
524
+ format_instruction = case @commit_format
525
+ when 'conventional'
526
+ "The output must follow Conventional Commits format:\n#{COMMIT_FORMATS['conventional']}"
527
+ when 'gitmoji'
528
+ "The output must use Gitmoji format:\n#{COMMIT_FORMATS['gitmoji']}"
529
+ else
530
+ ''
531
+ end
532
+
533
+ system_content = [
534
+ 'You are given multiple commit messages generated from different segments of a single large git diff.',
535
+ 'Synthesize them into ONE concise, unified git commit message that captures the overall change.',
536
+ 'Rules:',
537
+ '- Maximum 100 characters.',
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.',
541
+ format_instruction
542
+ ].reject(&:empty?).join("\n")
543
+
544
+ messages = [
545
+ { role: 'system', content: system_content },
546
+ { role: 'user', content: "Synthesize these segment commit messages into one:\n\n#{numbered}" }
547
+ ]
548
+
549
+ provider_config = ConfigManager.get_active_provider_config
550
+ can_disable_reasoning = provider_config.key?('can_disable_reasoning') ? provider_config['can_disable_reasoning'] : true
551
+
552
+ payload = {
553
+ model: @model,
554
+ messages: messages,
555
+ temperature: 0.5,
556
+ stream: true
557
+ }
558
+
559
+ if can_disable_reasoning
560
+ payload[:disable_reasoning] = true
561
+ payload[:max_tokens] = 300
562
+ else
563
+ payload[:max_tokens] = provider_config['max_tokens'] || 2000
564
+ end
565
+
566
+ full_content = ''
567
+ printed_content_prefix = false
568
+ stop_stream = false
569
+
570
+ uri = URI("#{@base_url}/chat/completions")
571
+ http = Net::HTTP.new(uri.host, uri.port)
572
+ http.use_ssl = (uri.scheme == 'https')
573
+ http.read_timeout = 120
574
+
575
+ request = Net::HTTP::Post.new(uri)
576
+ request['Content-Type'] = 'application/json'
577
+ request['Authorization'] = "Bearer #{@api_key}" if @api_key
578
+ request.body = payload.to_json
579
+
580
+ begin
581
+ http.request(request) do |response|
582
+ if response.code != '200'
583
+ error_body = response.read_body
584
+ puts "āœ– API Error: #{error_body}".red
585
+ return nil
586
+ end
587
+
588
+ buffer = ''
589
+ response.read_body do |chunk|
590
+ break if stop_stream
591
+
592
+ buffer += chunk
593
+ while (line_end = buffer.index("\n"))
594
+ line = buffer.slice!(0, line_end + 1).strip
595
+ next if line.empty?
596
+ next unless line.start_with?('data: ')
597
+
598
+ data_str = line[6..]
599
+ next if data_str == '[DONE]'
600
+
601
+ begin
602
+ data = JSON.parse(data_str)
603
+ delta = data.dig('choices', 0, 'delta')
604
+ next unless delta
605
+
606
+ content_chunk = delta['content']
607
+ if content_chunk && !content_chunk.empty?
608
+ unless printed_content_prefix
609
+ print "\n✦ Commit message: git commit -am \"".green
610
+ printed_content_prefix = true
611
+ end
612
+
613
+ if full_content.length + content_chunk.length > 300
614
+ stop_stream = true
615
+ break
616
+ end
617
+
618
+ print content_chunk.green
619
+ full_content += content_chunk
620
+ $stdout.flush
621
+ end
622
+ rescue JSON::ParserError
623
+ # Partial JSON, wait for more data
624
+ end
625
+ end
626
+ end
627
+ end
628
+ rescue StandardError => e
629
+ puts "āœ– Error: #{e.message}".red
630
+ return nil
631
+ end
632
+
633
+ puts '"'.green if printed_content_prefix
634
+
635
+ if full_content.strip.empty?
636
+ puts 'āœ– No response from AI during synthesis.'.red
637
+ return nil
638
+ end
639
+
640
+ first_line = full_content.split("\n").map(&:strip).reject(&:empty?).first
641
+ first_line&.gsub(/\A["']|["']\z/, '') || ''
642
+ end
376
643
  end
377
644
  end
@@ -102,8 +102,8 @@ module CommitGpt
102
102
  save_local_config(local_config)
103
103
 
104
104
  # Remind user to add config.local.yml to .gitignore
105
- puts 'ā–² Generated default configuration files.'.green
106
- puts 'ā–² Remember to add ~/.config/commitgpt/config.local.yml to your .gitignore'.yellow
105
+ puts 'āœ” Generated default configuration files.'.green
106
+ puts '⚠ Remember to add ~/.config/commitgpt/config.local.yml to your .gitignore'.yellow
107
107
  end
108
108
 
109
109
  # Get list of configured providers (with API keys)
@@ -141,6 +141,22 @@ module CommitGpt
141
141
  save_main_config(main_config)
142
142
  end
143
143
 
144
+ # Get commit format configuration
145
+ def get_commit_format
146
+ return 'simple' unless config_exists?
147
+
148
+ main_config = YAML.load_file(main_config_path)
149
+ main_config['commit_format'] || 'simple'
150
+ end
151
+
152
+ # Set commit format configuration
153
+ def set_commit_format(format)
154
+ ensure_config_dir
155
+ main_config = config_exists? ? YAML.load_file(main_config_path) : { 'providers' => [], 'active_provider' => '' }
156
+ main_config['commit_format'] = format
157
+ save_main_config(main_config)
158
+ end
159
+
144
160
  private
145
161
 
146
162
  # Merge main config with local config (local overrides main)
@@ -21,11 +21,11 @@ module CommitGpt
21
21
  choice = prompt_no_staged_changes
22
22
  case choice
23
23
  when :add_all
24
- puts 'ā–² Running git add .'.yellow
24
+ puts '→ Running git add .'.yellow
25
25
  system('git add .')
26
26
  diff_cached = `git diff --cached . #{exclusions}`.chomp
27
27
  if diff_cached.empty?
28
- puts 'ā–² Still no changes to commit.'.red
28
+ puts 'āœ– Still no changes to commit.'.red
29
29
  return nil
30
30
  end
31
31
  when :exit
@@ -33,7 +33,7 @@ module CommitGpt
33
33
  end
34
34
  else
35
35
  # Scenario: Mixed state (some staged, some not)
36
- puts 'ā–² You have both staged and unstaged changes:'.yellow
36
+ puts '⚠ You have both staged and unstaged changes:'.yellow
37
37
 
38
38
  staged_files = `git diff --cached --name-status . #{exclusions}`.chomp
39
39
  unstaged_files = `git diff --name-status . #{exclusions}`.chomp
@@ -54,7 +54,7 @@ module CommitGpt
54
54
 
55
55
  case choice
56
56
  when :add_all
57
- puts 'ā–² Running git add .'.yellow
57
+ puts '→ Running git add .'.yellow
58
58
  system('git add .')
59
59
  diff_cached = `git diff --cached . #{exclusions}`.chomp
60
60
  when :exit
@@ -67,7 +67,7 @@ module CommitGpt
67
67
  # git status --porcelain includes untracked files
68
68
  git_status = `git status --porcelain`.chomp
69
69
  if git_status.empty?
70
- puts 'ā–² No changes to commit. Working tree clean.'.yellow
70
+ puts '⚠ No changes to commit. Working tree clean.'.yellow
71
71
  return nil
72
72
  else
73
73
  # Only untracked files? Or ignored files?
@@ -76,7 +76,7 @@ module CommitGpt
76
76
  choice = prompt_no_staged_changes
77
77
  case choice
78
78
  when :add_all
79
- puts 'ā–² Running git add .'.yellow
79
+ puts '→ Running git add .'.yellow
80
80
  system('git add .')
81
81
  diff_cached = `git diff --cached . #{exclusions}`.chomp
82
82
  when :exit
@@ -93,11 +93,14 @@ module CommitGpt
93
93
  if diff.length > diff_len
94
94
  choice = prompt_diff_handling(diff.length, diff_len)
95
95
  case choice
96
+ when :chunked
97
+ @chunked_mode = true
98
+ puts "→ Smart chunked mode: splitting #{diff.length} chars into ~#{(diff.length.to_f / diff_len).ceil} segments...".yellow
96
99
  when :truncate
97
- puts "ā–² Truncating diff to #{diff_len} chars...".yellow
100
+ puts "→ Truncating diff to #{diff_len} chars...".yellow
98
101
  diff = diff[0...diff_len]
99
102
  when :unlimited
100
- puts "ā–² Using full diff (#{diff.length} chars)...".yellow
103
+ puts "→ Using full diff (#{diff.length} chars)...".yellow
101
104
  when :exit
102
105
  return nil
103
106
  end
@@ -107,7 +110,7 @@ module CommitGpt
107
110
  end
108
111
 
109
112
  def prompt_no_staged_changes
110
- puts 'ā–² No staged changes found (but unstaged/untracked files exist).'.yellow
113
+ puts '⚠ No staged changes found (but unstaged/untracked files exist).'.yellow
111
114
  prompt = TTY::Prompt.new
112
115
  begin
113
116
  prompt.select('Choose an option:') do |menu|
@@ -120,10 +123,11 @@ module CommitGpt
120
123
  end
121
124
 
122
125
  def prompt_diff_handling(current_len, max_len)
123
- puts "ā–² The diff is too large (#{current_len} chars, max #{max_len}).".yellow
126
+ puts "⚠ The diff is too large (#{current_len} chars, max #{max_len}).".yellow
124
127
  prompt = TTY::Prompt.new
125
128
  begin
126
129
  prompt.select('Choose an option:') do |menu|
130
+ menu.choice 'Smart chunked: split into segments and synthesize (recommended)', :chunked
127
131
  menu.choice "Use first #{max_len} characters to generate commit message", :truncate
128
132
  menu.choice 'Use unlimited characters (may fail or be slow)', :unlimited
129
133
  menu.choice 'Exit', :exit
@@ -133,6 +137,21 @@ module CommitGpt
133
137
  end
134
138
  end
135
139
 
140
+ def split_diff_by_length(diff, max_len)
141
+ chunks = []
142
+ current_chunk = ''
143
+
144
+ diff.each_line do |line|
145
+ if current_chunk.length + line.length > max_len && !current_chunk.empty?
146
+ chunks << current_chunk
147
+ current_chunk = ''
148
+ end
149
+ current_chunk += line
150
+ end
151
+ chunks << current_chunk unless current_chunk.empty?
152
+ chunks
153
+ end
154
+
136
155
  def detect_lock_file_changes
137
156
  # Check both staged and unstaged changes for lock files
138
157
  staged_files = `git diff --cached --name-only`.chomp.split("\n")
@@ -3,7 +3,7 @@
3
3
  module CommitGpt
4
4
  # Provider presets for common AI providers
5
5
  PROVIDER_PRESETS = [
6
- { label: 'Anthropic (Claude)', value: 'anthropic', base_url: 'https://api.anthropic.com/v1' },
6
+ { label: 'Anthropic Claude', value: 'anthropic', base_url: 'https://api.anthropic.com/v1' },
7
7
  { label: 'Cerebras', value: 'cerebras', base_url: 'https://api.cerebras.ai/v1' },
8
8
  { label: 'DeepSeek', value: 'deepseek', base_url: 'https://api.deepseek.com' },
9
9
  { label: 'Google AI', value: 'gemini', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai' },
@@ -28,7 +28,7 @@ module CommitGpt
28
28
  configured = ConfigManager.configured_providers
29
29
 
30
30
  if configured.empty?
31
- puts "ā–² No providers configured. Please run 'aicm setup' first.".red
31
+ puts "āœ– No providers configured. Please run 'aicm setup' first.".red
32
32
  return
33
33
  end
34
34
 
@@ -69,7 +69,7 @@ module CommitGpt
69
69
  provider_config = ConfigManager.get_active_provider_config
70
70
 
71
71
  if provider_config.nil? || provider_config['api_key'].nil? || provider_config['api_key'].empty?
72
- puts "ā–² No active provider configured. Please run 'aicm setup'.".red
72
+ puts "āœ– No active provider configured. Please run 'aicm setup'.".red
73
73
  return
74
74
  end
75
75
 
@@ -86,6 +86,22 @@ module CommitGpt
86
86
  puts "\nModel selected: #{model}".green
87
87
  end
88
88
 
89
+ # Choose commit message format
90
+ def choose_format
91
+ prompt = TTY::Prompt.new
92
+
93
+ puts "\n→ Choose git commit message format:\n".green
94
+
95
+ format = prompt.select('Select format:') do |menu|
96
+ menu.choice 'Simple - Concise commit message', 'simple'
97
+ menu.choice 'Conventional - Follow Conventional Commits specification', 'conventional'
98
+ menu.choice 'Gitmoji - Use Gitmoji emoji standard', 'gitmoji'
99
+ end
100
+
101
+ ConfigManager.set_commit_format(format)
102
+ puts "\nāœ” Commit format set to: #{format}".green
103
+ end
104
+
89
105
  private
90
106
 
91
107
  # Select provider from list
@@ -248,20 +264,20 @@ module CommitGpt
248
264
  models = response['data'] || []
249
265
  models = models.map { |m| m['id'] }.compact.sort
250
266
  else
251
- puts "ā–² Failed to fetch models: HTTP #{response.code}".red
267
+ puts "āœ– Failed to fetch models: HTTP #{response.code}".red
252
268
  return nil
253
269
  end
254
270
  end
255
271
  rescue Timeout::Error
256
- puts 'ā–² Connection timeout (5s). Please check your network, base_url, and api_key.'.red
272
+ puts 'āœ– Connection timeout (5s). Please check your network, base_url, and api_key.'.red
257
273
  exit(0)
258
274
  rescue StandardError => e
259
- puts "ā–² Error fetching models: #{e.message}".red
275
+ puts "āœ– Error fetching models: #{e.message}".red
260
276
  exit(0)
261
277
  end
262
278
 
263
279
  if models.nil? || models.empty?
264
- puts 'ā–² No models found. Please check your configuration.'.red
280
+ puts 'āœ– No models found. Please check your configuration.'.red
265
281
  exit(0)
266
282
  end
267
283
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CommitGpt
4
- VERSION = '0.3.4'
4
+ VERSION = '0.3.6'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: commitgpt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.4
4
+ version: 0.3.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peng Zhang