commitcraft 0.2.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7e514536174168988c5ab69979cd03087275c27a070a08b75c764d04f9ee2d63
4
+ data.tar.gz: 0aee9c93f8a43c25f1fe46ba83daf73f70776f1a6db7258966a0052559d5e56c
5
+ SHA512:
6
+ metadata.gz: 0c663dcd43da6f431955d6825393449dc7c6312002cf199c16f41c4a13eb02ff2ef0d14b901a07e9b3ddd4a0f617db6469c06203d21b819e4e3191691469731f
7
+ data.tar.gz: e7eec0574248aebdeee7da1f1a9945ae55e393dce0c6e36b603299aee45c1a089e4e93838cb75f688139242c6ff5af2bb5ce93b4633bbb8ab165c63a71de563b
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CommitCraft
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,232 @@
1
+ # CommitCraft
2
+
3
+ **AI-powered git commit message generator using FREE Google Gemini API**
4
+
5
+ CommitCraft uses Google's Gemini AI to analyze your code changes and automatically generate meaningful, conventional commit messages. Completely FREE - no credit card required.
6
+
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+
9
+ ## Features
10
+
11
+ - 100% FREE - Uses Google's free Gemini API
12
+ - AI-Powered - Uses latest Gemini models to understand your code
13
+ - Multiple Suggestions - Get 3 different commit message options
14
+ - Multiple Styles - Conventional, Semantic, Descriptive, or Custom
15
+ - Jira Integration - Automatic ticket key prepending
16
+ - Interactive CLI - Beautiful terminal interface
17
+ - Context-Aware - Considers branch names, files, and history
18
+ - Fast & Easy - One command to generate and commit
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ gem install commitcraft
24
+ ```
25
+
26
+ Or add to your Gemfile:
27
+
28
+ ```ruby
29
+ gem 'commitcraft'
30
+ ```
31
+
32
+ ## Setup
33
+
34
+ ### Get Your FREE API Key
35
+
36
+ 1. Go to https://aistudio.google.com/app/apikey
37
+ 2. Click "Create API Key"
38
+ 3. Copy your key
39
+
40
+ ### Set Your API Key
41
+
42
+ ```bash
43
+ export GEMINI_API_KEY='your-key-here'
44
+
45
+ # Make it permanent:
46
+ echo 'export GEMINI_API_KEY="your-key"' >> ~/.bashrc
47
+ source ~/.bashrc
48
+ ```
49
+
50
+ ### Use It
51
+
52
+ ```bash
53
+ cd your-project
54
+ git add .
55
+ commitcraft generate
56
+ ```
57
+
58
+ ## Usage
59
+
60
+ ### Basic Usage
61
+
62
+ ```bash
63
+ git add .
64
+ commitcraft generate
65
+ ```
66
+
67
+ CommitCraft will:
68
+ - Analyze your staged changes
69
+ - Generate 3 commit message suggestions
70
+ - Let you choose one (or write your own)
71
+ - Commit with your selected message
72
+
73
+ ### Command Options
74
+
75
+ ```bash
76
+ commitcraft generate --auto-commit # Auto-commit with first suggestion
77
+ commitcraft generate --style semantic # Use specific commit style
78
+ commitcraft generate --all # Include all changes
79
+ commitcraft generate --amend # Amend previous commit
80
+ commitcraft generate --include-history # Include commit history as context
81
+ commitcraft generate --jira CF-123 # Add Jira ticket key
82
+ ```
83
+
84
+ ### Jira Integration
85
+
86
+ Automatically prepend Jira ticket keys to your commit messages:
87
+
88
+ ```bash
89
+ # One-time Jira key
90
+ commitcraft generate --jira CF-123
91
+
92
+ # Short form
93
+ commitcraft generate -j CF-123
94
+
95
+ # Combined with other options
96
+ commitcraft generate -j CF-123 -s semantic -y
97
+ ```
98
+
99
+ **Output:**
100
+ ```
101
+ Choose a commit message:
102
+ [CF-123] feat(auth): add OAuth2 authentication
103
+ [CF-123] feat: implement user login system
104
+ [CF-123] Add authentication with OAuth2
105
+ ```
106
+
107
+ **Set default Jira prefix:**
108
+ ```bash
109
+ # Set once for all commits
110
+ commitcraft config --jira-prefix CF-123
111
+
112
+ # Now every commit includes CF-123 automatically
113
+ commitcraft generate
114
+ ```
115
+
116
+ **Supported formats:**
117
+ - `PROJECT-123`
118
+ - `ABC-456`
119
+ - `JIRA-789`
120
+
121
+ ### Commit Styles
122
+
123
+ **Conventional (default)**
124
+ ```
125
+ feat(auth): add OAuth2 login flow
126
+ fix(api): resolve null pointer error
127
+ docs(readme): update installation steps
128
+ ```
129
+
130
+ **Semantic**
131
+ ```
132
+ Add user authentication middleware
133
+ Fix memory leak in background worker
134
+ Update dependencies to latest versions
135
+ ```
136
+
137
+ **Descriptive**
138
+ ```
139
+ Implement caching to improve response time by 40%
140
+ Refactor database queries to eliminate N+1 problems
141
+ Add comprehensive error handling for edge cases
142
+ ```
143
+
144
+ ## Configuration
145
+
146
+ ```bash
147
+ commitcraft config --show # Show current config
148
+ commitcraft config --style conventional # Set default style
149
+ commitcraft config --model gemini-2.5-flash # Set AI model
150
+ commitcraft config --jira-prefix CF-123 # Set default Jira prefix
151
+ commitcraft status # View git status
152
+ commitcraft version # Check version
153
+ ```
154
+
155
+ ### Available Models (All FREE)
156
+
157
+ - `gemini-2.5-flash` (default) - Best balance
158
+ - `gemini-2.5-pro` - Highest quality
159
+ - `gemini-2.5-flash-lite` - Fastest
160
+ - `gemini-2.0-flash` - Fast and versatile
161
+ - `gemini-flash-latest` - Auto-updates to latest
162
+
163
+ ### Git Alias
164
+
165
+ Add to `~/.gitconfig`:
166
+
167
+ ```ini
168
+ [alias]
169
+ ai = !commitcraft generate
170
+ aic = !commitcraft generate --auto-commit
171
+ ```
172
+
173
+ Then use:
174
+ ```bash
175
+ git ai # Interactive mode
176
+ git aic # Auto-commit
177
+ ```
178
+
179
+ ### Set Default for Long-Running Work
180
+ ```bash
181
+ # Working on CF-123 all week
182
+ commitcraft config --jira-prefix CF-123
183
+
184
+ # All commits include CF-123
185
+ git add file1.rb && commitcraft generate -y
186
+ git add file2.rb && commitcraft generate -y
187
+ git add file3.rb && commitcraft generate -y
188
+ ```
189
+
190
+ ### Git History with Jira
191
+ ```bash
192
+ $ git log --oneline
193
+
194
+ a1b2c3d [CF-123] feat(auth): add OAuth2 authentication
195
+ d4e5f6g [CF-123] test(auth): add integration tests
196
+ h7i8j9k [CF-456] fix(api): resolve validation error
197
+ l0m1n2o [CF-456] docs(api): update API documentation
198
+ ```
199
+
200
+ **Filter by ticket:**
201
+ ```bash
202
+ git log --grep="CF-123" --oneline
203
+ ```
204
+
205
+ ## Rate Limits (Free Tier)
206
+
207
+ - 15 requests/minute
208
+ - 1 million tokens/minute
209
+ - 1,500 requests/day
210
+
211
+ ## Contributing
212
+
213
+ Bug reports and pull requests are welcome.
214
+
215
+ 1. Fork the repository
216
+ 2. Create your feature branch
217
+ 3. Commit your changes
218
+ 4. Push to the branch
219
+ 5. Create a Pull Request
220
+
221
+ ## License
222
+
223
+ MIT License - see LICENSE.txt
224
+
225
+ ## Tips
226
+
227
+ 1. **Stage related changes together** for better commit messages
228
+ 2. **Use --include-history** for context-aware suggestions
229
+ 3. **Try different styles** to match your team's conventions
230
+ 4. **Set default Jira prefix** for long-running feature work
231
+ 5. **Use --auto-commit** for quick, simple changes
232
+ 6. **Create git aliases** for faster workflow
data/exe/commitcraft ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/commitcraft"
5
+
6
+ begin
7
+ CommitCraft::CLI.start(ARGV)
8
+ rescue Interrupt
9
+ puts "\nInterrupted. Exiting..."
10
+ exit 130
11
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gemini-ai"
4
+
5
+ module CommitCraft
6
+ class AIClient
7
+ def initialize(config = CommitCraft.configuration)
8
+ @config = config
9
+ @config.validate!
10
+ @client = Gemini.new(
11
+ credentials: {
12
+ service: "generative-language-api",
13
+ api_key: @config.api_key
14
+ },
15
+ options: {
16
+ model: @config.model,
17
+ server_sent_events: true
18
+ }
19
+ )
20
+ end
21
+
22
+ def generate_commit_messages(diff, context = {})
23
+ prompt = build_prompt(diff, context)
24
+
25
+ result = @client.stream_generate_content({
26
+ contents: { role: "user", parts: { text: prompt } }
27
+ })
28
+
29
+ # Collect the full response
30
+ full_response = ""
31
+ result.each do |event|
32
+ next unless event.dig("candidates", 0, "content", "parts")
33
+
34
+ event.dig("candidates", 0, "content", "parts").each do |part|
35
+ full_response += part["text"] if part["text"]
36
+ end
37
+ end
38
+
39
+ parse_response(full_response)
40
+ rescue StandardError => e
41
+ raise AIError, "Failed to generate commit message: #{e.message}"
42
+ end
43
+
44
+ private
45
+
46
+ def build_prompt(diff, context)
47
+ style_instruction = style_instructions[@config.commit_style]
48
+
49
+ prompt = <<~PROMPT
50
+ You are an expert at writing clear, concise git commit messages.
51
+
52
+ #{style_instruction}
53
+
54
+ Please generate 3 different commit message options for the following changes.
55
+ Each message should be on its own line, numbered 1-3.
56
+
57
+ #{context_section(context)}
58
+
59
+ Git diff:
60
+ ```
61
+ #{diff}
62
+ ```
63
+
64
+ Generate exactly 3 commit messages, one per line, numbered 1-3.
65
+ PROMPT
66
+
67
+ prompt.strip
68
+ end
69
+
70
+ def context_section(context)
71
+ return "" if context.empty?
72
+
73
+ sections = []
74
+ sections << "Current branch: #{context[:branch]}" if context[:branch]
75
+ sections << "Files changed: #{context[:files].join(", ")}" if context[:files]&.any?
76
+ sections << "Recent commits:\n#{context[:recent_commits].join("\n")}" if context[:recent_commits]&.any?
77
+
78
+ return "" if sections.empty?
79
+
80
+ "Context:\n#{sections.join("\n")}\n"
81
+ end
82
+
83
+ def style_instructions
84
+ {
85
+ "conventional" => <<~STYLE,
86
+ Follow the Conventional Commits specification:
87
+ - Format: <type>(<scope>): <description>
88
+ - Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build
89
+ - Keep the description concise and in present tense
90
+ - Example: "feat(auth): add OAuth2 login flow"
91
+ STYLE
92
+ "semantic" => <<~STYLE,
93
+ Follow semantic commit format:
94
+ - Start with a verb in present tense (Add, Update, Fix, Remove, etc.)
95
+ - Be specific about what changed
96
+ - Example: "Add user authentication middleware"
97
+ STYLE
98
+ "descriptive" => <<~STYLE,
99
+ Write descriptive commit messages:
100
+ - Focus on the "what" and "why"
101
+ - Use complete sentences
102
+ - Example: "Implement caching to improve API response time by 40%"
103
+ STYLE
104
+ "custom" => <<~STYLE
105
+ Write clear, professional commit messages that accurately describe the changes.
106
+ STYLE
107
+ }
108
+ end
109
+
110
+ def parse_response(response)
111
+ return [] if response.nil? || response.empty?
112
+
113
+ messages = response.split("\n")
114
+ .map(&:strip)
115
+ .grep(/^\d+[.)]\s+/)
116
+ .map { |line| line.sub(/^\d+[.)]\s+/, "") }
117
+ .reject(&:empty?)
118
+
119
+ raise AIError, "Failed to parse commit messages from AI response" if messages.empty?
120
+
121
+ messages
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "tty-prompt"
5
+ require "tty-spinner"
6
+ require "pastel"
7
+
8
+ module CommitCraft
9
+ class CLI < Thor
10
+ def self.exit_on_failure?
11
+ true
12
+ end
13
+
14
+ desc "generate", "Generate AI-powered commit messages"
15
+ method_option :all, type: :boolean, aliases: "-a", desc: "Include all changes (staged and unstaged)"
16
+ method_option :unstaged, type: :boolean, aliases: "-u", desc: "Use unstaged changes only"
17
+ method_option :amend, type: :boolean, desc: "Amend the previous commit"
18
+ method_option :no_context, type: :boolean, desc: "Don't include git context (branch, files)"
19
+ method_option :include_history, type: :boolean, aliases: "-h", desc: "Include recent commit history as context"
20
+ method_option :auto_commit, type: :boolean, aliases: "-y", desc: "Automatically commit with the first suggestion"
21
+ method_option :style, type: :string, aliases: "-s",
22
+ desc: "Commit message style (conventional, semantic, descriptive, custom)"
23
+ method_option :jira, type: :string, aliases: '-j', desc: 'Jira ticket key (e.g., CF-123)'
24
+ def generate
25
+ setup_style(options[:style]) if options[:style]
26
+
27
+ generator = MessageGenerator.new
28
+ prompt = TTY::Prompt.new
29
+ pastel = Pastel.new
30
+
31
+ if options[:jira]
32
+ validate_jira_key!(options[:jira])
33
+ end
34
+
35
+ # Show test mode indicator
36
+ if CommitCraft.test_mode?
37
+ puts pastel.yellow("⚠️ TEST MODE - Using mock AI (no API calls)")
38
+ puts ""
39
+ end
40
+
41
+ # Show spinner while generating
42
+ spinner = TTY::Spinner.new("[:spinner] Analyzing changes and generating commit messages...", format: :dots)
43
+ spinner.auto_spin
44
+
45
+ begin
46
+ result = generator.generate(
47
+ all: options[:all],
48
+ unstaged: options[:unstaged],
49
+ no_context: options[:no_context],
50
+ include_history: options[:include_history],
51
+ jira: options[:jira]
52
+ )
53
+ rescue CommitCraft::Error => e
54
+ spinner.error("(#{pastel.red("failed")})")
55
+ puts pastel.red("Error: #{e.message}")
56
+ exit 1
57
+ end
58
+
59
+ spinner.success("(#{pastel.green("done")})")
60
+ puts
61
+
62
+ # Show diff summary
63
+ summary = result[:diff_summary]
64
+ puts pastel.dim("Files changed: #{summary[:files_changed]}, ") +
65
+ pastel.green("+#{summary[:additions]}") + pastel.dim(" / ") +
66
+ pastel.red("-#{summary[:deletions]}")
67
+ puts
68
+
69
+ # Show generated messages
70
+ messages = result[:messages]
71
+
72
+ if options[:auto_commit]
73
+ selected_message = messages.first
74
+ puts pastel.cyan("Auto-committing with: ") + pastel.white(selected_message)
75
+ else
76
+ choices = messages.map.with_index do |msg, _idx|
77
+ { name: msg, value: msg }
78
+ end
79
+ choices << { name: pastel.dim("✎ Write custom message"), value: :custom }
80
+ choices << { name: pastel.dim("✗ Cancel"), value: :cancel }
81
+
82
+ selected_message = prompt.select("Choose a commit message:", choices, per_page: 10)
83
+
84
+ case selected_message
85
+ when :custom
86
+ selected_message = prompt.ask("Enter your commit message:") do |q|
87
+ q.required true
88
+ q.validate(/\S+/)
89
+ end
90
+ when :cancel
91
+ puts pastel.yellow("Commit cancelled.")
92
+ exit 0
93
+ end
94
+ end
95
+
96
+ puts
97
+
98
+ # Confirm and commit
99
+ begin
100
+ generator.commit_with_message(selected_message, amend: options[:amend])
101
+ action = options[:amend] ? "amended" : "created"
102
+ puts pastel.green("✓ Commit #{action} successfully!")
103
+ puts pastel.dim(" Message: #{selected_message}")
104
+ rescue CommitCraft::GitError => e
105
+ puts pastel.red("Failed to commit: #{e.message}")
106
+ exit 1
107
+ end
108
+ end
109
+
110
+ desc "config", "Configure CommitCraft"
111
+ method_option :api_key, type: :string, desc: "Set Anthropic API key"
112
+ method_option :model, type: :string, desc: "Set AI model"
113
+ method_option :style, type: :string, desc: "Set default commit message style"
114
+ method_option :show, type: :boolean, desc: "Show current configuration"
115
+ method_option :jira_prefix, type: :string, desc: 'Set default Jira prefix'
116
+
117
+ def config
118
+ config_file = File.join(Dir.home, ".commitcraft.yml")
119
+
120
+ if options[:show]
121
+ show_config(config_file)
122
+ elsif options[:jira_prefix]
123
+ validate_jira_key!(options[:jira_prefix])
124
+ set_config_value('jira_prefix', options[:jira_prefix])
125
+ say pastel.green("✓ Default Jira prefix set to: #{options[:jira_prefix]}")
126
+ end
127
+
128
+ require "yaml"
129
+
130
+ current_config = File.exist?(config_file) ? YAML.load_file(config_file) : {}
131
+
132
+ current_config["api_key"] = options[:api_key] if options[:api_key]
133
+ current_config["model"] = options[:model] if options[:model]
134
+ current_config["style"] = options[:style] if options[:style]
135
+
136
+ File.write(config_file, current_config.to_yaml)
137
+
138
+ pastel = Pastel.new
139
+ puts pastel.green("✓ Configuration saved to #{config_file}")
140
+ end
141
+
142
+ desc "status", "Show git status and staged changes"
143
+ def status
144
+ git_client = GitClient.new
145
+ pastel = Pastel.new
146
+
147
+ begin
148
+ puts pastel.bold("Git Status:")
149
+ puts git_client.status
150
+ puts
151
+
152
+ staged_files = git_client.staged_files
153
+ if staged_files.any?
154
+ puts pastel.bold("Staged Files:")
155
+ staged_files.each { |file| puts pastel.green(" + #{file}") }
156
+ puts
157
+
158
+ stats = git_client.file_stats
159
+ total_add = stats.sum { |s| s[:additions] }
160
+ total_del = stats.sum { |s| s[:deletions] }
161
+ puts pastel.dim("Total changes: ") +
162
+ pastel.green("+#{total_add}") + pastel.dim(" / ") +
163
+ pastel.red("-#{total_del}")
164
+ else
165
+ puts pastel.yellow("No staged changes.")
166
+ end
167
+ rescue CommitCraft::GitError => e
168
+ puts pastel.red("Error: #{e.message}")
169
+ exit 1
170
+ end
171
+ end
172
+
173
+ desc "version", "Show CommitCraft version"
174
+ def version
175
+ puts "CommitCraft v#{CommitCraft::VERSION}"
176
+ end
177
+
178
+ private
179
+
180
+ def validate_jira_key!(key)
181
+ pastel = Pastel.new
182
+ unless key.match?(/^[A-Z]+-\d+$/)
183
+ say pastel.red("✗ Invalid Jira key format: #{key}")
184
+ say pastel.dim("Expected format: PROJECT-123 (e.g., CF-123, JIRA-456)")
185
+ exit 1
186
+ end
187
+ end
188
+
189
+ def setup_style(style)
190
+ CommitCraft.configure do |config|
191
+ config.commit_style = style
192
+ end
193
+ end
194
+
195
+ def show_config(config_file)
196
+ pastel = Pastel.new
197
+
198
+ if File.exist?(config_file)
199
+ require "yaml"
200
+ config = YAML.load_file(config_file)
201
+
202
+ puts pastel.bold("Current Configuration:")
203
+ puts pastel.dim("Location: #{config_file}")
204
+ puts
205
+ config.each do |key, value|
206
+ display_value = key == "api_key" ? "#{value[0..8]}..." : value
207
+ puts " #{pastel.cyan(key)}: #{display_value}"
208
+ end
209
+ else
210
+ puts pastel.yellow("No configuration file found.")
211
+ puts pastel.dim("Using environment variable: GEMINI_API_KEY")
212
+ end
213
+
214
+ puts
215
+ puts pastel.bold("Environment:")
216
+ api_key = ENV.fetch("GEMINI_API_KEY", nil)
217
+ puts " GEMINI_API_KEY: #{api_key ? pastel.green("Set") : pastel.red("Not set")}"
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommitCraft
4
+ class Configuration
5
+ attr_accessor :api_key, :model, :max_tokens, :temperature, :commit_style, :test_mode, :jira_prefix
6
+
7
+ SUPPORTED_MODELS = %w[
8
+ gemini-2.5-flash
9
+ gemini-2.5-pro
10
+ gemini-2.0-flash
11
+ gemini-flash-latest
12
+ gemini-pro-latest
13
+ gemini-2.5-flash-lite
14
+ ].freeze
15
+
16
+ COMMIT_STYLES = %w[
17
+ conventional
18
+ semantic
19
+ descriptive
20
+ custom
21
+ ].freeze
22
+
23
+ def initialize
24
+ load_config_file
25
+ @api_key ||= ENV.fetch("GEMINI_API_KEY", nil)
26
+ @model ||= "gemini-2.5-flash"
27
+ @max_tokens ||= 1000
28
+ @temperature ||= 0.7
29
+ @commit_style ||= "conventional"
30
+ @jira_prefix ||= nil
31
+ @test_mode = ENV["COMMITCRAFT_TEST_MODE"] == "true"
32
+ end
33
+
34
+ def validate!
35
+ return if @test_mode
36
+ raise ConfigurationError, "API key is required" if api_key.nil? || api_key.empty?
37
+ raise ConfigurationError, "Invalid model: #{model}" unless SUPPORTED_MODELS.include?(model)
38
+ raise ConfigurationError, "Invalid commit style: #{commit_style}" unless COMMIT_STYLES.include?(commit_style)
39
+ validate_jira_prefix! if jira_prefix
40
+ end
41
+
42
+ private
43
+
44
+ def load_config_file
45
+ config_file = File.join(Dir.home, ".commitcraft.yml")
46
+ return unless File.exist?(config_file)
47
+
48
+ require "yaml"
49
+ config = YAML.load_file(config_file)
50
+ @api_key = config["api_key"]
51
+ @model = config["model"]
52
+ @max_tokens = config["max_tokens"]
53
+ @temperature = config["temperature"]
54
+ @commit_style = config["style"]
55
+ @jira_prefix = config["jira_prefix"]
56
+ rescue StandardError
57
+ nil
58
+ end
59
+
60
+ def validate_jira_prefix!
61
+ unless jira_prefix.match?(/^[A-Z]+-\d+$/)
62
+ raise ConfigurationError, "Invalid Jira key format: #{jira_prefix}. Expected format: PROJECT-123"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommitCraft
4
+ class Error < StandardError; end
5
+ class ConfigurationError < Error; end
6
+ class GitError < Error; end
7
+ class AIError < Error; end
8
+ class NoChangesError < GitError; end
9
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ module CommitCraft
5
+ class GitClient
6
+ def initialize(repo_path: Dir.pwd)
7
+ @repo_path = repo_path
8
+ validate_git_repo!
9
+ end
10
+
11
+ def staged_diff
12
+ diff = execute_command("git diff --cached")
13
+ raise NoChangesError, "No staged changes found. Use 'git add' to stage your changes." if diff.empty?
14
+
15
+ diff
16
+ end
17
+
18
+ def unstaged_diff
19
+ execute_command("git diff")
20
+ end
21
+
22
+ def all_diff
23
+ execute_command("git diff HEAD")
24
+ end
25
+
26
+ def status
27
+ execute_command("git status --short")
28
+ end
29
+
30
+ def current_branch
31
+ execute_command("git branch --show-current").strip
32
+ end
33
+
34
+ def recent_commits(count = 5)
35
+ execute_command("git log -#{count} --oneline").split("\n")
36
+ end
37
+
38
+ def commit(message, amend: false)
39
+ escaped_message = message.gsub("'", "'\\''")
40
+ cmd = amend ? "git commit --amend -m '#{escaped_message}'" : "git commit -m '#{escaped_message}'"
41
+ execute_command(cmd)
42
+ end
43
+
44
+ def staged_files
45
+ execute_command("git diff --cached --name-only").split("\n")
46
+ end
47
+
48
+ def file_stats
49
+ stats = execute_command("git diff --cached --numstat")
50
+ parse_file_stats(stats)
51
+ end
52
+
53
+ private
54
+
55
+ def validate_git_repo!
56
+ Dir.chdir(@repo_path) do
57
+ `git rev-parse --git-dir 2>&1`
58
+ raise GitError, "Not a git repository: #{@repo_path}" unless $CHILD_STATUS.success?
59
+ end
60
+ end
61
+
62
+ def execute_command(command)
63
+ Dir.chdir(@repo_path) do
64
+ result = `#{command} 2>&1`
65
+ raise GitError, "Git command failed: #{result}" unless $CHILD_STATUS.success?
66
+
67
+ result
68
+ end
69
+ end
70
+
71
+ def parse_file_stats(stats)
72
+ return [] if stats.empty?
73
+
74
+ stats.split("\n").map do |line|
75
+ added, deleted, file = line.split("\t")
76
+ {
77
+ file: file,
78
+ additions: added.to_i,
79
+ deletions: deleted.to_i
80
+ }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommitCraft
4
+ class MessageGenerator
5
+ def initialize(git_client: nil, ai_client: nil)
6
+ @git_client = git_client || GitClient.new
7
+ @config = CommitCraft.configuration
8
+
9
+ @ai_client = if ai_client
10
+ ai_client
11
+ elsif CommitCraft.test_mode?
12
+ MockAIClient.new
13
+ else
14
+ AIClient.new
15
+ end
16
+ end
17
+
18
+ def generate(options = {})
19
+ diff = get_diff(options)
20
+ context = build_context(options)
21
+
22
+ jira_key = options[:jira] || @config.jira_prefix
23
+
24
+ messages = @ai_client.generate_commit_messages(diff, context)
25
+
26
+ if jira_key
27
+ messages = messages.map { |msg| format_with_jira(msg, jira_key) }
28
+ end
29
+
30
+ {
31
+ messages: messages,
32
+ context: context,
33
+ diff_summary: summarize_diff
34
+ }
35
+ end
36
+
37
+ def commit_with_message(message, amend: false)
38
+ @git_client.commit(message, amend: amend)
39
+ end
40
+
41
+ private
42
+
43
+ def format_with_jira(message, jira_key)
44
+ return message if message.match?(/^\[[A-Z]+-\d+\]/)
45
+
46
+ "[#{jira_key}] #{message}"
47
+ end
48
+
49
+ def get_diff(options)
50
+ if options[:all]
51
+ @git_client.all_diff
52
+ elsif options[:unstaged]
53
+ @git_client.unstaged_diff
54
+ else
55
+ @git_client.staged_diff
56
+ end
57
+ end
58
+
59
+ def build_context(options)
60
+ context = {}
61
+
62
+ unless options[:no_context]
63
+ context[:branch] = @git_client.current_branch
64
+ context[:files] = @git_client.staged_files
65
+ context[:recent_commits] = @git_client.recent_commits(3) if options[:include_history]
66
+ end
67
+
68
+ context
69
+ end
70
+
71
+ def summarize_diff
72
+ stats = @git_client.file_stats
73
+
74
+ total_additions = stats.sum { |s| s[:additions] }
75
+ total_deletions = stats.sum { |s| s[:deletions] }
76
+
77
+ {
78
+ files_changed: stats.length,
79
+ additions: total_additions,
80
+ deletions: total_deletions,
81
+ file_stats: stats
82
+ }
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommitCraft
4
+ class MockAIClient
5
+ def initialize(config = CommitCraft.configuration)
6
+ @config = config
7
+ end
8
+
9
+ def generate_commit_messages(diff, context = {})
10
+ # Analyze the diff to generate relevant messages
11
+ files_changed = context[:files] || []
12
+
13
+ # Generate messages based on commit style
14
+ case @config.commit_style
15
+ when "conventional"
16
+ generate_conventional_messages(diff, files_changed)
17
+ when "semantic"
18
+ generate_semantic_messages(diff, files_changed)
19
+ when "descriptive"
20
+ generate_descriptive_messages(diff, files_changed)
21
+ else
22
+ generate_default_messages(diff, files_changed)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def generate_conventional_messages(diff, files)
29
+ type = detect_type(diff, files)
30
+ scope = detect_scope(files)
31
+
32
+ [
33
+ "#{type}(#{scope}): implement changes to #{files.first || "codebase"}",
34
+ "#{type}: update #{files.length} file#{"s" if files.length != 1}",
35
+ "#{type}(#{scope}): add new functionality"
36
+ ]
37
+ end
38
+
39
+ def generate_semantic_messages(diff, files)
40
+ [
41
+ "Add #{extract_feature(diff, files)}",
42
+ "Update #{files.first || "code"} with improvements",
43
+ "Implement #{extract_feature(diff, files)}"
44
+ ]
45
+ end
46
+
47
+ def generate_descriptive_messages(diff, files)
48
+ [
49
+ "Implement new features across #{files.length} file#{"s" if files.length != 1}",
50
+ "Refactor code to improve maintainability and readability",
51
+ "Add functionality for #{extract_feature(diff, files)}"
52
+ ]
53
+ end
54
+
55
+ def generate_default_messages(_diff, files)
56
+ [
57
+ "Update #{files.first || "code"}",
58
+ "Implement changes",
59
+ "Add new functionality"
60
+ ]
61
+ end
62
+
63
+ def detect_type(diff, files)
64
+ # Simple heuristic based on files
65
+ return "feat" if diff.include?("class") || diff.include?("def")
66
+ return "fix" if diff.include?("bug") || diff.include?("fix")
67
+ return "docs" if files.any? { |f| f.end_with?(".md", ".txt") }
68
+ return "test" if files.any? { |f| f.include?("spec") || f.include?("test") }
69
+ return "refactor" if diff.include?("refactor")
70
+
71
+ "feat"
72
+ end
73
+
74
+ def detect_scope(files)
75
+ return "core" if files.empty?
76
+
77
+ # Get first file's directory or name
78
+ first_file = files.first.to_s
79
+ if first_file.include?("/")
80
+ parts = first_file.split("/")
81
+ parts.length > 1 ? parts[0] : "core"
82
+ else
83
+ first_file.split(".").first
84
+ end
85
+ end
86
+
87
+ def extract_feature(diff, files)
88
+ # Try to extract a feature name from class/method names
89
+ if diff =~ /class\s+(\w+)/
90
+ ::Regexp.last_match(1).downcase
91
+ elsif diff =~ /def\s+(\w+)/
92
+ ::Regexp.last_match(1).gsub("_", " ")
93
+ elsif files.any?
94
+ files.first.to_s.split("/").last.split(".").first
95
+ else
96
+ "new feature"
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommitCraft
4
+ VERSION = "0.2.0"
5
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "commitcraft/version"
4
+ require_relative "commitcraft/configuration"
5
+ require_relative "commitcraft/git_client"
6
+ require_relative "commitcraft/ai_client"
7
+ require_relative "commitcraft/mock_ai_client"
8
+ require_relative "commitcraft/message_generator"
9
+ require_relative "commitcraft/cli"
10
+ require_relative "commitcraft/errors"
11
+
12
+ module CommitCraft
13
+ class << self
14
+ attr_writer :configuration
15
+
16
+ def configuration
17
+ @configuration ||= Configuration.new
18
+ end
19
+
20
+ def configure
21
+ yield(configuration)
22
+ end
23
+
24
+ def reset_configuration!
25
+ @configuration = Configuration.new
26
+ end
27
+
28
+ def test_mode?
29
+ ENV["COMMITCRAFT_TEST_MODE"] == "true" || configuration.test_mode
30
+ end
31
+ end
32
+ end
metadata ADDED
@@ -0,0 +1,187 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: commitcraft
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Khaled Elabady
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-02-15 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: gemini-ai
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '4.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '4.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: pastel
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.8'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.8'
41
+ - !ruby/object:Gem::Dependency
42
+ name: thor
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.3'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.3'
55
+ - !ruby/object:Gem::Dependency
56
+ name: tty-prompt
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.23'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.23'
69
+ - !ruby/object:Gem::Dependency
70
+ name: tty-spinner
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.9'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.9'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pry
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.14'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.14'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rake
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '13.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '13.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rspec
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '3.12'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '3.12'
125
+ - !ruby/object:Gem::Dependency
126
+ name: rubocop
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '1.50'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '1.50'
139
+ description: CommitCraft uses AI to analyze your code changes and generate meaningful,
140
+ conventional commit messages automatically.
141
+ email:
142
+ - khaledelabadyy@gmail.com
143
+ executables:
144
+ - commitcraft
145
+ extensions: []
146
+ extra_rdoc_files: []
147
+ files:
148
+ - LICENSE.txt
149
+ - README.md
150
+ - exe/commitcraft
151
+ - lib/commitcraft.rb
152
+ - lib/commitcraft/ai_client.rb
153
+ - lib/commitcraft/cli.rb
154
+ - lib/commitcraft/configuration.rb
155
+ - lib/commitcraft/errors.rb
156
+ - lib/commitcraft/git_client.rb
157
+ - lib/commitcraft/message_generator.rb
158
+ - lib/commitcraft/mock_ai_client.rb
159
+ - lib/commitcraft/version.rb
160
+ homepage: https://github.com/Khaledelabady11/commitcraft
161
+ licenses:
162
+ - MIT
163
+ metadata:
164
+ homepage_uri: https://github.com/Khaledelabady11/commitcraft
165
+ source_code_uri: https://github.com/Khaledelabady11/commitcraft
166
+ changelog_uri: https://github.com/Khaledelabady11/commitcraft/blob/main/CHANGELOG.md
167
+ rubygems_mfa_required: 'true'
168
+ post_install_message:
169
+ rdoc_options: []
170
+ require_paths:
171
+ - lib
172
+ required_ruby_version: !ruby/object:Gem::Requirement
173
+ requirements:
174
+ - - ">="
175
+ - !ruby/object:Gem::Version
176
+ version: 2.7.0
177
+ required_rubygems_version: !ruby/object:Gem::Requirement
178
+ requirements:
179
+ - - ">="
180
+ - !ruby/object:Gem::Version
181
+ version: '0'
182
+ requirements: []
183
+ rubygems_version: 3.5.22
184
+ signing_key:
185
+ specification_version: 4
186
+ summary: AI-powered git commit message generator
187
+ test_files: []