commitgpt 0.3.1 → 0.3.3

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.
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "httparty"
4
- require "net/http"
5
- require "uri"
6
- require "json"
7
- require "io/console"
8
- require "tty-prompt"
9
- require_relative "string"
10
- require_relative "config_manager"
3
+ require 'httparty'
4
+ require 'net/http'
5
+ require 'uri'
6
+ require 'json'
7
+ require 'io/console'
8
+ require 'tty-prompt'
9
+ require_relative 'string'
10
+ require_relative 'config_manager'
11
11
 
12
12
  # CommitGpt based on GPT-3
13
13
  module CommitGpt
@@ -17,17 +17,17 @@ module CommitGpt
17
17
 
18
18
  def initialize
19
19
  provider_config = ConfigManager.get_active_provider_config
20
-
20
+
21
21
  if provider_config
22
- @api_key = provider_config["api_key"]
23
- @base_url = provider_config["base_url"]
24
- @model = provider_config["model"]
25
- @diff_len = provider_config["diff_len"] || 32768
22
+ @api_key = provider_config['api_key']
23
+ @base_url = provider_config['base_url']
24
+ @model = provider_config['model']
25
+ @diff_len = provider_config['diff_len'] || 32_768
26
26
  else
27
27
  @api_key = nil
28
28
  @base_url = nil
29
29
  @model = nil
30
- @diff_len = 32768
30
+ @diff_len = 32_768
31
31
  end
32
32
  end
33
33
 
@@ -47,7 +47,7 @@ module CommitGpt
47
47
  case action
48
48
  when :commit
49
49
  commit_command = "git commit -m \"#{ai_commit_message}\""
50
- puts "\n▲ Executing: #{commit_command}".orange
50
+ puts "\n▲ Executing: #{commit_command}".yellow
51
51
  system(commit_command)
52
52
  puts "\n\n"
53
53
  puts `git log -1`
@@ -57,18 +57,18 @@ module CommitGpt
57
57
  next
58
58
  when :edit
59
59
  prompt = TTY::Prompt.new
60
- new_message = prompt.ask("Enter your commit message:")
60
+ new_message = prompt.ask('Enter your commit message:')
61
61
  if new_message && !new_message.strip.empty?
62
62
  commit_command = "git commit -m \"#{new_message}\""
63
63
  system(commit_command)
64
64
  puts "\n"
65
65
  puts `git log -1`
66
66
  else
67
- puts "▲ Commit aborted (empty message).".red
67
+ puts '▲ Commit aborted (empty message).'.red
68
68
  end
69
69
  break
70
70
  when :exit
71
- puts "▲ Exit without commit.".yellow
71
+ puts '▲ Exit without commit.'.yellow
72
72
  break
73
73
  end
74
74
  end
@@ -76,15 +76,15 @@ module CommitGpt
76
76
 
77
77
  def list_models
78
78
  headers = {
79
- "Content-Type" => "application/json",
80
- "User-Agent" => "Ruby/#{RUBY_VERSION}"
79
+ 'Content-Type' => 'application/json',
80
+ 'User-Agent' => "Ruby/#{RUBY_VERSION}"
81
81
  }
82
- headers["Authorization"] = "Bearer #{@api_key}" if @api_key
82
+ headers['Authorization'] = "Bearer #{@api_key}" if @api_key
83
83
 
84
84
  begin
85
85
  response = HTTParty.get("#{@base_url}/models", headers: headers)
86
- models = response["data"] || []
87
- models.each { |m| puts m["id"] }
86
+ models = response['data'] || []
87
+ models.each { |m| puts m['id'] }
88
88
  rescue StandardError => e
89
89
  puts "▲ Failed to list models: #{e.message}".red
90
90
  end
@@ -92,15 +92,15 @@ module CommitGpt
92
92
 
93
93
  def list_models
94
94
  headers = {
95
- "Content-Type" => "application/json",
96
- "User-Agent" => "Ruby/#{RUBY_VERSION}"
95
+ 'Content-Type' => 'application/json',
96
+ 'User-Agent' => "Ruby/#{RUBY_VERSION}"
97
97
  }
98
- headers["Authorization"] = "Bearer #{AICM_KEY}" if AICM_KEY
98
+ headers['Authorization'] = "Bearer #{AICM_KEY}" if AICM_KEY
99
99
 
100
100
  begin
101
101
  response = HTTParty.get("#{AICM_LINK}/models", headers: headers)
102
- models = response["data"] || []
103
- models.each { |m| puts m["id"] }
102
+ models = response['data'] || []
103
+ models.each { |m| puts m['id'] }
104
104
  rescue StandardError => e
105
105
  puts "▲ Failed to list models: #{e.message}".red
106
106
  end
@@ -108,15 +108,15 @@ module CommitGpt
108
108
 
109
109
  private
110
110
 
111
- def confirm_commit(message)
111
+ def confirm_commit(_message)
112
112
  prompt = TTY::Prompt.new
113
-
113
+
114
114
  begin
115
- prompt.select("Action:") do |menu|
116
- menu.choice "Commit", :commit
117
- menu.choice "Regenerate", :regenerate
118
- menu.choice "Edit", :edit
119
- menu.choice "Exit without commit", :exit
115
+ prompt.select('Action:') do |menu|
116
+ menu.choice 'Commit', :commit
117
+ menu.choice 'Regenerate', :regenerate
118
+ menu.choice 'Edit', :edit
119
+ menu.choice 'Exit without commit', :exit
120
120
  end
121
121
  rescue TTY::Reader::InputInterrupt, Interrupt
122
122
  :exit
@@ -133,47 +133,47 @@ module CommitGpt
133
133
  diff_unstaged = `git diff . #{exclusions}`.chomp
134
134
 
135
135
  if !diff_unstaged.empty?
136
- if !diff_cached.empty?
136
+ if diff_cached.empty?
137
+ # Scenario: Only unstaged changes
138
+ choice = prompt_no_staged_changes
139
+ case choice
140
+ when :add_all
141
+ puts '▲ Running git add .'.yellow
142
+ system('git add .')
143
+ diff_cached = `git diff --cached . #{exclusions}`.chomp
144
+ if diff_cached.empty?
145
+ puts '▲ Still no changes to commit.'.red
146
+ return nil
147
+ end
148
+ when :exit
149
+ return nil
150
+ end
151
+ else
137
152
  # Scenario: Mixed state (some staged, some not)
138
- puts "▲ You have both staged and unstaged changes:".yellow
139
-
153
+ puts '▲ You have both staged and unstaged changes:'.yellow
154
+
140
155
  staged_files = `git diff --cached --name-status . #{exclusions}`.chomp
141
156
  unstaged_files = `git diff --name-status . #{exclusions}`.chomp
142
157
 
143
158
  puts "\n #{'Staged changes:'.green}"
144
- puts staged_files.gsub(/^/, " ")
159
+ puts staged_files.gsub(/^/, ' ')
145
160
 
146
161
  puts "\n #{'Unstaged changes:'.red}"
147
- puts unstaged_files.gsub(/^/, " ")
148
- puts ""
162
+ puts unstaged_files.gsub(/^/, ' ')
163
+ puts ''
149
164
 
150
165
  prompt = TTY::Prompt.new
151
- choice = prompt.select("How to proceed?") do |menu|
152
- menu.choice "Include unstaged changes (git add .)", :add_all
153
- menu.choice "Use staged changes only", :staged_only
154
- menu.choice "Exit", :exit
166
+ choice = prompt.select('How to proceed?') do |menu|
167
+ menu.choice 'Include unstaged changes (git add .)', :add_all
168
+ menu.choice 'Use staged changes only', :staged_only
169
+ menu.choice 'Exit', :exit
155
170
  end
156
171
 
157
172
  case choice
158
173
  when :add_all
159
- puts "▲ Running git add .".yellow
160
- system("git add .")
161
- diff_cached = `git diff --cached . #{exclusions}`.chomp
162
- when :exit
163
- return nil
164
- end
165
- else
166
- # Scenario: Only unstaged changes
167
- choice = prompt_no_staged_changes
168
- case choice
169
- when :add_all
170
- puts "▲ Running git add .".yellow
171
- system("git add .")
174
+ puts '▲ Running git add .'.yellow
175
+ system('git add .')
172
176
  diff_cached = `git diff --cached . #{exclusions}`.chomp
173
- if diff_cached.empty?
174
- puts "▲ Still no changes to commit.".red
175
- return nil
176
- end
177
177
  when :exit
178
178
  return nil
179
179
  end
@@ -184,7 +184,7 @@ module CommitGpt
184
184
  # git status --porcelain includes untracked files
185
185
  git_status = `git status --porcelain`.chomp
186
186
  if git_status.empty?
187
- puts "▲ No changes to commit. Working tree clean.".yellow
187
+ puts '▲ No changes to commit. Working tree clean.'.yellow
188
188
  return nil
189
189
  else
190
190
  # Only untracked files? Or ignored files?
@@ -193,11 +193,11 @@ module CommitGpt
193
193
  choice = prompt_no_staged_changes
194
194
  case choice
195
195
  when :add_all
196
- puts "▲ Running git add .".yellow
197
- system("git add .")
196
+ puts '▲ Running git add .'.yellow
197
+ system('git add .')
198
198
  diff_cached = `git diff --cached . #{exclusions}`.chomp
199
199
  when :exit
200
- return nil
200
+ return nil
201
201
  end
202
202
  end
203
203
  end
@@ -221,12 +221,12 @@ module CommitGpt
221
221
  end
222
222
 
223
223
  def prompt_no_staged_changes
224
- puts "▲ No staged changes found (but unstaged/untracked files exist).".yellow
224
+ puts '▲ No staged changes found (but unstaged/untracked files exist).'.yellow
225
225
  prompt = TTY::Prompt.new
226
226
  begin
227
- prompt.select("Choose an option:") do |menu|
227
+ prompt.select('Choose an option:') do |menu|
228
228
  menu.choice "Run 'git add .' to stage all changes", :add_all
229
- menu.choice "Exit (stage files manually)", :exit
229
+ menu.choice 'Exit (stage files manually)', :exit
230
230
  end
231
231
  rescue TTY::Reader::InputInterrupt, Interrupt
232
232
  :exit
@@ -237,10 +237,10 @@ module CommitGpt
237
237
  puts "▲ The diff is too large (#{current_len} chars, max #{max_len}).".yellow
238
238
  prompt = TTY::Prompt.new
239
239
  begin
240
- prompt.select("Choose an option:") do |menu|
240
+ prompt.select('Choose an option:') do |menu|
241
241
  menu.choice "Use first #{max_len} characters to generate commit message", :truncate
242
- menu.choice "Use unlimited characters (may fail or be slow)", :unlimited
243
- menu.choice "Exit", :exit
242
+ menu.choice 'Use unlimited characters (may fail or be slow)', :unlimited
243
+ menu.choice 'Exit', :exit
244
244
  end
245
245
  rescue TTY::Reader::InputInterrupt, Interrupt
246
246
  :exit
@@ -252,7 +252,7 @@ module CommitGpt
252
252
 
253
253
  # Check if config exists
254
254
  unless ConfigManager.config_exists?
255
- puts "▲ Configuration not found. Generating default config...".yellow
255
+ puts '▲ Configuration not found. Generating default config...'.yellow
256
256
  ConfigManager.generate_default_configs
257
257
  puts "▲ Please run 'aicm setup' to configure your provider.".red
258
258
  return false
@@ -272,36 +272,36 @@ module CommitGpt
272
272
  begin
273
273
  `git rev-parse --is-inside-work-tree`
274
274
  rescue StandardError
275
- puts "▲ This is not a git repository".red
275
+ puts '▲ This is not a git repository'.red
276
276
  return false
277
277
  end
278
278
 
279
279
  true
280
280
  end
281
281
 
282
- def generate_commit(diff = "")
282
+ def generate_commit(diff = '')
283
283
  messages = [
284
284
  {
285
- role: "system",
286
- 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. " \
285
+ role: 'system',
286
+ 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. ' \
287
287
  "Message language: English. Rules:\n" \
288
288
  "- Commit message must be a maximum of 100 characters.\n" \
289
289
  "- Exclude anything unnecessary such as translation. Your entire response will be passed directly into git commit.\n" \
290
290
  "- 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" \
291
291
  "- Be specific: include concrete details (package names, versions, functionality) rather than generic statements. \n" \
292
- "- Return ONLY the commit message, nothing else."
292
+ '- Return ONLY the commit message, nothing else.'
293
293
  },
294
294
  {
295
- role: "user",
295
+ role: 'user',
296
296
  content: "Generate a commit message for the following git diff:\n\n#{diff}"
297
297
  }
298
298
  ]
299
299
 
300
300
  # Check config for disable_reasoning support (default true if not set)
301
301
  provider_config = ConfigManager.get_active_provider_config
302
- can_disable_reasoning = provider_config.key?("can_disable_reasoning") ? provider_config["can_disable_reasoning"] : true
302
+ can_disable_reasoning = provider_config.key?('can_disable_reasoning') ? provider_config['can_disable_reasoning'] : true
303
303
  # Get configured max_tokens or default to 2000
304
- configured_max_tokens = provider_config["max_tokens"] || 2000
304
+ configured_max_tokens = provider_config['max_tokens'] || 2000
305
305
 
306
306
  payload = {
307
307
  model: @model,
@@ -318,33 +318,37 @@ module CommitGpt
318
318
  end
319
319
 
320
320
  # Initial UI feedback (only on first try)
321
- puts "....... Generating your AI commit message ......".gray unless defined?(@is_retrying) && @is_retrying
321
+ puts '....... Generating your AI commit message ......'.gray unless defined?(@is_retrying) && @is_retrying
322
322
 
323
- full_content = ""
324
- full_reasoning = ""
323
+ full_content = ''
324
+ full_reasoning = ''
325
325
  printed_reasoning = false
326
326
  printed_content_prefix = false
327
327
  stop_stream = false
328
-
328
+
329
329
  uri = URI("#{@base_url}/chat/completions")
330
330
  http = Net::HTTP.new(uri.host, uri.port)
331
- http.use_ssl = (uri.scheme == "https")
331
+ http.use_ssl = (uri.scheme == 'https')
332
332
  http.read_timeout = 120
333
333
 
334
334
  request = Net::HTTP::Post.new(uri)
335
- request["Content-Type"] = "application/json"
336
- request["Authorization"] = "Bearer #{@api_key}" if @api_key
335
+ request['Content-Type'] = 'application/json'
336
+ request['Authorization'] = "Bearer #{@api_key}" if @api_key
337
337
  request.body = payload.to_json
338
338
 
339
339
  begin
340
340
  http.request(request) do |response|
341
- if response.code != "200"
341
+ if response.code != '200'
342
342
  # Parse error body
343
- error_body = response.read_body
344
- result = JSON.parse(error_body) rescue nil
345
-
343
+ error_body = response.read_body
344
+ result = begin
345
+ JSON.parse(error_body)
346
+ rescue StandardError
347
+ nil
348
+ end
349
+
346
350
  error_msg = if result
347
- result.dig("error", "message") || result["error"] || result["message"]
351
+ result.dig('error', 'message') || result['error'] || result['message']
348
352
  else
349
353
  error_body
350
354
  end
@@ -353,10 +357,10 @@ module CommitGpt
353
357
  error_msg = "HTTP #{response.code}"
354
358
  error_msg += " Raw: #{error_body}" unless error_body.to_s.strip.empty?
355
359
  end
356
-
357
- if can_disable_reasoning && (error_msg =~ /parameter|reasoning|unsupported/i || response.code == "400")
360
+
361
+ if can_disable_reasoning && (error_msg =~ /parameter|reasoning|unsupported/i || response.code == '400')
358
362
  puts "▲ Provider does not support 'disable_reasoning'. Updating config and retrying...".yellow
359
- ConfigManager.update_provider(provider_config["name"], { "can_disable_reasoning" => false })
363
+ ConfigManager.update_provider(provider_config['name'], { 'can_disable_reasoning' => false })
360
364
  @is_retrying = true
361
365
  return generate_commit(diff)
362
366
  else
@@ -364,9 +368,9 @@ module CommitGpt
364
368
  return nil
365
369
  end
366
370
  end
367
-
371
+
368
372
  # Process Streaming Response
369
- buffer = ""
373
+ buffer = ''
370
374
  response.read_body do |chunk|
371
375
  break if stop_stream
372
376
 
@@ -374,22 +378,20 @@ module CommitGpt
374
378
  while (line_end = buffer.index("\n"))
375
379
  line = buffer.slice!(0, line_end + 1).strip
376
380
  next if line.empty?
377
- next unless line.start_with?("data: ")
381
+ next unless line.start_with?('data: ')
378
382
 
379
- data_str = line[6..-1]
380
- next if data_str == "[DONE]"
383
+ data_str = line[6..]
384
+ next if data_str == '[DONE]'
381
385
 
382
386
  begin
383
387
  data = JSON.parse(data_str)
384
- delta = data.dig("choices", 0, "delta")
388
+ delta = data.dig('choices', 0, 'delta')
385
389
  next unless delta
386
390
 
387
391
  # Handle Reasoning
388
- reasoning_chunk = delta["reasoning_content"] || delta["reasoning"]
392
+ reasoning_chunk = delta['reasoning_content'] || delta['reasoning']
389
393
  if reasoning_chunk && !reasoning_chunk.empty?
390
- unless printed_reasoning
391
- puts "\nThinking...".gray
392
- end
394
+ puts "\nThinking...".gray unless printed_reasoning
393
395
  print reasoning_chunk.gray
394
396
  full_reasoning += reasoning_chunk
395
397
  printed_reasoning = true
@@ -397,17 +399,17 @@ module CommitGpt
397
399
  end
398
400
 
399
401
  # Handle Content
400
- content_chunk = delta["content"]
402
+ content_chunk = delta['content']
401
403
  if content_chunk && !content_chunk.empty?
402
404
  if printed_reasoning && !printed_content_prefix
403
- puts "" # Newline after reasoning block
405
+ puts '' # Newline after reasoning block
404
406
  end
405
-
407
+
406
408
  unless printed_content_prefix
407
409
  print "\n▲ Commit message: git commit -am \"".green
408
410
  printed_content_prefix = true
409
411
  end
410
-
412
+
411
413
  # Prevent infinite loops/repetitive garbage
412
414
  if full_content.length + content_chunk.length > 300
413
415
  stop_stream = true
@@ -418,12 +420,9 @@ module CommitGpt
418
420
  full_content += content_chunk
419
421
  $stdout.flush
420
422
  end
421
-
422
- # Handle Usage (some providers send usage at the end)
423
- if data["usage"]
424
- @last_usage = data["usage"]
425
- end
426
423
 
424
+ # Handle Usage (some providers send usage at the end)
425
+ @last_usage = data['usage'] if data['usage']
427
426
  rescue JSON::ParserError
428
427
  # Partial JSON, wait for more data
429
428
  end
@@ -434,38 +433,38 @@ module CommitGpt
434
433
  puts "▲ Error: #{e.message}".red
435
434
  return nil
436
435
  end
437
-
436
+
438
437
  # Close the quote
439
- puts "\"".green if printed_content_prefix
438
+ puts '"'.green if printed_content_prefix
440
439
 
441
440
  # Post-processing Logic (Retry if empty content)
442
441
  if (full_content.nil? || full_content.strip.empty?) && (full_reasoning && !full_reasoning.strip.empty?)
443
442
  if can_disable_reasoning
444
443
  puts "\n▲ Model returned reasoning despite 'disable_reasoning: true'. Updating config and retrying...".yellow
445
- ConfigManager.update_provider(provider_config["name"], { "can_disable_reasoning" => false })
444
+ ConfigManager.update_provider(provider_config['name'], { 'can_disable_reasoning' => false })
446
445
  @is_retrying = true
447
446
  return generate_commit(diff)
448
447
  else
449
448
  puts "\n▲ Model output truncated (Reasoning consumed all #{configured_max_tokens} tokens).".red
450
449
  prompt = TTY::Prompt.new
451
- choice = prompt.select("Choose an action:") do |menu|
450
+ choice = prompt.select('Choose an action:') do |menu|
452
451
  menu.choice "Double max_tokens to #{configured_max_tokens * 2}", :double
453
- menu.choice "Set custom max_tokens...", :custom
454
- menu.choice "Abort", :abort
452
+ menu.choice 'Set custom max_tokens...', :custom
453
+ menu.choice 'Abort', :abort
455
454
  end
456
455
 
457
456
  new_max = case choice
458
457
  when :double
459
458
  configured_max_tokens * 2
460
459
  when :custom
461
- prompt.ask("Enter new max_tokens:", convert: :int)
460
+ prompt.ask('Enter new max_tokens:', convert: :int)
462
461
  when :abort
463
462
  return nil
464
463
  end
465
-
464
+
466
465
  if new_max
467
466
  puts "▲ Updating max_tokens to #{new_max} and retrying...".yellow
468
- ConfigManager.update_provider(provider_config["name"], { "max_tokens" => new_max })
467
+ ConfigManager.update_provider(provider_config['name'], { 'max_tokens' => new_max })
469
468
  @is_retrying = true
470
469
  return generate_commit(diff)
471
470
  end
@@ -474,10 +473,10 @@ module CommitGpt
474
473
  end
475
474
 
476
475
  if full_content.empty? && full_reasoning.empty?
477
- puts "▲ No response from AI.".red
476
+ puts '▲ No response from AI.'.red
478
477
  return nil
479
478
  end
480
-
479
+
481
480
  # Print usage info if available (saved from stream or approximated)
482
481
  if defined?(@last_usage) && @last_usage
483
482
  puts "\n...... Tokens: #{@last_usage['total_tokens']} (Prompt: #{@last_usage['prompt_tokens']}, Completion: #{@last_usage['completion_tokens']})\n\n".gray
@@ -489,7 +488,7 @@ module CommitGpt
489
488
 
490
489
  # Take only the first non-empty line to avoid repetition or multi-line garbage
491
490
  first_line = full_content.split("\n").map(&:strip).reject(&:empty?).first
492
- first_line&.gsub(/\A["']|["']\z/, "") || ""
491
+ first_line&.gsub(/\A["']|["']\z/, '') || ''
493
492
  end
494
493
  end
495
494
  end