ai_git 0.2.0 → 1.0.1

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: bc3e320bc55f08d7665a8a6ae9123b6b032a4b4a3861cf439b5ff018f14b415a
4
- data.tar.gz: 055a7bd2bc06b4d0e18aacb318a6aa6725e1632f0bb1b5c4c3ad2ca5930013ab
3
+ metadata.gz: 2fb023f0a7a794ab912a9502ce5155883ecad2171832690124534e4ded1ce551
4
+ data.tar.gz: bb16265d754dffabf9718b410d38d8f71e00cfc249e2a97ed41cc82cfefcca2c
5
5
  SHA512:
6
- metadata.gz: d90a349b399361d0c52fcaec15fec584bb4251197a8a282a7632b5e1078ec571753f8085c6edddf51edffd0f3d6c10ef24b7922afdd6c69df700b564f1f446e1
7
- data.tar.gz: 603b003ec1cefdae26db68cc026ac1d2950ebd00421e9e1c0fe56c663dc8ae202458a8334bfeef1cd6f1e8cfc9a1d29893578e3e1c34c41b239c8245a11dc744
6
+ metadata.gz: beb8cf739a17dc85da3e07bcd75dca5108e30f1aaef7249ac663435322440b0b4ada1768607e97faf15aab35cc629a01b58bacaddd491a3e71171838258ce1a0
7
+ data.tar.gz: 8a9e339b686103ecc350e3844f594f1b0bf0e7297e8305da1754399cfa71955c56eca197742781847213f71beb9b74e333276ca32c39b2edf81a1d5247d24155
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,8 @@
1
+ # AI Git
2
+
3
+ AI-powered Git commit messages using a local LLM
4
+
5
+ ## Documentation
6
+
7
+ - [Usage](doc/USAGE.md) — requirements, install, configuration, flags, and privacy
8
+ - [Release](doc/RELEASE.md) — how a version is tagged and published to RubyGems
data/bin/ai_git CHANGED
@@ -1,6 +1,25 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
+ # bin/ai_git
4
+ #
5
+ # @purpose Executable entry point for the ai_git CLI: load the library and
6
+ # hand ARGV to AIGit.start.
7
+ # @exports ai_git: the `config` subcommand and the -n/--dry-run, --no-push,
8
+ # -y/--yes, -f/--force, -h/--help, -v/--version flags.
9
+ # @dependencies lib/ai_git: supplies AIGit.start and AIGit::UI.error.
10
+ # @sideEffects Runs the whole CLI; exits 130 on interrupt and 1 on any other
11
+ # error, after printing the message to stderr.
12
+ # @notes Catches StandardError only, so the string messages the library
13
+ # raises surface as one clean line instead of a backtrace.
3
14
 
4
15
  require_relative "../lib/ai_git"
5
16
 
6
- AIGit.start(ARGV)
17
+ begin
18
+ AIGit.start(ARGV)
19
+ rescue Interrupt
20
+ warn "\nAborted."
21
+ exit 130
22
+ rescue StandardError => e
23
+ AIGit::UI.error("ai_git: #{e.message}")
24
+ exit 1
25
+ end
data/doc/RELEASE.md ADDED
@@ -0,0 +1,77 @@
1
+ # Release
2
+
3
+ `ai_git` is published to [RubyGems](https://rubygems.org/gems/ai_git) by
4
+ `.github/workflows/release.yml`, which runs on every push to `master` that
5
+ touches `lib/ai_git/version.rb`. The workflow authenticates with
6
+ [trusted publishing](https://guides.rubygems.org/trusted-publishing/): GitHub
7
+ mints a short-lived OIDC token, RubyGems exchanges it for a single-use API key
8
+ scoped to this gem. No API key is stored in the repository, and the push
9
+ satisfies the `rubygems_mfa_required` flag set in `ai_git.gemspec` without an
10
+ interactive MFA prompt.
11
+
12
+ ## One-time setup
13
+
14
+ 1. **RubyGems** — profile → the `ai_git` gem → *Trusted publishers* → *Create*:
15
+
16
+ | Field | Value |
17
+ |-------|-------|
18
+ | Repository owner | `kaiquekandykoga` |
19
+ | Repository name | `ai_git` |
20
+ | Workflow filename | `release.yml` |
21
+ | Environment | `release` |
22
+
23
+ 2. **GitHub** — Settings → Environments → `release`. It is created on the first
24
+ workflow run; add required reviewers there to gate each publish behind a
25
+ manual approval.
26
+
27
+ The values must match the workflow exactly. Renaming the workflow file or the
28
+ environment invalidates the publisher and the push is rejected.
29
+
30
+ ## Cutting a release
31
+
32
+ 1. Bump `AIGit::VERSION` in `lib/ai_git/version.rb`.
33
+ 2. Commit the bump and push it to `master`.
34
+
35
+ That is the whole procedure. The push starts the workflow, which tags and
36
+ publishes on its own — there is no tag to create by hand.
37
+
38
+ ## What the workflow does
39
+
40
+ 1. Checks out `master` with its full history and tags, on Ruby 4.0.
41
+ 2. Reads `AIGit::VERSION` and looks for the matching `v<version>` tag. If the
42
+ tag already exists the version was not bumped — the change to `version.rb`
43
+ was a comment or a header edit — and every later step is skipped, so the
44
+ run is a no-op rather than a failure.
45
+ 3. Installs the bundle, then runs `rake test` and `rubocop`. A failure here
46
+ stops the run before anything is tagged or published.
47
+ 4. Creates the annotated `v<version>` tag and pushes it.
48
+ 5. Runs `rubygems/release-gem@v1`, which configures the OIDC credentials, runs
49
+ `bundle exec rake release` (build, `guard_clean`, `gem push`), attaches a
50
+ sigstore attestation, and waits for the version to appear on RubyGems. The
51
+ tag from step 4 already exists, so the release task skips tagging and goes
52
+ straight to the gem push.
53
+
54
+ A failure before step 5 publishes nothing. If the run fails after the tag is
55
+ pushed, delete the tag (`git push origin :refs/tags/v1.0.0`) and re-run the
56
+ workflow from the Actions tab — `workflow_dispatch` is enabled for exactly
57
+ that case.
58
+
59
+ ## Verifying
60
+
61
+ The gem is live when the version shows up on
62
+ [rubygems.org/gems/ai_git/versions](https://rubygems.org/gems/ai_git/versions),
63
+ which the last step of the workflow waits for. Locally:
64
+
65
+ ```bash
66
+ gem list -r ai_git --all
67
+ ```
68
+
69
+ ## Publishing by hand
70
+
71
+ Only needed if the workflow is unavailable. This is the path that prompts for
72
+ MFA:
73
+
74
+ ```bash
75
+ gem build ai_git.gemspec
76
+ gem push ai_git-1.0.0.gem
77
+ ```
data/doc/USAGE.md ADDED
@@ -0,0 +1,107 @@
1
+ # Usage
2
+
3
+ ## Requirements
4
+
5
+ - [llama.cpp](https://github.com/ggml-org/llama.cpp) running locally (e.g. `./llama-server --port 8080`)
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ gem install ai_git
11
+ ```
12
+
13
+ ## Configuration
14
+
15
+ Settings live in a YAML file at `~/.ai_git/config.yml` (`config.yaml` is read
16
+ too). The file is optional — without it every setting falls back to its
17
+ default.
18
+
19
+ ```yaml
20
+ # ~/.ai_git/config.yml
21
+ model_name: ggml-org/gemma-4-E4B-it-GGUF:Q8_0
22
+ base_url: http://127.0.0.1:8080
23
+ no_color: false
24
+ ```
25
+
26
+ | Setting | Description | Default |
27
+ |---------|-------------|---------|
28
+ | `model_name` | Model name | `ggml-org/gemma-4-E4B-it-GGUF:Q8_0` |
29
+ | `base_url` | Base URL of the llama.cpp server | `http://127.0.0.1:8080` |
30
+ | `no_color` | Disable colored terminal output | `false` |
31
+
32
+ An unknown key or a malformed value fails the run with an error naming the
33
+ file, so a typo never silently leaves the default in place.
34
+
35
+ Run `ai_git config` to see exactly which model, URL and endpoint are resolved,
36
+ and which file they came from.
37
+
38
+ ai_git talks to a local [llama.cpp](https://github.com/ggml-org/llama.cpp) server over its OpenAI-compatible
39
+ `/v1/chat/completions` endpoint. No API key is needed.
40
+
41
+ ### Example
42
+
43
+ ```bash
44
+ # Start llama.cpp's server, then run ai_git with defaults
45
+ ./llama-server --port 8080
46
+ ai_git
47
+
48
+ # Or point at a custom model/port
49
+ mkdir -p ~/.ai_git
50
+ cat > ~/.ai_git/config.yml <<'YAML'
51
+ model_name: my-model
52
+ base_url: http://127.0.0.1:8081
53
+ YAML
54
+ ```
55
+
56
+ ## Run
57
+
58
+ ```bash
59
+ git add <files>
60
+ ai_git
61
+ ```
62
+
63
+ `ai_git` generates a commit message from your staged changes, then asks what to
64
+ do with it:
65
+
66
+ ```
67
+ Commit this message? [A]ccept / [e]dit / [r]egenerate / [q]uit:
68
+ ```
69
+
70
+ Accepting commits and pushes to `origin`. The prompt only appears on a
71
+ terminal — piped or scripted runs stay unattended, as does `--yes`.
72
+
73
+ ## Flags
74
+
75
+ | Flag | Description |
76
+ |------|-------------|
77
+ | `-n`, `--dry-run` | Print the generated message and change nothing |
78
+ | `--no-push` | Commit locally without pushing |
79
+ | `-y`, `--yes` | Skip the confirmation prompt (unattended) |
80
+ | `-f`, `--force` | Proceed despite secret or remote-server warnings |
81
+
82
+ ```bash
83
+ ai_git --dry-run # see what it would write, commit nothing
84
+ ai_git --no-push # commit locally, publish later yourself
85
+ ```
86
+
87
+ ## Privacy
88
+
89
+ The **full staged diff is sent to the configured `base_url`** as part of the
90
+ prompt. The default is your own machine (`http://127.0.0.1:8080`), and nothing
91
+ leaves it. Point `base_url` at another host and ai_git warns before every run;
92
+ plain `http://` to a non-loopback host is refused outright unless you pass
93
+ `--force`.
94
+
95
+ Before generating, ai_git also checks the staged change for credentials —
96
+ `.env` files, private keys, AWS/GitHub/Slack-shaped tokens — and refuses to
97
+ send them without `--force`. It is a guard, not a guarantee: review what you
98
+ stage.
99
+
100
+ ## Subcommands
101
+
102
+ | Subcommand | Description |
103
+ |------------|-------------|
104
+ | `ai_git` | Generate a commit message, commit, and push staged files |
105
+ | `ai_git config` | Show the resolved provider configuration |
106
+ | `ai_git --help` | Show usage |
107
+ | `ai_git --version` | Print version |
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+ # lib/ai_git/ai_client.rb
3
+ #
4
+ # @purpose Talk to the OpenAI-compatible chat endpoint: post the prompt,
5
+ # retry transient failures, and strip the model's wrapping from
6
+ # the reply.
7
+ # @exports AIGit::AIClient: READ_TIMEOUT_SECONDS, OPEN_TIMEOUT_SECONDS,
8
+ # MAX_ATTEMPTS, RETRY_BASE_DELAY, TRANSIENT_STATUSES,
9
+ # RETRYABLE_ERRORS, .complete, .sanitize.
10
+ # @dependencies ai_git/config: supplies the base URL, endpoint, and provider
11
+ # name used in requests and error messages;
12
+ # json: encodes the request body and parses the response;
13
+ # net/http, uri: perform the HTTP POST.
14
+ # @sideEffects Makes network requests to the configured base URL; sleeps
15
+ # between retries; raises a string message on failure.
16
+ # @notes Retries with exponential backoff on the listed connection
17
+ # errors and status codes only; any other status raises at once.
18
+ # Sanitizing also unescapes a reply whose only newlines are
19
+ # literal backslash-n, which some models emit.
20
+
21
+ require "json"
22
+ require "net/http"
23
+ require "uri"
24
+
25
+ require_relative "config"
26
+
27
+ module AIGit
28
+ module AIClient
29
+ module_function
30
+
31
+ READ_TIMEOUT_SECONDS = 120
32
+ OPEN_TIMEOUT_SECONDS = 10
33
+
34
+ MAX_ATTEMPTS = 3
35
+ RETRY_BASE_DELAY = 0.5
36
+ TRANSIENT_STATUSES = [408, 425, 429, 500, 502, 503, 504].freeze
37
+ RETRYABLE_ERRORS = [
38
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EPIPE,
39
+ Net::OpenTimeout, Net::ReadTimeout, SocketError, EOFError
40
+ ].freeze
41
+
42
+ PREAMBLE_PREFIXES = /\A(here|output|generated|based\son|the\schanges|
43
+ the\s(commit\smessage|review)\sis|json|markdown)\b/ix.freeze
44
+ CODE_FENCE = /\A`{3,}/.freeze
45
+ ESCAPED_MESSAGE = /\A[^\n]*\\n\\n[^\n]*\z/.freeze
46
+
47
+ def complete(prompt:, model_name:, temperature:)
48
+ sanitize(openai_complete(prompt, model_name, temperature))
49
+ end
50
+
51
+ def openai_complete(prompt, model_name, temperature)
52
+ body = {
53
+ model: model_name,
54
+ messages: [{ role: "user", content: prompt }],
55
+ stream: false,
56
+ temperature: temperature
57
+ }
58
+
59
+ data = post_json(body)
60
+ data.dig("choices", 0, "message", "content").to_s
61
+ end
62
+
63
+ def transient_status?(code)
64
+ TRANSIENT_STATUSES.include?(code.to_i)
65
+ end
66
+
67
+ def retry_delay(attempt)
68
+ RETRY_BASE_DELAY * (2**(attempt - 1))
69
+ end
70
+
71
+ def post_json(body)
72
+ uri = URI("#{AIGit::Config.base_url}#{AIGit::Config.endpoint}")
73
+ attempt = 0
74
+
75
+ loop do
76
+ attempt += 1
77
+
78
+ begin
79
+ response = perform_request(uri, body)
80
+ rescue *RETRYABLE_ERRORS => e
81
+ raise connection_error_message(e) if attempt >= MAX_ATTEMPTS
82
+
83
+ sleep retry_delay(attempt)
84
+ next
85
+ end
86
+
87
+ return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
88
+
89
+ if transient_status?(response.code) && attempt < MAX_ATTEMPTS
90
+ sleep retry_delay(attempt)
91
+ next
92
+ end
93
+
94
+ raise http_error_message(uri, response)
95
+ end
96
+ end
97
+
98
+ def perform_request(uri, body)
99
+ request = Net::HTTP::Post.new(uri)
100
+ request["Content-Type"] = "application/json"
101
+ request.body = body.to_json
102
+
103
+ Net::HTTP.start(
104
+ uri.host,
105
+ uri.port,
106
+ use_ssl: uri.scheme == "https",
107
+ open_timeout: OPEN_TIMEOUT_SECONDS,
108
+ read_timeout: READ_TIMEOUT_SECONDS
109
+ ) { |http| http.request(request) }
110
+ end
111
+
112
+ def http_error_message(uri, response)
113
+ provider = AIGit::Config.provider
114
+ body = response.body.to_s.strip
115
+ body = "#{body[0, 500]}…" if body.length > 500
116
+
117
+ hint = response.code.to_i == 404 ? " Check the model name and base URL (see `ai_git config`)." : ""
118
+
119
+ "#{provider} returned HTTP #{response.code} at #{uri}.#{hint}" \
120
+ "#{body.empty? ? '' : "\n#{body}"}"
121
+ end
122
+
123
+ def connection_error_message(error)
124
+ provider = AIGit::Config.provider
125
+ base_url = AIGit::Config.base_url
126
+ hint = "Is the local server running? See `ai_git config`."
127
+
128
+ "Cannot reach #{provider} at #{base_url} after #{MAX_ATTEMPTS} attempts: #{error.message}. #{hint}"
129
+ end
130
+
131
+ def sanitize(text)
132
+ lines = unescape_newlines(text.to_s)
133
+ .lines
134
+ .map { |line| line.rstrip.sub(/\A>\s*/, "") }
135
+
136
+ strip_preamble(strip_code_fences(lines)).join("\n").strip
137
+ end
138
+
139
+ def unescape_newlines(text)
140
+ return text unless text.match?(ESCAPED_MESSAGE)
141
+
142
+ text.gsub(/\\n/, "\n")
143
+ end
144
+
145
+ def strip_code_fences(lines)
146
+ lines = lines.drop_while { |line| line.strip.empty? }
147
+
148
+ if lines.first&.match?(CODE_FENCE)
149
+ unfenced = lines.first.sub(CODE_FENCE, "").strip
150
+ unfenced.empty? ? lines.shift : lines[0] = unfenced
151
+ end
152
+
153
+ lines.pop while lines.last && (lines.last.strip.empty? || lines.last.strip.match?(CODE_FENCE))
154
+ lines
155
+ end
156
+
157
+ def strip_preamble(lines)
158
+ lines.drop_while { |line| line.strip.empty? || preamble?(line) }
159
+ end
160
+
161
+ def preamble?(line)
162
+ line.strip.match?(PREAMBLE_PREFIXES)
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+ # lib/ai_git/commands/config.rb
3
+ #
4
+ # @purpose Implement the `config` subcommand: print the resolved provider
5
+ # settings and the file they come from, so the user can see what
6
+ # the tool will talk to.
7
+ # @exports AIGit::Commands::Config: .call, .resolved_rows, .config_file.
8
+ # @dependencies ai_git/config: supplies every value printed and the path of
9
+ # the config file;
10
+ # ai_git/ai_client: supplies the read timeout shown;
11
+ # ai_git/ui: formats the heading and the key/value lines.
12
+ # @sideEffects Writes the resolved configuration to stdout; raises a string
13
+ # when the config file cannot be resolved.
14
+ # @notes Every value is resolved before the first line is printed, so a
15
+ # broken config file reports its error instead of a half-printed
16
+ # listing.
17
+
18
+ require_relative "../ai_client"
19
+ require_relative "../config"
20
+ require_relative "../ui"
21
+
22
+ module AIGit
23
+ module Commands
24
+ module Config
25
+ module_function
26
+
27
+ def call(_argv = [])
28
+ rows = resolved_rows(AIGit::Config)
29
+
30
+ AIGit::UI.heading("ai_git configuration")
31
+ rows.each { |key, value| AIGit::UI.kv(key, value) }
32
+ end
33
+
34
+ def resolved_rows(cfg)
35
+ [
36
+ ["Provider", cfg.provider],
37
+ ["Model", cfg.model_name],
38
+ ["Base URL", cfg.base_url],
39
+ ["Endpoint", cfg.endpoint],
40
+ ["Read timeout", "#{AIGit::AIClient::READ_TIMEOUT_SECONDS}s"],
41
+ ["Config file", config_file(cfg)]
42
+ ]
43
+ end
44
+
45
+ def config_file(cfg)
46
+ return cfg.config_path if cfg.config_path
47
+
48
+ dir = cfg.config_dir
49
+ return "(no home directory)" if dir.nil?
50
+
51
+ "#{File.join(dir, AIGit::Config::CONFIG_FILENAMES.first)} (not found)"
52
+ end
53
+ end
54
+ end
55
+ end