diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..3459e52 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,26 @@ +name: Tests + +on: + push: + branches: [master, main, maintenance] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: ['3.1', '3.2', '3.3', '3.4'] + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Run tests + run: bundle exec rake test + - name: Scan dependencies for vulnerabilities + run: | + gem install bundler-audit --no-document + bundler-audit check diff --git a/.gitignore b/.gitignore index e3200e0..e93407a 100644 --- a/.gitignore +++ b/.gitignore @@ -45,7 +45,7 @@ build-iPhoneSimulator/ # for a library or gem, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: -# Gemfile.lock +Gemfile.lock # .ruby-version # .ruby-gemset diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..fa75df1 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' + +gemspec diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..62f1aa7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Afonso Neto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index fd3f014..6af32f1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # AI git commit message -A Ruby gem that automatically generates concise and meaningful git commit messages using AI models via [Ollama](https://github.com/ollama/ollama) or any OpenAI-compatible API. +A Ruby gem that automatically generates concise and meaningful git commit messages using a local LLM via [Ollama](https://github.com/ollama/ollama), LM Studio, llama.cpp `llama-server`, or any other OpenAI-compatible API. + +![Terminal demo](docs/demo.gif) ## Installation @@ -26,13 +28,13 @@ bundle install - Ruby 3.0 or newer - Git repository -- [Ollama](https://github.com/ollama/ollama) model running locally or other OpenAI-compatible API endpoint +- A local LLM server: [Ollama](https://github.com/ollama/ollama), [LM Studio](https://lmstudio.ai/), [llama.cpp `llama-server`](https://github.com/ggml-org/llama.cpp), or any other OpenAI-compatible endpoint ## Usage ### Basic Usage -Generate a commit message for your staged changes: +Generate a commit message for your staged changes and commit with it after a confirmation: ```bash ai-commit-message commit @@ -43,18 +45,39 @@ This will: 2. Review your recent commit history for style consistency 3. Consider your current branch name 4. Generate an appropriate commit message +5. Ask for confirmation and create the commit (answering "no" keeps the printed message so you can edit or copy it) + +### Message only + +If you just want the suggested message without the confirmation prompt — e.g. to pipe it somewhere or review it yourself — use `--message-only`: + +```bash +ai-commit-message commit --message-only +``` + +### Conventional Commits + +Ask the model to follow the [Conventional Commits](https://www.conventionalcommits.org/) format (`feat:`, `fix:`, `chore:`, ...): + +```bash +ai-commit-message commit --conventional +``` ### Configuration -You can configure the gem using the built-in configuration command: +Configure the API URL and model interactively: ```bash ai-commit-message config ``` -This interactive prompt allows you to set: -- **API URL**: The endpoint for your AI model (defaults to `http://localhost:11434`) -- **Model name**: The AI model to use (defaults to `qwen2.5-coder:7b`, which I personally recommend) +If your server is reachable, the model prompt becomes a picker populated with the models available on that server. + +List the models available on your server: + +```bash +ai-commit-message models +``` ### Command Line Options @@ -64,14 +87,26 @@ Override configuration settings directly: ai-commit-message commit --url=http://your-api-endpoint --model=your-model-name ``` +All options: + +| Option | Description | Default | +| --- | --- | --- | +| `--url` | Base URL of the OpenAI-compatible API | `http://localhost:11434` | +| `--model` | Model name | `qwen3:8b` | +| `--length` | Maximum message length in characters | `72` | +| `--conventional` | Use Conventional Commits format | off | +| `--message-only` | Print the message without the commit confirmation prompt | off | + ## How It Works The gem: -1. Collects your staged changes using `git diff --cached` +1. Collects your staged changes using `git diff --cached` (capped at 12,000 characters so large diffs don't overwhelm a local model's context) 2. Retrieves your recent commit history for context 3. Identifies your current branch name -4. Sends this information to the specified AI model -5. Returns a concise, contextual commit message (limited to 250 characters) +4. Sends a chat request to `{url}/v1/chat/completions` — the standard OpenAI-compatible endpoint +5. Cleans the model output (strips code fences, quotes and reasoning blocks) and prints a single-line commit message (limited to 72 characters by default) + +The URL accepts base addresses with or without a trailing `/v1` and `/`, so `http://localhost:11434` (Ollama), `http://localhost:1234/v1` (LM Studio) and `http://localhost:8080` (llama.cpp `llama-server`) all work as-is. ## Configuration File @@ -79,29 +114,32 @@ The gem stores your configuration in `~/.ai-commit-message.conf`. You can manual ```bash url=http://localhost:11434 -model=qwen2.5-coder:7b +model=qwen3:8b ``` ## Models -The default configuration uses Ollama with the `qwen2.5-coder:7b` model, but you can use any model available through your API endpoint. +The default configuration uses Ollama with the `qwen3:8b` model, but you can use any model available through your API endpoint. ### Recommended Models -- **qwen2.5-coder:7b**: Good balance of quality and speed (default) -- **llama3:8b**: Excellent for general commit messages -- **codellama:7b**: Specialized for code-related commits +- **qwen3:8b**: Good balance of quality and speed (default). Note that it is a thinking model, so expect a few seconds of reasoning before the message appears +- **qwen2.5-coder:7b**: Faster, non-thinking coding model +- **qwen3.5:4b**: Current-generation small model for modest hardware +- **gemma4:e4b**: Compact general-purpose option +- **llama3.1:8b**: Solid general-purpose choice ## Contributing 1. Fork the repository 2. Create your feature branch: `git checkout -b my-new-feature` 3. Install dependencies: `bundle install` -4. Make your changes and add tests if applicable -5. Commit your changes: `git commit -m 'Add some feature'` -6. Push to the branch: `git push origin my-new-feature` -7. Submit a pull request +4. Run the tests: `bundle exec rake test` +5. Make your changes and add tests if applicable +6. Commit your changes: `git commit -m 'Add some feature'` +7. Push to the branch: `git push origin my-new-feature` +8. Submit a pull request ## License -This gem is available as open source free to use/alter without restrictions. \ No newline at end of file +This gem is available as open source under the terms of the [MIT License](LICENSE). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..d9b28c0 --- /dev/null +++ b/Rakefile @@ -0,0 +1,9 @@ +require 'rake/testtask' + +Rake::TestTask.new do |t| + t.libs << 'lib' << 'test' + t.test_files = FileList['test/**/*_test.rb'] + t.warning = false +end + +task default: :test diff --git a/ai-commit-message.gemspec b/ai-commit-message.gemspec index 5c07731..b9465ed 100644 --- a/ai-commit-message.gemspec +++ b/ai-commit-message.gemspec @@ -1,21 +1,24 @@ Gem::Specification.new do |gem| gem.name = 'ai-commit-message' - gem.version = '0.1.2' + gem.version = '0.2.0' gem.authors = ['Afonso Neto'] gem.email = ['afonso.pontesneto@gmail.com'] gem.summary = 'Git commit messages suggested by AI' - gem.description = 'Git commit messages suggested by AI' + gem.description = 'Git commit messages suggested by AI using a local LLM via any OpenAI-compatible API' gem.homepage = 'https://github.com/AfonsoNeto/ai-commit-message' - gem.license = nil - - gem.files = Dir.glob('{bin/*,lib/**/*,README.md}') + gem.license = 'MIT' + + gem.metadata['rubygems_mfa_required'] = 'true' + + gem.files = Dir.glob('{bin/*,lib/**/*,README.md,LICENSE}') gem.require_paths = ['lib'] gem.executables = ['ai-commit-message'] gem.required_ruby_version = '>= 3.0' gem.add_dependency 'thor' - gem.add_runtime_dependency 'tty-prompt' - gem.add_runtime_dependency 'net-http' - gem.add_runtime_dependency 'json' -end \ No newline at end of file + gem.add_dependency 'tty-prompt' + + gem.add_development_dependency 'rake' + gem.add_development_dependency 'minitest' +end diff --git a/docs/demo.cast b/docs/demo.cast new file mode 100644 index 0000000..89ffe40 --- /dev/null +++ b/docs/demo.cast @@ -0,0 +1,39 @@ +{"version": 3, "term": {"cols": 84, "rows": 13}, "timestamp": 1790494557, "command": "ai-commit-message"} +[0.8, "o", ""] +[0.84, "o", "\u001b[2m# ai-commit-message: AI commit messages from a local LLM\u001b[0m\r\n\r\n"] +[0.89, "o", "\u001b[1;32m$\u001b[0m "] +[0.94, "o", "ai-c"] +[0.99, "o", "ommi"] +[1.04, "o", "t-me"] +[1.09, "o", "ssag"] +[1.14, "o", "e co"] +[1.19, "o", "mmit"] +[1.24, "o", " --m"] +[1.29, "o", "essa"] +[1.34, "o", "ge-o"] +[1.46, "o", "nly"] +[2.36, "o", "\r\n"] +[2.96, "o", ""] +[3.0, "o", "docs: add type check and docstring to add function\r\n\r\n"] +[3.05, "o", "\u001b[1;32m$\u001b[0m "] +[3.1, "o", "ai-c"] +[3.15, "o", "ommi"] +[3.2, "o", "t-me"] +[3.25, "o", "ssag"] +[3.3, "o", "e co"] +[3.42, "o", "mmit"] +[4.32, "o", "\r\n"] +[4.72, "o", ""] +[5.22, "o", "docs: add type check and docstring to add function\r\n"] +[5.62, "o", "Commit with this message? (\u001b[1mY\u001b[0m/n) "] +[6.22, "o", "y\r\n"] +[6.26, "o", "\u001b[32m3f7159a docs: add docstring and type check for add function\u001b[0m\r\n\r\n"] +[6.31, "o", "\u001b[1;32m$\u001b[0m "] +[6.36, "o", "git "] +[6.41, "o", "log "] +[6.46, "o", "--on"] +[6.51, "o", "elin"] +[6.63, "o", "e -2"] +[7.03, "o", "\r\n"] +[7.53, "o", ""] +[7.53, "o", "\u001b[33m3f7159a docs: add docstring and type check for add function\r\nb445970 chore: initial setup\u001b[0m\r\n\r\n"] diff --git a/docs/demo.gif b/docs/demo.gif new file mode 100644 index 0000000..07e2f0d Binary files /dev/null and b/docs/demo.gif differ diff --git a/docs/generate_demo.py b/docs/generate_demo.py new file mode 100644 index 0000000..b9d0cb5 --- /dev/null +++ b/docs/generate_demo.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Generate docs/demo.cast and docs/demo.gif. + +Runs real `ai-commit-message` commands against a local LLM server, then +assembles an asciicast v3 recording with realistic typing animation and the +real command outputs. The GIF is rendered with agg (asciinema gif generator). + +Requirements: ai-commit-message installed and pointed at a running local LLM +server, git, and agg (https://github.com/asciinema/agg) on PATH. + +Usage: python3 docs/generate_demo.py +""" + +import json +import os +import shutil +import subprocess +import sys +import tempfile + +COLS, ROWS = 84, 13 +PROMPT = "\x1b[1;32m$\x1b[0m " +REPO = None +EVENTS = [] + + +def run(cmd, input_=None): + return subprocess.run( + cmd, shell=True, input=input_, capture_output=True, text=True, cwd=REPO + ) + + +def emit(data, delay=0.04): + EVENTS[-1][0] = round(EVENTS[-1][0] + delay, 3) + EVENTS.append([EVENTS[-1][0], "o", data]) + + +def type_command(cmd): + emit(PROMPT) + for i in range(0, len(cmd), 4): + emit(cmd[i:i + 4], 0.05) + emit("\r\n", 0.12) + + +def main(): + global REPO + REPO = tempfile.mkdtemp(prefix="acm-demo-") + + # Prepare a small demo repository with one staged change. + run("git init -q && git config user.email demo@example.com") + run("git config user.name Demo && git config commit.gpgsign false") + run("printf 'def add(a, b):\\n return a + b\\n\\nprint(add(1, 2))\\n' > calc.py") + run("git add calc.py && git commit -qm 'chore: initial setup'") + run( + "printf 'def add(a, b):\\n" + " \\\"\\\"\\\"Return the sum of two numbers.\\\"\\\"\\\"\\n" + " if not isinstance(a, (int, float)) or not isinstance(b, (int, float)):\\n" + " raise TypeError(\\\"add() expects numbers\\\")\\n" + " return a + b\\n' > calc.py" + ) + run("git add calc.py") + + # Run the real commands and capture their real outputs. + suggest = run("ai-commit-message commit --message-only") + if suggest.returncode != 0: + sys.exit(f"demo generation failed: {suggest.stderr.strip()}") + message = suggest.stdout.strip() + + apply_out = run("ai-commit-message commit", input_="y\n") + if apply_out.returncode != 0: + sys.exit(f"demo apply failed: {apply_out.stderr.strip()}") + + # Assemble the recording. + EVENTS.append([0.0, "o", ""]) + emit("\x1b[2m# ai-commit-message: AI commit messages from a local LLM\x1b[0m\r\n\r\n", 0.8) + + type_command("ai-commit-message commit --message-only") + emit("", 0.9) # the model is thinking + emit(message + "\r\n\r\n", 0.6) + + type_command("ai-commit-message commit") + emit("", 0.9) + emit(message + "\r\n", 0.4) + emit("Commit with this message? (\x1b[1mY\x1b[0m/n) ", 0.5) + emit("y\r\n", 0.4) + emit("\x1b[32m" + run("git log --oneline -1").stdout.rstrip() + "\x1b[0m\r\n\r\n", 0.6) + + type_command("git log --oneline -2") + emit("", 0.4) + log_out = run("git log --oneline -2").stdout.rstrip().replace("\n", "\r\n") + emit("\x1b[33m" + log_out + "\x1b[0m\r\n\r\n", 0.5) + + shutil.rmtree(REPO, ignore_errors=True) + + header = { + "version": 3, + "term": {"cols": COLS, "rows": ROWS}, + "timestamp": int(subprocess.run("date +%s", shell=True, capture_output=True, text=True).stdout.strip()), + "command": "ai-commit-message", + } + here = os.path.dirname(os.path.abspath(__file__)) + cast_path = os.path.join(here, "demo.cast") + with open(cast_path, "w") as f: + f.write(json.dumps(header) + "\n") + for event in EVENTS: + f.write(json.dumps(event) + "\n") + print(f"wrote {cast_path}") + + gif_path = os.path.join(here, "demo.gif") + subprocess.run( + [ + "agg", "--speed", "2", "--idle-time-limit", "1", + "--theme", "github-dark", "--font-size", "15", + cast_path, gif_path, + ], + check=True, + ) + print(f"wrote {gif_path}") + + +if __name__ == "__main__": + main() diff --git a/lib/ai-commit-message/cli.rb b/lib/ai-commit-message/cli.rb index 0bdc529..6717f40 100755 --- a/lib/ai-commit-message/cli.rb +++ b/lib/ai-commit-message/cli.rb @@ -5,25 +5,66 @@ module AiCommitMessage class CLI < Thor + def self.exit_on_failure? + true + end + DEFAULT_URL = 'http://localhost:11434' - DEFAULT_MODEL_NAME = 'qwen2.5-coder:7b' + DEFAULT_MODEL_NAME = 'qwen3:8b' + MAX_DIFF_LENGTH = 12_000 - desc "commit", "Generate git commit message" - method_option :url, type: :string, default: DEFAULT_URL - method_option :model, type: :string, default: DEFAULT_MODEL_NAME + desc "commit", "Generate a commit message and commit with it after confirmation" + method_option :url, type: :string + method_option :model, type: :string + method_option :length, type: :numeric, default: Suggester::DEFAULT_LENGTH + method_option :conventional, type: :boolean, default: false + method_option :message_only, type: :boolean, default: false def commit git_diff_output = `git diff --cached --no-color` + if git_diff_output.strip.empty? + warn 'Nothing is staged. Stage your changes with `git add` first.' + exit 1 + end + git_log_output = `git log --format=%s -n 30` git_current_branch = `git branch --show-current` - url = url_to_be_used(options.url) - model = model_to_be_used(options.model) - - suggester = AiCommitMessage::Suggester.new(git_diff_output, git_log_output, git_current_branch) - commit_message = suggester.generate_commit_message(url:, model:) + suggester = AiCommitMessage::Suggester.new(git_diff_output[0, MAX_DIFF_LENGTH], git_log_output, git_current_branch) + commit_message = suggester.generate_commit_message( + url: url_to_be_used(options[:url]), + model: model_to_be_used(options[:model]), + length: options[:length], + conventional: options[:conventional] + ) puts commit_message + return if options[:message_only] + + prompt = TTY::Prompt.new + begin + # tty-prompt takes the default on EOF, which would silently commit + # in non-interactive runs; treat a closed stdin as "no". + confirmed = !$stdin.eof? && prompt.yes?("Commit with this message?", default: true) + rescue TTY::Reader::InputInterrupt + confirmed = false + end + return unless confirmed + + system('git', 'commit', '-m', commit_message) + rescue AiCommitMessage::Suggester::Error => e + warn e.message + exit 1 + end + + desc "models", "List models available on the configured API URL" + method_option :url, type: :string + + def models + puts AiCommitMessage::Suggester.list_models(url_to_be_used(options[:url])) + rescue AiCommitMessage::Suggester::Error => e + warn e.message + exit 1 end desc "config", "Set global configs. API URL and Model name" @@ -31,7 +72,12 @@ def config prompt = TTY::Prompt.new url = prompt.ask("API URL:", default: ConfigManager.get_url || DEFAULT_URL) - model = prompt.ask("Model name:", default: ConfigManager.get_model || DEFAULT_MODEL_NAME) + available_models = AiCommitMessage::Suggester.list_models(url) rescue nil + model = if available_models && !available_models.empty? + prompt.select("Model name:", available_models) + else + prompt.ask("Model name:", default: ConfigManager.get_model || DEFAULT_MODEL_NAME) + end ConfigManager.set_url(url) ConfigManager.set_model(model) @@ -42,15 +88,11 @@ def config private def url_to_be_used(options_url) - return options_url if !options_url.empty? && (options_url != DEFAULT_URL) - - ConfigManager.get_url || DEFAULT_URL + options_url || ConfigManager.get_url || DEFAULT_URL end def model_to_be_used(options_model) - return options_model if !options_model.empty? && (options_model != DEFAULT_MODEL_NAME) - - ConfigManager.get_model || DEFAULT_MODEL_NAME + options_model || ConfigManager.get_model || DEFAULT_MODEL_NAME end end end diff --git a/lib/ai-commit-message/config_manager.rb b/lib/ai-commit-message/config_manager.rb index f80d11c..bcb38f3 100644 --- a/lib/ai-commit-message/config_manager.rb +++ b/lib/ai-commit-message/config_manager.rb @@ -34,5 +34,8 @@ def self.config @config end - def self.save_config = File.write(CONFIG_FILE, config.map { |key, value| "#{key}=#{value}" }.join("\n")) + def self.save_config + File.write(CONFIG_FILE, config.map { |key, value| "#{key}=#{value}" }.join("\n")) + FileUtils.chmod(0600, CONFIG_FILE) + end end \ No newline at end of file diff --git a/lib/ai-commit-message/suggester.rb b/lib/ai-commit-message/suggester.rb index 6879041..b7a9b11 100644 --- a/lib/ai-commit-message/suggester.rb +++ b/lib/ai-commit-message/suggester.rb @@ -1,41 +1,144 @@ require 'net/http' require 'uri' require 'json' +require 'openssl' module AiCommitMessage class Suggester + class Error < StandardError; end + class ConnectionError < Error; end + class ApiError < Error; end + + OPEN_TIMEOUT = 5 + READ_TIMEOUT = 120 + DEFAULT_LENGTH = 72 + # Thinking models (qwen3 and friends) spend tokens reasoning before the + # answer; the budget must leave room for both or content comes back empty. + MAX_TOKENS = 2048 + + def self.list_models(url) + uri = URI("#{normalized_base(url)}/v1/models") + http = build_http(uri) + + begin + response = http.request(Net::HTTP::Get.new(uri)) + rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError + raise ConnectionError, + "Could not connect to #{uri.host}:#{uri.port}. Is your local LLM server running?" + end + + parse_response(response).fetch('data', []).map { |m| m['id'] }.compact.sort + end + + def self.parse_response(response) + unless response.is_a?(Net::HTTPSuccess) + raise ApiError, "API returned HTTP #{response.code}: #{response.body.to_s[0, 200]}" + end + + JSON.parse(response.body) + rescue JSON::ParserError + raise ApiError, 'API response was not valid JSON' + end + + def self.build_http(uri) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == 'https' + http.verify_mode = OpenSSL::SSL::VERIFY_PEER + http.open_timeout = OPEN_TIMEOUT + http.read_timeout = READ_TIMEOUT + http + end + + def self.normalized_base(url) + url.chomp('/').sub(%r{/v1$}, '') + end + def initialize(git_diff_output, git_log_output, git_current_branch) @git_diff_output = git_diff_output @git_log_output = git_log_output @git_current_branch = git_current_branch end - def generate_commit_message(url:, model:) - url = URI(url + '/api/generate') - body = { + def generate_commit_message(url:, model:, length: DEFAULT_LENGTH, conventional: false) + response = post_json(chat_completions_uri(url), request_body(model, length, conventional)) + json = self.class.parse_response(response) + message = clean_commit_message(json.dig('choices', 0, 'message', 'content'), length) + return message unless message.empty? + + raise ApiError, + 'Model returned no commit message content. Try a non-thinking model or a different model.' + end + + private + + def request_body(model, length, conventional) + { model: model, - prompt: "Create a concise git commit message with no more than 250 characters. Exclude anything unnecessary such as translation, backticks characters or multiple suggestions, since your entire response will be passed directly into git commit. Consider the following messages as example to follow: #{@git_log_output}. - The First [#XXX] option is the branch name. The current branch name is: #{@git_current_branch}. Now Do it for the following git diff: #{@git_diff_output}", - stream: false, - options: { temperature: 0.8 } + messages: [ + { role: 'system', content: system_prompt(conventional) }, + { role: 'user', content: user_prompt(length) } + ], + temperature: 0.3, + max_tokens: MAX_TOKENS, + stream: false } - json_body = body.to_json - headers = { 'Content-Type' => 'application/json' } + end + + def system_prompt(conventional) + prompt = 'You write git commit messages. Respond with a single line containing ' \ + 'only the commit message: no quotes, no backticks, no markdown, no explanations.' + prompt += ' Use the Conventional Commits format (type: description).' if conventional + prompt + end - http = Net::HTTP.new(url.host, url.port) - request = Net::HTTP::Post.new(url) - request.body = json_body - headers.each { |key, value| request[key] = value } - response = http.request(request) + def user_prompt(length) + <<~PROMPT + Write a concise git commit message with no more than #{length} characters for the staged changes below. + Follow the style of these recent commit messages: #{@git_log_output} + #{branch_line} + Git diff: + #{@git_diff_output} + PROMPT + end + + # git branch --show-current returns nothing on a detached HEAD + def branch_line + @git_current_branch.strip.empty? ? '' : "The current branch name is: #{@git_current_branch.strip}" + end + # Models tend to wrap answers in code fences or quotes, inline their + # reasoning in blocks, or offer alternatives; reduce everything to + # a single clean subject line. Control characters are stripped so that a + # prompt-injected model cannot smuggle terminal escapes into git history. + def clean_commit_message(raw, length) + line = raw.to_s + .gsub(%r{.*?}m, '') + .gsub(/\e\][^\x07\e]*(?:\x07|\e\\)/, '') + .gsub(/\e\[[0-9;:]*[A-Za-z]/, '') + .gsub(/[\e\a\b\v\f\r]/, '') + .gsub('`', '') + .each_line.map(&:strip).reject(&:empty?).first.to_s + line = line.sub(/\A["'](.*)["']\z/, '\1') + line.gsub(/\s+/, ' ')[0, length].to_s.strip + end + + # Accepts base URLs with or without a trailing /v1 and with or without a + # trailing slash, e.g. http://localhost:11434, http://localhost:1234/v1/ + def chat_completions_uri(url) + URI("#{self.class.normalized_base(url)}/v1/chat/completions") + end + + def post_json(uri, body) + http = self.class.build_http(uri) + request = Net::HTTP::Post.new(uri) + request['Content-Type'] = 'application/json' + request.body = body.to_json begin - json = JSON.parse(response.body) - return json['response'] if json - rescue JSON::ParserError => e - puts "Failed to parse API response as JSON: #{e.message}" + http.request(request) + rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e + raise ConnectionError, + "Could not connect to #{uri.host}:#{uri.port}. Is your local LLM server running?" end - - nil end end -end \ No newline at end of file +end diff --git a/test/config_manager_test.rb b/test/config_manager_test.rb new file mode 100644 index 0000000..a71c338 --- /dev/null +++ b/test/config_manager_test.rb @@ -0,0 +1,65 @@ +require_relative 'test_helper' + +class ConfigManagerTest < Minitest::Test + def setup + @config_file = Tempfile.new('ai-commit-message-test') + @config_file.close + stub_constant + reset_cache + end + + def teardown + restore_constant + @config_file.unlink + end + + def test_defaults_to_empty_config + assert_nil ConfigManager.get_url + assert_nil ConfigManager.get_model + end + + def test_set_and_get_roundtrip + ConfigManager.set_url('http://localhost:1234') + ConfigManager.set_model('qwen3:8b') + assert_equal 'http://localhost:1234', ConfigManager.get_url + assert_equal 'qwen3:8b', ConfigManager.get_model + end + + def test_persists_to_config_file + ConfigManager.set_url('http://localhost:1234') + ConfigManager.set_model('qwen3:8b') + + reset_cache + assert_equal 'http://localhost:1234', ConfigManager.get_url + assert_equal 'qwen3:8b', ConfigManager.get_model + end + + def test_loads_existing_config_file + File.write(@config_file.path, "url=http://localhost:8080\nmodel=gemma3:4b\n") + reset_cache + assert_equal 'http://localhost:8080', ConfigManager.get_url + assert_equal 'gemma3:4b', ConfigManager.get_model + end + + def test_config_file_is_owner_only + ConfigManager.set_url('http://localhost:1234') + assert_equal 0600, File.stat(@config_file.path).mode & 0777 + end + + private + + def stub_constant + @original = ConfigManager.const_get(:CONFIG_FILE) + ConfigManager.send(:remove_const, :CONFIG_FILE) + ConfigManager.const_set(:CONFIG_FILE, @config_file.path) + end + + def restore_constant + ConfigManager.send(:remove_const, :CONFIG_FILE) + ConfigManager.const_set(:CONFIG_FILE, @original) + end + + def reset_cache + ConfigManager.remove_instance_variable(:@config) if ConfigManager.instance_variable_defined?(:@config) + end +end diff --git a/test/suggester_test.rb b/test/suggester_test.rb new file mode 100644 index 0000000..854b495 --- /dev/null +++ b/test/suggester_test.rb @@ -0,0 +1,135 @@ +require_relative 'test_helper' + +class SuggesterTest < Minitest::Test + include TestHelper + + def setup + @suggester = AiCommitMessage::Suggester.new('+def foo', 'add foo', 'feature/foo') + end + + def test_chat_completions_uri_normalization + [ + 'http://localhost:11434', + 'http://localhost:11434/', + 'http://localhost:1234/v1', + 'http://localhost:1234/v1/' + ].each do |url| + uri = @suggester.send(:chat_completions_uri, url) + assert_equal '/v1/chat/completions', uri.path + assert_equal 'localhost', uri.host + end + end + + def test_normalized_base_keeps_custom_paths + assert_equal 'http://localhost:8080/api', AiCommitMessage::Suggester.normalized_base('http://localhost:8080/api/') + end + + def test_clean_commit_message_strips_code_fences_and_quotes + assert_equal 'feat: add login page', @suggester.send(:clean_commit_message, "```\nfeat: add login page\n```", 72) + assert_equal 'Fix the parser bug', @suggester.send(:clean_commit_message, '"Fix the parser bug"', 72) + end + + def test_clean_commit_message_takes_first_line + assert_equal 'fix: handle nil', @suggester.send(:clean_commit_message, "fix: handle nil\n\nAlternative: something else", 72) + end + + def test_clean_commit_message_truncates_to_length + assert_equal 'a' * 10, @suggester.send(:clean_commit_message, 'a' * 100, 10) + assert_equal '', @suggester.send(:clean_commit_message, nil, 72) + end + + def test_clean_commit_message_strips_ansi_escape_sequences + raw = "\e[31mfix:\e[0m handle \e[2Knil \e]0;title\a crash" + assert_equal 'fix: handle nil crash', @suggester.send(:clean_commit_message, raw, 72) + end + + def test_build_http_enables_ssl_verification_for_https + http = AiCommitMessage::Suggester.build_http(URI('https://api.example.com/v1/chat/completions')) + assert http.use_ssl? + assert_equal OpenSSL::SSL::VERIFY_PEER, http.verify_mode + end + + def test_build_http_keeps_plain_http_local + http = AiCommitMessage::Suggester.build_http(URI('http://localhost:11434/v1/chat/completions')) + refute http.use_ssl? + end + + def test_clean_commit_message_strips_inline_think_blocks + raw = "Let me decide on wording.\nfix: handle nil diff" + assert_equal 'fix: handle nil diff', @suggester.send(:clean_commit_message, raw, 72) + end + + def test_generate_commit_message_raises_when_content_is_empty + with_mock_server(openai_chat_response('')) do |port| + assert_raises AiCommitMessage::Suggester::ApiError do + @suggester.generate_commit_message(url: "http://127.0.0.1:#{port}", model: 'm') + end + end + end + + def test_generate_commit_message_returns_cleaned_content + with_mock_server(openai_chat_response("```\nfix: avoid nil crash\n```")) do |port| + message = @suggester.generate_commit_message(url: "http://127.0.0.1:#{port}", model: 'test-model') + assert_equal 'fix: avoid nil crash', message + end + end + + def test_generate_commit_message_uses_chat_completions_and_chat_payload + request = with_mock_server(openai_chat_response('ok')) do |port| + @suggester.generate_commit_message(url: "http://127.0.0.1:#{port}/v1", model: 'm', conventional: true) + end + + assert_includes request, "POST /v1/chat/completions" + json_body = JSON.parse(request.split("\r\n\r\n", 2).last) + assert_equal 'm', json_body['model'] + assert_equal 'system', json_body['messages'][0]['role'] + assert_includes json_body['messages'][0]['content'], 'Conventional Commits' + assert_equal 0.3, json_body['temperature'] + end + + def test_generate_commit_message_omits_branch_when_detached + suggester = AiCommitMessage::Suggester.new('', '', '') + prompt = suggester.send(:user_prompt, 72) + refute_includes prompt, 'The current branch name is' + end + + def test_api_error_on_http_failure + with_mock_server({'error' => 'boom'}.to_json, status: '500 Internal Server Error') do |port| + assert_raises AiCommitMessage::Suggester::ApiError do + @suggester.generate_commit_message(url: "http://127.0.0.1:#{port}", model: 'm') + end + end + end + + def test_api_error_on_invalid_json + with_mock_server('not json') do |port| + assert_raises AiCommitMessage::Suggester::ApiError do + @suggester.generate_commit_message(url: "http://127.0.0.1:#{port}", model: 'm') + end + end + end + + def test_connection_error_when_server_is_down + server = TCPServer.new('127.0.0.1', 0) + closed_port = server.addr[1] + server.close + + assert_raises AiCommitMessage::Suggester::ConnectionError do + @suggester.generate_commit_message(url: "http://127.0.0.1:#{closed_port}", model: 'm') + end + end + + def test_list_models_returns_sorted_ids + with_mock_server({ data: [{ id: 'zeta' }, { id: 'alpha' }] }.to_json) do |port| + models = AiCommitMessage::Suggester.list_models("http://127.0.0.1:#{port}") + assert_equal %w[alpha zeta], models + end + end + + def test_list_models_normalizes_v1_suffix + with_mock_server({ data: [{ id: 'm' }] }.to_json) do |port| + models = AiCommitMessage::Suggester.list_models("http://127.0.0.1:#{port}/v1") + assert_equal ['m'], models + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..a8d9eda --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,49 @@ +require 'minitest/autorun' +require 'tempfile' +require_relative '../lib/ai-commit-message/suggester' +require_relative '../lib/ai-commit-message/config_manager' + +module TestHelper + # Reads a full HTTP request from the socket: headers plus Content-Length + # bytes of body. Segments can arrive split across multiple reads. + def read_http_request(client) + request = +'' + request << client.readpartial(4096) until request.include?("\r\n\r\n") + headers, body = request.split("\r\n\r\n", 2) + body = +(body || '') + content_length = headers[/^Content-Length: (\d+)$/i, 1].to_i + body << client.readpartial(4096) while body.bytesize < content_length + "#{headers}\r\n\r\n#{body}" + end + + # Minimal dependency-free HTTP server returning a canned JSON response. + # Yields the bound port and returns the full received HTTP request. + def with_mock_server(response_body, status: '200 OK') + server = TCPServer.new('127.0.0.1', 0) + thread = nil + received = nil + begin + thread = Thread.new do + client = server.accept + received = read_http_request(client) + client.write( + "HTTP/1.1 #{status}\r\n" \ + "Content-Type: application/json\r\n" \ + "Content-Length: #{response_body.bytesize}\r\n" \ + "Connection: close\r\n\r\n#{response_body}" + ) + client.close + end + yield server.addr[1] + ensure + # Close the server first so a thread still blocked in accept can exit. + server&.close + thread&.join(1) + end + received + end + + def openai_chat_response(content) + { choices: [{ message: { role: 'assistant', content: content } }] }.to_json + end +end