ai_git 0.1.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3d2a98e6fef0c0ce3deb757a48e43701f1800d8c524f0cea525a92c8b4ae575
4
- data.tar.gz: 054d32afa6032a6bdfb2d4946673466b2b65ec6628dfecfb359bf67442c54e4e
3
+ metadata.gz: e698533dcbb5389f8ada1834ad78bbc65cbd6671e37874203de67c7376b11b51
4
+ data.tar.gz: 858e8d1de39b0034506da5fbd7858029e6ca77e922c551ceee4da1c2b4feb732
5
5
  SHA512:
6
- metadata.gz: b9064aeb4a13c6719a3847a41e07e3a933d2404cd7be169cf7806d6efa5ade9d5c3b0f1ebeb12c9951ac1386ec9fe40af83bc8cba5017829f788e1211bc9f566
7
- data.tar.gz: 6eacdb5d1ad3be96c5893466e02e28f2e5886091e5f8ca19fcd6814d31fc2629938bfc18447c3e07b98d6fde28e7582b63f483b0e3631deb0829f2e424d619ad
6
+ metadata.gz: 330b7566ca8ec91486faffd05ebefe651e543df74f39b796001d568793342cfa53689699baec35be9ec6a15b1f11505c60a06e17297e696593e752661465a6c2
7
+ data.tar.gz: 357d164f5a63cfb02bd173902bee1c098a3b24590a2347f33a97b0bff259c786c87aee1fca5e79937ccf7e916df9214d21cd280884370c877e520dd8dc2aea5b
data/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Kaíque Kandy Koga
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # AI Git
2
+
3
+ AI-powered Git commit messages using a local LLM
4
+
5
+ ## Usage
6
+
7
+ #### Requirements
8
+
9
+ - [llama.cpp](https://github.com/ggml-org/llama.cpp) running locally (e.g. `./llama-server --port 8080`)
10
+
11
+ #### Install
12
+
13
+ ```bash
14
+ gem install ai_git
15
+ ```
16
+
17
+ #### Environment Variables
18
+
19
+ | Variable | Description | Default |
20
+ |----------|-------------|---------|
21
+ | `AI_GIT_MODEL_NAME` | Model name | `ggml-org/gemma-4-E4B-it-GGUF:Q8_0` |
22
+ | `AI_GIT_BASE_URL` | Base URL of the llama.cpp server | `http://127.0.0.1:8080` |
23
+ | `NO_COLOR` | Disable colored terminal output when set | — |
24
+
25
+ Run `ai_git config` to see exactly which model, URL and endpoint are resolved from your environment.
26
+
27
+ ai_git talks to a local [llama.cpp](https://github.com/ggml-org/llama.cpp) server over its OpenAI-compatible
28
+ `/v1/chat/completions` endpoint. No API key is needed.
29
+
30
+ ##### Example
31
+
32
+ ```bash
33
+ # Start llama.cpp's server, then run ai_git with defaults
34
+ ./llama-server --port 8080
35
+ ai_git
36
+
37
+ # Or point at a custom model/port
38
+ export AI_GIT_MODEL_NAME=my-model
39
+ export AI_GIT_BASE_URL=http://127.0.0.1:8081
40
+ ```
41
+
42
+ #### Run
43
+
44
+ ```bash
45
+ git add <files>
46
+ ai_git
47
+ ```
48
+
49
+ `ai_git` generates a commit message from your staged changes, commits, and
50
+ pushes to the current branch's upstream — no prompts, no flags.
51
+
52
+ #### Subcommands
53
+
54
+ | Subcommand | Description |
55
+ |------------|-------------|
56
+ | `ai_git` | Generate a commit message, commit, and push staged files |
57
+ | `ai_git config` | Show the resolved provider configuration |
58
+ | `ai_git --help` | Show usage |
59
+ | `ai_git --version` | Print version |
60
+
61
+ ## Development
62
+
63
+ ```bash
64
+ gem build ai_git.gemspec
65
+ gem install ./ai_git-$(ruby -r./lib/ai_git/version -e 'print AIGit::VERSION').gem
66
+ ```
data/bin/ai_git CHANGED
@@ -3,4 +3,12 @@
3
3
 
4
4
  require_relative "../lib/ai_git"
5
5
 
6
- AIGit.start(ARGV)
6
+ begin
7
+ AIGit.start(ARGV)
8
+ rescue Interrupt
9
+ warn "\nAborted."
10
+ exit 130
11
+ rescue StandardError => e
12
+ AIGit::UI.error("ai_git: #{e.message}")
13
+ exit 1
14
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ require_relative "config"
8
+
9
+ module AIGit
10
+ module AIClient
11
+ module_function
12
+
13
+ READ_TIMEOUT_SECONDS = 120
14
+ OPEN_TIMEOUT_SECONDS = 10
15
+
16
+ # Transient failures worth retrying with backoff.
17
+ MAX_ATTEMPTS = 3
18
+ RETRY_BASE_DELAY = 0.5
19
+ TRANSIENT_STATUSES = [408, 425, 429, 500, 502, 503, 504].freeze
20
+ RETRYABLE_ERRORS = [
21
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EPIPE,
22
+ Net::OpenTimeout, Net::ReadTimeout, SocketError, EOFError
23
+ ].freeze
24
+
25
+ OUTPUT_NOISE_PREFIXES = /^(Here|Output|Generated|Based on|The changes)/i.freeze
26
+ OUTPUT_NOISE_HEADERS = /^(Here is|The (commit message|review) is|```|json|markdown)/i.freeze
27
+
28
+ # Sends `prompt` to the local llama.cpp server and returns the cleaned text body.
29
+ def complete(prompt:, model_name:, temperature:)
30
+ sanitize(openai_complete(prompt, model_name, temperature))
31
+ end
32
+
33
+ def openai_complete(prompt, model_name, temperature)
34
+ body = {
35
+ model: model_name,
36
+ messages: [{ role: "user", content: prompt }],
37
+ stream: false,
38
+ temperature: temperature
39
+ }
40
+
41
+ data = post_json(body)
42
+ data.dig("choices", 0, "message", "content").to_s
43
+ end
44
+
45
+ def transient_status?(code)
46
+ TRANSIENT_STATUSES.include?(code.to_i)
47
+ end
48
+
49
+ def retry_delay(attempt)
50
+ RETRY_BASE_DELAY * (2**(attempt - 1))
51
+ end
52
+
53
+ def post_json(body)
54
+ uri = URI("#{AIGit::Config.base_url}#{AIGit::Config.endpoint}")
55
+ attempt = 0
56
+
57
+ loop do
58
+ attempt += 1
59
+
60
+ begin
61
+ response = perform_request(uri, body)
62
+ rescue *RETRYABLE_ERRORS => e
63
+ raise connection_error_message(e) if attempt >= MAX_ATTEMPTS
64
+
65
+ sleep retry_delay(attempt)
66
+ next
67
+ end
68
+
69
+ return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
70
+
71
+ if transient_status?(response.code) && attempt < MAX_ATTEMPTS
72
+ sleep retry_delay(attempt)
73
+ next
74
+ end
75
+
76
+ raise http_error_message(uri, response)
77
+ end
78
+ end
79
+
80
+ def perform_request(uri, body)
81
+ request = Net::HTTP::Post.new(uri)
82
+ request["Content-Type"] = "application/json"
83
+ request.body = body.to_json
84
+
85
+ Net::HTTP.start(
86
+ uri.host,
87
+ uri.port,
88
+ use_ssl: uri.scheme == "https",
89
+ open_timeout: OPEN_TIMEOUT_SECONDS,
90
+ read_timeout: READ_TIMEOUT_SECONDS
91
+ ) { |http| http.request(request) }
92
+ end
93
+
94
+ def http_error_message(uri, response)
95
+ provider = AIGit::Config.provider
96
+ body = response.body.to_s.strip
97
+ body = "#{body[0, 500]}…" if body.length > 500
98
+
99
+ hint = response.code.to_i == 404 ? " Check the model name and base URL (see `ai_git config`)." : ""
100
+
101
+ "#{provider} returned HTTP #{response.code} at #{uri}.#{hint}" \
102
+ "#{body.empty? ? '' : "\n#{body}"}"
103
+ end
104
+
105
+ def connection_error_message(error)
106
+ provider = AIGit::Config.provider
107
+ base_url = AIGit::Config.base_url
108
+ hint = "Is the local server running? See `ai_git config`."
109
+
110
+ "Cannot reach #{provider} at #{base_url} after #{MAX_ATTEMPTS} attempts: #{error.message}. #{hint}"
111
+ end
112
+
113
+ def sanitize(text)
114
+ cleaned = text.to_s
115
+ .gsub(OUTPUT_NOISE_HEADERS, "")
116
+ .gsub(/^>\s*/, "")
117
+ .gsub(/\\n/, "\n")
118
+ .strip
119
+
120
+ cleaned.lines
121
+ .map(&:strip)
122
+ .reject { |line| line.match?(OUTPUT_NOISE_PREFIXES) }
123
+ .join("\n")
124
+ .strip
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ai_client"
4
+ require_relative "../config"
5
+ require_relative "../ui"
6
+
7
+ module AIGit
8
+ module Commands
9
+ # `ai_git config` — print the resolved provider configuration so users can
10
+ # see exactly which provider, model, URL and key will be used.
11
+ module Config
12
+ module_function
13
+
14
+ def call(_argv = [])
15
+ cfg = AIGit::Config
16
+
17
+ AIGit::UI.heading("ai_git configuration")
18
+ AIGit::UI.kv("Provider", cfg.provider)
19
+ AIGit::UI.kv("Model", cfg.model_name)
20
+ AIGit::UI.kv("Base URL", cfg.base_url)
21
+ AIGit::UI.kv("Endpoint", cfg.endpoint)
22
+ AIGit::UI.kv("Read timeout", "#{AIGit::AIClient::READ_TIMEOUT_SECONDS}s")
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ai_client"
4
+ require_relative "../config"
5
+ require_relative "../git"
6
+ require_relative "../ui"
7
+
8
+ module AIGit
9
+ module Commands
10
+ module Default
11
+ module_function
12
+
13
+ def generate_commit_message(diff, model_name, temperature: 0.3)
14
+ raise "No staged changes to generate commit message for" if diff.to_s.strip.empty?
15
+
16
+ message = AIClient.complete(
17
+ prompt: build_prompt(diff),
18
+ model_name: model_name,
19
+ temperature: temperature
20
+ )
21
+
22
+ message = normalize_message(message)
23
+ message.empty? ? "chore: update code" : message
24
+ end
25
+
26
+ # Keep the commit body readable: collapse runs of blank lines to a single
27
+ # one and guarantee a blank line after the title so git sees a proper
28
+ # subject/body split (otherwise `git log --oneline` mashes them together).
29
+ def normalize_message(message)
30
+ text = message.to_s.gsub(/\n{3,}/, "\n\n").strip
31
+ lines = text.lines.map(&:chomp)
32
+ return text if lines.length < 2
33
+
34
+ lines.insert(1, "") unless lines[1].empty?
35
+ lines.join("\n").gsub(/\n{3,}/, "\n\n").strip
36
+ end
37
+
38
+ def call(_argv = [])
39
+ provider = AIGit::Config.provider
40
+ model_name = AIGit::Config.model_name
41
+
42
+ staged = AIGit::Git.staged_files
43
+ abort "Error: No staged files. Use `git add` first." if staged.to_s.strip.empty?
44
+
45
+ diff = AIGit::Git.diff
46
+ branch = AIGit::Git.current_branch
47
+
48
+ print_header(provider, model_name, staged, branch)
49
+
50
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
51
+
52
+ AIGit::UI.info(AIGit::UI.bold("Generating commit message…"))
53
+ message = generate_commit_message(diff, model_name)
54
+ print_message(message)
55
+
56
+ AIGit::Git.commit_with_message(message)
57
+ AIGit::UI.success("Committed.")
58
+
59
+ AIGit::Git.push_current_branch
60
+ AIGit::UI.success("Pushed to origin/#{branch}.")
61
+
62
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
63
+ AIGit::UI.kv("Done in", format("%.1fs", elapsed))
64
+ end
65
+
66
+ def print_header(provider, model_name, staged, branch)
67
+ AIGit::UI.kv("AI Provider", provider)
68
+ AIGit::UI.kv("Model", model_name)
69
+ AIGit::UI.kv("Staged Files", staged.to_s.strip.gsub("\n", ", "))
70
+ AIGit::UI.kv("Branch", branch)
71
+ puts
72
+ end
73
+
74
+ def print_message(message)
75
+ puts
76
+ puts AIGit::UI.bold("Commit message:")
77
+ puts
78
+ puts message
79
+ puts
80
+ end
81
+
82
+ def build_prompt(diff)
83
+ standard_prompt(diff)
84
+ end
85
+
86
+ def standard_prompt(diff)
87
+ <<~PROMPT
88
+ You are an expert Git commit message writer. Output ONLY the commit message — no explanations, no markdown, no backticks, no preamble.
89
+
90
+ Here are the changes:
91
+ #{diff}
92
+
93
+ STRICT OUTPUT FORMAT (follow exactly):
94
+
95
+ <short imperative title, max 72 chars>
96
+
97
+ <blank line>
98
+
99
+ ## Summary
100
+ <2–4 bullet points covering the most important changes. Each bullet starts with a verb.>
101
+
102
+ ## Why
103
+ <1–3 sentences explaining the motivation or context behind the change. Omit if the reason is obvious.>
104
+
105
+ RULES:
106
+ - Title line: short, specific, imperative mood (e.g. "Add JWT login with refresh token support"). Avoid vague titles like "Update stuff" or "Fix bug".
107
+ - Summary bullets: describe WHAT changed, not HOW the code looks. Focus on behaviour and impact.
108
+ - Why section: explain the problem being solved or the goal being achieved. Skip if it adds no value.
109
+ - No filler phrases ("this commit", "this PR", "as per discussion").
110
+ - No line should exceed 72 characters.
111
+
112
+ EXAMPLES OF GOOD OUTPUT:
113
+
114
+ Add JWT-based login with refresh token support
115
+
116
+ ## Summary
117
+ - Implement login endpoint with access and refresh token issuance
118
+ - Add token refresh route with rotation and expiry validation
119
+ - Protect private routes via middleware that verifies access tokens
120
+ - Store refresh tokens using encrypted HTTP-only cookies
121
+
122
+ ## Why
123
+ Users were being logged out on every page reload. Refresh tokens allow
124
+ sessions to persist securely without requiring re-authentication.
125
+
126
+ ---
127
+
128
+ Prevent nil crash when user preferences are missing
129
+
130
+ ## Summary
131
+ - Add nil guard in ReportGenerator#process before accessing preferences
132
+ - Fall back to system defaults when preferences object is absent
133
+
134
+ ## Why
135
+ Reports were raising NoMethodError in production for users created
136
+ before the preferences feature shipped.
137
+
138
+ ---
139
+
140
+ Now generate the commit message:
141
+ PROMPT
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIGit
4
+ module Config
5
+ PROVIDER = "llama_cpp"
6
+ DEFAULT_MODEL = "ggml-org/gemma-4-E4B-it-GGUF:Q8_0"
7
+ DEFAULT_BASE_URL = "http://127.0.0.1:8080"
8
+ ENDPOINT = "/v1/chat/completions"
9
+
10
+ module_function
11
+
12
+ def provider
13
+ PROVIDER
14
+ end
15
+
16
+ def model_name
17
+ ENV["AI_GIT_MODEL_NAME"] || DEFAULT_MODEL
18
+ end
19
+
20
+ def base_url
21
+ ENV["AI_GIT_BASE_URL"] || DEFAULT_BASE_URL
22
+ end
23
+
24
+ def endpoint
25
+ ENDPOINT
26
+ end
27
+ end
28
+ end
data/lib/ai_git/git.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "English"
3
+ require "open3"
4
+ require "tempfile"
5
+
4
6
  module AIGit
5
7
  module Git
6
8
  module_function
@@ -14,13 +16,41 @@ module AIGit
14
16
  end
15
17
 
16
18
  def current_branch
17
- result = `git rev-parse --abbrev-ref HEAD`
18
- result.chomp
19
+ `git rev-parse --abbrev-ref HEAD`.chomp
20
+ end
21
+
22
+ # Run a command with arguments as an array — no shell, so values are not
23
+ # interpolated or word-split. Accepts either:
24
+ # run_command("git", "status") (single string of args)
25
+ # run_command("git", "commit", "-m", message) (variadic args, preferred)
26
+ def run_command(cmd, *args)
27
+ argv =
28
+ if args.length == 1 && args.first.is_a?(String)
29
+ args.first.split
30
+ else
31
+ args.map(&:to_s)
32
+ end
33
+
34
+ _stdout, stderr, status = Open3.capture3(cmd, *argv)
35
+
36
+ return if status.success?
37
+
38
+ raise "Command failed: #{cmd} #{argv.join(' ')} (exit #{status.exitstatus})#{stderr.empty? ? '' : "\n#{stderr}"}"
39
+ end
40
+
41
+ # Commit using a temp file so the message can contain anything (quotes,
42
+ # backticks, dollar signs) without shell-escaping concerns.
43
+ def commit_with_message(message)
44
+ Tempfile.create("ai_git_commit_msg") do |file|
45
+ file.write(message)
46
+ file.flush
47
+
48
+ run_command("git", "commit", "-F", file.path)
49
+ end
19
50
  end
20
51
 
21
- def run_command(cmd, args)
22
- system("#{cmd} #{args} > /dev/null 2>&1")
23
- raise "Command failed: #{cmd} #{args}" if $CHILD_STATUS.exitstatus != 0
52
+ def push_current_branch
53
+ run_command("git", "push", "-u", "origin", "HEAD")
24
54
  end
25
55
  end
26
56
  end
data/lib/ai_git/ui.rb ADDED
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIGit
4
+ # Terminal output helpers: ANSI colors (auto-disabled when output is not a
5
+ # TTY or NO_COLOR is set) and key/value lines.
6
+ module UI
7
+ module_function
8
+
9
+ CODES = {
10
+ bold: 1, dim: 2, red: 31, green: 32, yellow: 33, blue: 34, cyan: 36, gray: 90
11
+ }.freeze
12
+
13
+ # Colors are on only for an interactive terminal and when the user has not
14
+ # opted out via NO_COLOR (https://no-color.org) or AI_GIT_NO_COLOR.
15
+ def color?
16
+ return false if ENV["NO_COLOR"] && !ENV["NO_COLOR"].empty?
17
+ return false if ENV["AI_GIT_NO_COLOR"] && !ENV["AI_GIT_NO_COLOR"].empty?
18
+
19
+ $stdout.tty?
20
+ end
21
+
22
+ def paint(text, *styles)
23
+ return text.to_s unless color?
24
+
25
+ codes = styles.map { |style| CODES[style] }.compact
26
+ return text.to_s if codes.empty?
27
+
28
+ "\e[#{codes.join(';')}m#{text}\e[0m"
29
+ end
30
+
31
+ def bold(text)
32
+ paint(text, :bold)
33
+ end
34
+
35
+ def dim(text)
36
+ paint(text, :dim)
37
+ end
38
+
39
+ def kv(key, value)
40
+ puts "#{paint("#{key}:", :bold)} #{value}"
41
+ end
42
+
43
+ def heading(text)
44
+ puts paint(text, :bold, :cyan)
45
+ end
46
+
47
+ def info(text)
48
+ puts text
49
+ end
50
+
51
+ def success(text)
52
+ puts paint(text, :green)
53
+ end
54
+
55
+ def error(text)
56
+ $stderr.puts paint(text, :red) # rubocop:disable Style/StderrPuts
57
+ end
58
+ end
59
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AIGit
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/ai_git.rb CHANGED
@@ -1,23 +1,58 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "ai_git/version"
4
+ require_relative "ai_git/config"
5
+ require_relative "ai_git/ui"
6
+ require_relative "ai_git/ai_client"
4
7
  require_relative "ai_git/git"
5
- require_relative "ai_git/review"
6
- require_relative "ai_git/default"
8
+ require_relative "ai_git/commands/default"
9
+ require_relative "ai_git/commands/config"
7
10
 
8
11
  module AIGit
9
12
  module_function
10
13
 
11
14
  SUBCOMMANDS = {
12
- "review" => AIGit::Review,
13
- "default" => AIGit::Default
15
+ "config" => AIGit::Commands::Config,
16
+ "default" => AIGit::Commands::Default
14
17
  }.freeze
15
18
 
19
+ HELP_FLAGS = %w[-h --help help].freeze
20
+ VERSION_FLAGS = %w[-v --version].freeze
21
+
22
+ USAGE = <<~USAGE
23
+ Usage: ai_git [subcommand]
24
+
25
+ Subcommands:
26
+ (none) Generate a commit message, commit, and push staged files
27
+ config Show the resolved provider configuration
28
+
29
+ Flags:
30
+ -h, --help Show this message
31
+ -v, --version Print version
32
+
33
+ Environment variables:
34
+ AI_GIT_MODEL_NAME Override the default model
35
+ AI_GIT_BASE_URL Override the default base URL
36
+ NO_COLOR Disable colored output
37
+ USAGE
38
+
16
39
  def start(args)
17
- command = args.first || "default"
40
+ args = args.dup
41
+ first = args.first
42
+
43
+ return puts(USAGE) if first && HELP_FLAGS.include?(first)
44
+ return puts(VERSION) if first && VERSION_FLAGS.include?(first)
18
45
 
19
- raise "Unknown subcommand: #{command}" unless SUBCOMMANDS.key?(command)
46
+ command = "default"
47
+ if first && !first.start_with?("-")
48
+ unless SUBCOMMANDS.key?(first)
49
+ warn "Unknown subcommand: #{first}"
50
+ warn USAGE
51
+ exit 1
52
+ end
53
+ command = args.shift
54
+ end
20
55
 
21
- SUBCOMMANDS[command].call
56
+ SUBCOMMANDS[command].call(args)
22
57
  end
23
58
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ai_git
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaíque Kandy Koga
@@ -9,22 +9,31 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
- description: AI‑powered Git using SLMs
12
+ description: Generate Git commit messages from staged changes via a local llama.cpp
13
+ server, then commit and push.
13
14
  executables:
14
15
  - ai_git
15
16
  extensions: []
16
17
  extra_rdoc_files: []
17
18
  files:
19
+ - LICENSE
20
+ - README.md
18
21
  - bin/ai_git
19
22
  - lib/ai_git.rb
20
- - lib/ai_git/default.rb
23
+ - lib/ai_git/ai_client.rb
24
+ - lib/ai_git/commands/config.rb
25
+ - lib/ai_git/commands/default.rb
26
+ - lib/ai_git/config.rb
21
27
  - lib/ai_git/git.rb
22
- - lib/ai_git/review.rb
28
+ - lib/ai_git/ui.rb
23
29
  - lib/ai_git/version.rb
24
30
  homepage: https://github.com/kaiquekandykoga/ai_git
25
31
  licenses:
26
32
  - BSD-3-Clause
27
- metadata: {}
33
+ metadata:
34
+ source_code_uri: https://github.com/kaiquekandykoga/ai_git
35
+ bug_tracker_uri: https://github.com/kaiquekandykoga/ai_git/issues
36
+ rubygems_mfa_required: 'true'
28
37
  rdoc_options: []
29
38
  require_paths:
30
39
  - lib
@@ -32,14 +41,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
32
41
  requirements:
33
42
  - - ">="
34
43
  - !ruby/object:Gem::Version
35
- version: '4.0'
44
+ version: '0'
36
45
  required_rubygems_version: !ruby/object:Gem::Requirement
37
46
  requirements:
38
47
  - - ">="
39
48
  - !ruby/object:Gem::Version
40
49
  version: '0'
41
50
  requirements: []
42
- rubygems_version: 4.0.9
51
+ rubygems_version: 4.0.10
43
52
  specification_version: 4
44
- summary: AIpowered Git using SLMs
53
+ summary: AI-powered Git commit messages using a local LLM
45
54
  test_files: []
@@ -1,152 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "benchmark"
4
- require "net/http"
5
- require "uri"
6
- require "json"
7
- require_relative "version"
8
- require_relative "git"
9
-
10
- module AIGit
11
- module Default
12
- module_function
13
-
14
- def escape_json(string)
15
- string.gsub("\\", "\\\\")
16
- .gsub('"', '\"')
17
- .gsub("\n", '\\n')
18
- .gsub("\r", '\\r')
19
- .gsub("\t", '\\t')
20
- end
21
-
22
- def generate_commit_message(diff, model_name)
23
- raise "No staged changes to generate commit message for" if diff.to_s.strip.empty?
24
-
25
- prompt = <<~PROMPT
26
- You are an expert Git commit message writer. Output ONLY the commit message — no explanations, no markdown, no backticks, no preamble.
27
-
28
- Here are the changes:
29
- #{diff}
30
-
31
- STRICT OUTPUT FORMAT (follow exactly):
32
-
33
- <short imperative title, max 72 chars>
34
-
35
- <blank line>
36
-
37
- ## Summary
38
- <2–4 bullet points covering the most important changes. Each bullet starts with a verb.>
39
-
40
- ## Why
41
- <1–3 sentences explaining the motivation or context behind the change. Omit if the reason is obvious.>
42
-
43
- RULES:
44
- - Title line: short, specific, imperative mood (e.g. "Add JWT login with refresh token support"). Avoid vague titles like "Update stuff" or "Fix bug".
45
- - Summary bullets: describe WHAT changed, not HOW the code looks. Focus on behaviour and impact.
46
- - Why section: explain the problem being solved or the goal being achieved. Skip if it adds no value.
47
- - No filler phrases ("this commit", "this PR", "as per discussion").
48
- - No line should exceed 72 characters.
49
-
50
- EXAMPLES OF GOOD OUTPUT:
51
-
52
- Add JWT-based login with refresh token support
53
-
54
- ## Summary
55
- - Implement login endpoint with access and refresh token issuance
56
- - Add token refresh route with rotation and expiry validation
57
- - Protect private routes via middleware that verifies access tokens
58
- - Store refresh tokens using encrypted HTTP-only cookies
59
-
60
- ## Why
61
- Users were being logged out on every page reload. Refresh tokens allow
62
- sessions to persist securely without requiring re-authentication.
63
-
64
- ---
65
-
66
- Prevent nil crash when user preferences are missing
67
-
68
- ## Summary
69
- - Add nil guard in ReportGenerator#process before accessing preferences
70
- - Fall back to system defaults when preferences object is absent
71
-
72
- ## Why
73
- Reports were raising NoMethodError in production for users created
74
- before the preferences feature shipped.
75
-
76
- ---
77
-
78
- Now generate the commit message:
79
- PROMPT
80
-
81
- json_body = {
82
- model: model_name,
83
- prompt: prompt,
84
- stream: false,
85
- temperature: 0.3,
86
- top_p: 0.9,
87
- stop: ["\n\n\n", "```", "Here is", "The commit message"],
88
- num_predict: 400
89
- }.to_json
90
-
91
- uri = URI("http://localhost:11434/api/generate")
92
- request = Net::HTTP::Post.new(uri)
93
- request["Content-Type"] = "application/json"
94
- request.body = json_body
95
-
96
- response = Net::HTTP.start(uri.host, uri.port, read_timeout: 120) do |http|
97
- http.request(request)
98
- end
99
-
100
- raise "Failed to connect to Ollama. Is it running?" unless response.is_a?(Net::HTTPSuccess)
101
-
102
- data = JSON.parse(response.body)
103
- message = data["response"].to_s.strip
104
-
105
- message = message.gsub(/^(Here is|The commit message is|```|json|markdown)/i, "")
106
- .gsub(/^>\s*/, "")
107
- .gsub(/\\n/, "\n")
108
- .strip
109
-
110
- lines = message.lines.map(&:strip)
111
- lines.reject! { |line| line.match?(/^(Here|Output|Generated|Based on|The changes)/i) }
112
-
113
- message = lines.join("\n").strip
114
-
115
- message = "chore: update code" if message.lines.count < 1 || message.strip.empty?
116
-
117
- message
118
- end
119
-
120
- def call
121
- model_name = ENV["AI_GIT_MODEL_NAME"] || "phi4:14b"
122
-
123
- staged = AIGit::Git.staged_files
124
- abort "Error: No staged files. Use `git add` first." if staged.to_s.strip.empty?
125
-
126
- diff = AIGit::Git.diff
127
- branch = AIGit::Git.current_branch
128
-
129
- puts "\e[1mModel Name:\e[0m #{model_name}"
130
- puts "\e[1mStaged Files:\e[0m #{staged}"
131
- puts "\e[1mBranch:\e[0m #{branch}"
132
- puts "\e[1mAI Generating Commit Message\e[0m"
133
-
134
- result = Benchmark.measure do
135
- message = generate_commit_message(diff, model_name)
136
- message = message.gsub(/\n{2,}/, "\n")
137
-
138
- puts "\e[1mCommit Message:\e[0m\n\n#{message}\n"
139
-
140
- escaped_msg = message.gsub(/[\\"`$]/) { |c| "\\#{c}" }
141
- AIGit::Git.run_command("git", "commit -m \"#{escaped_msg}\"")
142
- puts "\e[1mGit Commited\e[0m"
143
-
144
- AIGit::Git.run_command("git", "push")
145
- puts "\e[1mGit Pushed\e[0m"
146
- end
147
-
148
- puts "\e[1mBenchmark\e[0m"
149
- puts result
150
- end
151
- end
152
- end
data/lib/ai_git/review.rb DELETED
@@ -1,148 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "benchmark"
4
- require "net/http"
5
- require "uri"
6
- require "json"
7
- require_relative "version"
8
- require_relative "git"
9
-
10
- module AIGit
11
- module Review
12
- module_function
13
-
14
- def generate_review(diff, model_name)
15
- raise "No staged changes to review" if diff.to_s.strip.empty?
16
-
17
- prompt = <<~PROMPT
18
- You are a senior software engineer conducting a thorough code review. Output ONLY the review — no explanations, no markdown preamble, no backticks, no preamble.
19
-
20
- Here are the staged changes:
21
- #{diff}
22
-
23
- STRICT OUTPUT FORMAT (follow exactly):
24
-
25
- ## Summary
26
- <2–5 bullet points describing what this change does at a high level. Each bullet starts with a verb.>
27
-
28
- ## Suggestions
29
- <For each issue or improvement, use this format:>
30
-
31
- **<File and location, e.g. "app/models/user.rb">**
32
- - <Concise description of the issue or suggestion. Start with a verb. Be specific.>
33
- - <Another suggestion for the same file, if applicable.>
34
-
35
- <Repeat the file block for each file with suggestions. Omit files that look clean.>
36
-
37
- ## Verdict
38
- <One of: ✅ Looks good | ⚠️ Minor issues | 🚨 Needs attention>
39
- <One sentence explaining the verdict.>
40
-
41
- RULES:
42
- - Summary: describe WHAT the change does, not HOW the code looks. Focus on behaviour and intent.
43
- - Suggestions: flag real issues — bugs, edge cases, security problems, missing error handling, naming confusion, performance concerns, missing tests. Do NOT invent problems. If a file is clean, omit it entirely.
44
- - Be direct and constructive. No filler like "great job" or "consider possibly maybe".
45
- - No line should exceed 72 characters.
46
- - If the diff is a minor or trivial change (e.g. version bump, typo fix), keep the review brief.
47
-
48
- EXAMPLES OF GOOD OUTPUT:
49
-
50
- ## Summary
51
- - Add password reset flow with tokenised email verification
52
- - Introduce PasswordResetMailer with expiry-aware token links
53
- - Add DB migration for reset_token and reset_token_expires_at columns
54
-
55
- ## Suggestions
56
-
57
- **app/models/user.rb**
58
- - Ensure reset_token is invalidated after successful use to
59
- prevent token reuse attacks.
60
- - Add an index on reset_token column for fast lookup queries.
61
-
62
- **app/controllers/passwords_controller.rb**
63
- - Handle the case where the token has expired with a user-facing
64
- error message rather than a silent redirect.
65
-
66
- ## Verdict
67
- ⚠️ Minor issues
68
- Core logic is solid but token invalidation and expiry feedback
69
- need addressing before merge.
70
-
71
- ---
72
-
73
- ## Summary
74
- - Fix nil guard in ReportGenerator when preferences are absent
75
-
76
- ## Suggestions
77
-
78
- ## Verdict
79
- ✅ Looks good
80
- Safe defensive fix with no side effects.
81
-
82
- ---
83
-
84
- Now review the staged changes:
85
- PROMPT
86
-
87
- json_body = {
88
- model: model_name,
89
- prompt: prompt,
90
- stream: false,
91
- temperature: 0.2,
92
- top_p: 0.9,
93
- stop: ["\n\n\n", "```", "Here is", "The review"],
94
- num_predict: 600
95
- }.to_json
96
-
97
- uri = URI("http://localhost:11434/api/generate")
98
- request = Net::HTTP::Post.new(uri)
99
- request["Content-Type"] = "application/json"
100
- request.body = json_body
101
-
102
- response = Net::HTTP.start(uri.host, uri.port, read_timeout: 120) do |http|
103
- http.request(request)
104
- end
105
-
106
- raise "Failed to connect to Ollama. Is it running?" unless response.is_a?(Net::HTTPSuccess)
107
-
108
- data = JSON.parse(response.body)
109
- review = data["response"].to_s.strip
110
-
111
- review = review.gsub(/^(Here is|The review is|```|json|markdown)/i, "")
112
- .gsub(/^>\s*/, "")
113
- .gsub(/\\n/, "\n")
114
- .strip
115
-
116
- lines = review.lines.map(&:strip)
117
- lines.reject! { |line| line.match?(/^(Here|Output|Generated|Based on|The changes)/i) }
118
-
119
- review = lines.join("\n").strip
120
- review = "No review generated." if review.empty?
121
-
122
- review
123
- end
124
-
125
- def call
126
- model_name = ENV["AI_GIT_MODEL_NAME"] || "phi4:14b"
127
-
128
- staged = AIGit::Git.staged_files
129
- abort "Error: No staged files. Use `git add` first." if staged.to_s.strip.empty?
130
-
131
- diff = AIGit::Git.diff
132
- branch = AIGit::Git.current_branch
133
-
134
- puts "\e[1mModel Name:\e[0m #{model_name}"
135
- puts "\e[1mStaged Files:\e[0m #{staged}"
136
- puts "\e[1mBranch:\e[0m #{branch}"
137
- puts "\e[1mAI Reviewing Changes\e[0m"
138
-
139
- result = Benchmark.measure do
140
- review = generate_review(diff, model_name)
141
- puts "\e[1mCode Review:\e[0m\n\n#{review}\n"
142
- end
143
-
144
- puts "\e[1mBenchmark\e[0m"
145
- puts result
146
- end
147
- end
148
- end