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 +4 -4
- data/README.md +42 -1
- data/lib/commitgpt/cli.rb +6 -0
- data/lib/commitgpt/commit_ai.rb +307 -40
- data/lib/commitgpt/config_manager.rb +18 -2
- data/lib/commitgpt/diff_helpers.rb +29 -10
- data/lib/commitgpt/provider_presets.rb +1 -1
- data/lib/commitgpt/setup_wizard.rb +22 -6
- 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: a1e1fcc474c778dff2c10f370b5a291483793d0ded5c8d43beaab1b7e377b4ff
|
|
4
|
+
data.tar.gz: 17ec9d1daaf3ad085adbd8566173411db284995188b4c97d5eaf4d69a634763d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 ''
|
data/lib/commitgpt/commit_ai.rb
CHANGED
|
@@ -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 "
|
|
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
|
|
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 '
|
|
171
|
+
puts 'ā Commit aborted (empty message).'.red
|
|
71
172
|
end
|
|
72
173
|
break
|
|
73
174
|
when :exit
|
|
74
|
-
puts '
|
|
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 "
|
|
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 "
|
|
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
|
|
254
|
+
puts "\n⦠Welcome to AI Commits!".green
|
|
135
255
|
|
|
136
256
|
# Check if config exists
|
|
137
257
|
unless ConfigManager.config_exists?
|
|
138
|
-
puts '
|
|
258
|
+
puts 'ā Configuration not found. Generating default config...'.yellow
|
|
139
259
|
ConfigManager.generate_default_configs
|
|
140
|
-
puts "
|
|
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 "
|
|
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 '
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 "
|
|
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 "
|
|
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
|
-
|
|
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 "
|
|
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
|
|
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
|
|
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 "
|
|
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 '
|
|
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 "
|
|
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 '
|
|
106
|
-
puts '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
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 "
|
|
100
|
+
puts "ā Truncating diff to #{diff_len} chars...".yellow
|
|
98
101
|
diff = diff[0...diff_len]
|
|
99
102
|
when :unlimited
|
|
100
|
-
puts "
|
|
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 '
|
|
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 "
|
|
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
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 '
|
|
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 "
|
|
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 '
|
|
280
|
+
puts 'ā No models found. Please check your configuration.'.red
|
|
265
281
|
exit(0)
|
|
266
282
|
end
|
|
267
283
|
|
data/lib/commitgpt/version.rb
CHANGED