ai_git 0.3.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 +4 -4
- data/README.md +3 -61
- data/bin/ai_git +11 -0
- data/doc/RELEASE.md +77 -0
- data/doc/USAGE.md +107 -0
- data/lib/ai_git/ai_client.rb +53 -15
- data/lib/ai_git/commands/config.rb +37 -8
- data/lib/ai_git/commands/default.rb +148 -37
- data/lib/ai_git/config.rb +122 -2
- data/lib/ai_git/git.rb +57 -18
- data/lib/ai_git/options.rb +57 -0
- data/lib/ai_git/prompt.rb +67 -0
- data/lib/ai_git/secrets.rb +66 -0
- data/lib/ai_git/ui.rb +26 -6
- data/lib/ai_git/version.rb +7 -1
- data/lib/ai_git.rb +29 -6
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2fb023f0a7a794ab912a9502ce5155883ecad2171832690124534e4ded1ce551
|
|
4
|
+
data.tar.gz: bb16265d754dffabf9718b410d38d8f71e00cfc249e2a97ed41cc82cfefcca2c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: beb8cf739a17dc85da3e07bcd75dca5108e30f1aaef7249ac663435322440b0b4ada1768607e97faf15aab35cc629a01b58bacaddd491a3e71171838258ce1a0
|
|
7
|
+
data.tar.gz: 8a9e339b686103ecc350e3844f594f1b0bf0e7297e8305da1754399cfa71955c56eca197742781847213f71beb9b74e333276ca32c39b2edf81a1d5247d24155
|
data/README.md
CHANGED
|
@@ -2,65 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
AI-powered Git commit messages using a local LLM
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Documentation
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
```
|
|
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,5 +1,16 @@
|
|
|
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
|
|
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 |
|
data/lib/ai_git/ai_client.rb
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
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.
|
|
2
20
|
|
|
3
21
|
require "json"
|
|
4
22
|
require "net/http"
|
|
@@ -13,7 +31,6 @@ module AIGit
|
|
|
13
31
|
READ_TIMEOUT_SECONDS = 120
|
|
14
32
|
OPEN_TIMEOUT_SECONDS = 10
|
|
15
33
|
|
|
16
|
-
# Transient failures worth retrying with backoff.
|
|
17
34
|
MAX_ATTEMPTS = 3
|
|
18
35
|
RETRY_BASE_DELAY = 0.5
|
|
19
36
|
TRANSIENT_STATUSES = [408, 425, 429, 500, 502, 503, 504].freeze
|
|
@@ -22,10 +39,11 @@ module AIGit
|
|
|
22
39
|
Net::OpenTimeout, Net::ReadTimeout, SocketError, EOFError
|
|
23
40
|
].freeze
|
|
24
41
|
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
27
46
|
|
|
28
|
-
# Sends `prompt` to the local llama.cpp server and returns the cleaned text body.
|
|
29
47
|
def complete(prompt:, model_name:, temperature:)
|
|
30
48
|
sanitize(openai_complete(prompt, model_name, temperature))
|
|
31
49
|
end
|
|
@@ -111,17 +129,37 @@ module AIGit
|
|
|
111
129
|
end
|
|
112
130
|
|
|
113
131
|
def sanitize(text)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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)
|
|
125
163
|
end
|
|
126
164
|
end
|
|
127
165
|
end
|
|
@@ -1,4 +1,19 @@
|
|
|
1
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.
|
|
2
17
|
|
|
3
18
|
require_relative "../ai_client"
|
|
4
19
|
require_relative "../config"
|
|
@@ -6,20 +21,34 @@ require_relative "../ui"
|
|
|
6
21
|
|
|
7
22
|
module AIGit
|
|
8
23
|
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
24
|
module Config
|
|
12
25
|
module_function
|
|
13
26
|
|
|
14
27
|
def call(_argv = [])
|
|
15
|
-
|
|
28
|
+
rows = resolved_rows(AIGit::Config)
|
|
16
29
|
|
|
17
30
|
AIGit::UI.heading("ai_git configuration")
|
|
18
|
-
AIGit::UI.kv(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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)"
|
|
23
52
|
end
|
|
24
53
|
end
|
|
25
54
|
end
|
|
@@ -1,8 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# lib/ai_git/commands/default.rb
|
|
3
|
+
#
|
|
4
|
+
# @purpose Implement the default subcommand end to end: guard the staged
|
|
5
|
+
# diff, generate a commit message, confirm it, then commit and
|
|
6
|
+
# push.
|
|
7
|
+
# @exports AIGit::Commands::Default: .call, .run, .generate_commit_message,
|
|
8
|
+
# .normalize_message, .build_prompt.
|
|
9
|
+
# @dependencies ai_git/options: parses the flags .call receives;
|
|
10
|
+
# ai_git/git: reads the staged tree, commits, and pushes;
|
|
11
|
+
# ai_git/secrets: screens the diff before it leaves the machine;
|
|
12
|
+
# ai_git/config: supplies the model name and base-URL checks;
|
|
13
|
+
# ai_git/ai_client: generates the message;
|
|
14
|
+
# ai_git/prompt: runs the interactive confirmation and editor;
|
|
15
|
+
# ai_git/ui: prints the header, the message, and the outcome.
|
|
16
|
+
# @sideEffects Reads the repository, commits, pushes to origin, makes network
|
|
17
|
+
# requests, spawns the editor, and writes to stdout and stderr;
|
|
18
|
+
# raises a string on any refusal.
|
|
19
|
+
# @notes Refuses to send the diff over plain http to a non-loopback host
|
|
20
|
+
# or to send likely secrets, unless --force is passed. The
|
|
21
|
+
# confirmation prompt is skipped unless both streams are a tty.
|
|
2
22
|
|
|
3
23
|
require_relative "../ai_client"
|
|
4
24
|
require_relative "../config"
|
|
5
25
|
require_relative "../git"
|
|
26
|
+
require_relative "../options"
|
|
27
|
+
require_relative "../prompt"
|
|
28
|
+
require_relative "../secrets"
|
|
6
29
|
require_relative "../ui"
|
|
7
30
|
|
|
8
31
|
module AIGit
|
|
@@ -20,12 +43,16 @@ module AIGit
|
|
|
20
43
|
)
|
|
21
44
|
|
|
22
45
|
message = normalize_message(message)
|
|
23
|
-
message.empty?
|
|
46
|
+
raise empty_response_error if message.empty?
|
|
47
|
+
|
|
48
|
+
message
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def empty_response_error
|
|
52
|
+
"#{AIGit::Config.provider} returned an empty commit message. " \
|
|
53
|
+
"Check the model name and that the server is loaded (see `ai_git config`), then retry."
|
|
24
54
|
end
|
|
25
55
|
|
|
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
56
|
def normalize_message(message)
|
|
30
57
|
text = message.to_s.gsub(/\n{3,}/, "\n\n").strip
|
|
31
58
|
lines = text.lines.map(&:chomp)
|
|
@@ -35,39 +62,122 @@ module AIGit
|
|
|
35
62
|
lines.join("\n").gsub(/\n{3,}/, "\n\n").strip
|
|
36
63
|
end
|
|
37
64
|
|
|
38
|
-
def call(
|
|
39
|
-
|
|
40
|
-
|
|
65
|
+
def call(argv = [])
|
|
66
|
+
options = AIGit::Options.parse(argv)
|
|
67
|
+
return puts(AIGit::USAGE) if options.help?
|
|
41
68
|
|
|
42
69
|
staged = AIGit::Git.staged_files
|
|
43
|
-
|
|
70
|
+
raise "No staged files. Use `git add` first." if staged.strip.empty?
|
|
44
71
|
|
|
72
|
+
run(staged, options)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def run(staged, options)
|
|
45
76
|
diff = AIGit::Git.diff
|
|
46
77
|
branch = AIGit::Git.current_branch
|
|
78
|
+
model_name = AIGit::Config.model_name
|
|
47
79
|
|
|
48
|
-
|
|
80
|
+
check_base_url!(options)
|
|
81
|
+
check_secrets!(staged, diff, options)
|
|
82
|
+
|
|
83
|
+
print_header(AIGit::Config.provider, model_name, staged, branch)
|
|
49
84
|
|
|
50
85
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
86
|
+
message = resolve_message(diff, model_name, options)
|
|
87
|
+
return AIGit::UI.info("Aborted. Nothing committed.") if message.nil?
|
|
88
|
+
|
|
89
|
+
return dry_run_summary(branch, options) if options.dry_run?
|
|
51
90
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
91
|
+
commit_and_push(message, branch, options)
|
|
92
|
+
AIGit::UI.kv("Done in", format("%.1fs", Process.clock_gettime(Process::CLOCK_MONOTONIC) - started))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def resolve_message(diff, model_name, options)
|
|
96
|
+
loop do
|
|
97
|
+
AIGit::UI.info(AIGit::UI.bold("Generating commit message…"))
|
|
98
|
+
message = generate_commit_message(diff, model_name)
|
|
99
|
+
print_message(message)
|
|
100
|
+
|
|
101
|
+
return message unless confirm?(options)
|
|
102
|
+
|
|
103
|
+
case AIGit::Prompt.ask_action
|
|
104
|
+
when :accept then return message
|
|
105
|
+
when :edit then return edited_message(message)
|
|
106
|
+
when :abort then return nil
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
55
110
|
|
|
111
|
+
def confirm?(options)
|
|
112
|
+
!options.dry_run? && !options.assume_yes? && AIGit::Prompt.interactive?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def edited_message(message)
|
|
116
|
+
edited = normalize_message(AIGit::Prompt.edit(message))
|
|
117
|
+
raise "Aborted: the edited commit message is empty." if edited.empty?
|
|
118
|
+
|
|
119
|
+
print_message(edited)
|
|
120
|
+
edited
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def commit_and_push(message, branch, options)
|
|
56
124
|
AIGit::Git.commit_with_message(message)
|
|
57
125
|
AIGit::UI.success("Committed.")
|
|
58
126
|
|
|
127
|
+
return AIGit::UI.info(AIGit::UI.dim("Skipped push (--no-push).")) unless options.push?
|
|
128
|
+
|
|
129
|
+
return AIGit::UI.warning("Detached HEAD: skipping push. Commit is local only.") if branch.nil?
|
|
130
|
+
|
|
59
131
|
AIGit::Git.push_current_branch
|
|
60
132
|
AIGit::UI.success("Pushed to origin/#{branch}.")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def dry_run_summary(branch, options)
|
|
136
|
+
outcome =
|
|
137
|
+
if !options.push?
|
|
138
|
+
"would commit only"
|
|
139
|
+
elsif branch.nil?
|
|
140
|
+
"would commit only (detached HEAD)"
|
|
141
|
+
else
|
|
142
|
+
"would commit and push to origin/#{branch}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
AIGit::UI.info(AIGit::UI.dim("Dry run: nothing changed, #{outcome}."))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def check_base_url!(options)
|
|
149
|
+
return if AIGit::Config.loopback_base_url?
|
|
150
|
+
|
|
151
|
+
host = AIGit::Config.base_uri.host
|
|
152
|
+
|
|
153
|
+
if AIGit::Config.insecure_remote_base_url? && !options.force?
|
|
154
|
+
raise "Refusing to send the staged diff unencrypted to #{host} over http. " \
|
|
155
|
+
"Use https, a loopback address, or pass --force."
|
|
156
|
+
end
|
|
61
157
|
|
|
62
|
-
|
|
63
|
-
|
|
158
|
+
AIGit::UI.warning("Warning: the full staged diff will be sent to #{AIGit::Config.base_url} (not this machine).")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def check_secrets!(staged, diff, options)
|
|
162
|
+
findings = AIGit::Secrets.scan(staged, diff)
|
|
163
|
+
|
|
164
|
+
findings[:warnings].each { |finding| AIGit::UI.warning("Warning: #{finding}.") }
|
|
165
|
+
|
|
166
|
+
blocking = findings[:blocking]
|
|
167
|
+
return if blocking.empty?
|
|
168
|
+
|
|
169
|
+
blocking.each { |finding| AIGit::UI.warning("Possible secret: #{finding}.") }
|
|
170
|
+
return if options.force?
|
|
171
|
+
|
|
172
|
+
raise "Refusing to send possible secrets to #{AIGit::Config.provider}. " \
|
|
173
|
+
"Unstage these files or pass --force."
|
|
64
174
|
end
|
|
65
175
|
|
|
66
176
|
def print_header(provider, model_name, staged, branch)
|
|
67
177
|
AIGit::UI.kv("AI Provider", provider)
|
|
68
178
|
AIGit::UI.kv("Model", model_name)
|
|
69
179
|
AIGit::UI.kv("Staged Files", staged.to_s.strip.gsub("\n", ", "))
|
|
70
|
-
AIGit::UI.kv("Branch", branch)
|
|
180
|
+
AIGit::UI.kv("Branch", branch || "(detached HEAD)")
|
|
71
181
|
puts
|
|
72
182
|
end
|
|
73
183
|
|
|
@@ -92,20 +202,20 @@ module AIGit
|
|
|
92
202
|
|
|
93
203
|
STRICT OUTPUT FORMAT (follow exactly):
|
|
94
204
|
|
|
95
|
-
<short imperative title, max 72 chars>
|
|
205
|
+
<short imperative title, max 72 chars, summarizing the main change>
|
|
96
206
|
|
|
97
207
|
<blank line>
|
|
98
208
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
<1–3 sentences explaining the motivation or context behind the change. Omit if the reason is obvious.>
|
|
209
|
+
<body written as plain prose paragraphs, no headers, no bullet points, no tags.
|
|
210
|
+
Explain why the change is necessary and what problem it solves, how it addresses
|
|
211
|
+
the issue at a high level (do not recount line-by-line code changes, those are
|
|
212
|
+
visible in the diff), and any side effects or tradeoffs reviewers should know about.>
|
|
104
213
|
|
|
105
214
|
RULES:
|
|
106
215
|
- 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
|
-
-
|
|
108
|
-
-
|
|
216
|
+
- Body: plain prose only. No markdown headers, no bullet points, no bold/italics, no tags.
|
|
217
|
+
- Body: explain WHY the change is necessary and WHAT problem it solves, then HOW it addresses the issue at a high level. Do not describe the code line by line.
|
|
218
|
+
- Body: mention side effects or tradeoffs only if they exist and matter to a reviewer.
|
|
109
219
|
- No filler phrases ("this commit", "this PR", "as per discussion").
|
|
110
220
|
- No line should exceed 72 characters.
|
|
111
221
|
|
|
@@ -113,27 +223,28 @@ module AIGit
|
|
|
113
223
|
|
|
114
224
|
Add JWT-based login with refresh token support
|
|
115
225
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
226
|
+
Users were being logged out on every page reload because sessions
|
|
227
|
+
were not persisted across requests. This made the app unusable for
|
|
228
|
+
any workflow longer than a single page view.
|
|
229
|
+
|
|
230
|
+
Introduces a login endpoint that issues short-lived access tokens
|
|
231
|
+
alongside long-lived refresh tokens, plus a refresh route that
|
|
232
|
+
rotates tokens on use. Refresh tokens are stored in encrypted
|
|
233
|
+
HTTP-only cookies so the client never handles them directly.
|
|
121
234
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
sessions to persist securely without requiring re-authentication.
|
|
235
|
+
Adds a new middleware layer on private routes, so any route not
|
|
236
|
+
yet wired to it remains unauthenticated until migrated.
|
|
125
237
|
|
|
126
238
|
---
|
|
127
239
|
|
|
128
240
|
Prevent nil crash when user preferences are missing
|
|
129
241
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
242
|
+
Reports were raising NoMethodError in production for accounts
|
|
243
|
+
created before the preferences feature shipped, since those
|
|
244
|
+
accounts have no preferences record at all.
|
|
133
245
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
before the preferences feature shipped.
|
|
246
|
+
Falls back to system defaults whenever a user's preferences are
|
|
247
|
+
absent, so report generation no longer assumes the record exists.
|
|
137
248
|
|
|
138
249
|
---
|
|
139
250
|
|
data/lib/ai_git/config.rb
CHANGED
|
@@ -1,4 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# lib/ai_git/config.rb
|
|
3
|
+
#
|
|
4
|
+
# @purpose Resolve the model provider settings from the defaults and the
|
|
5
|
+
# user's YAML config file, and judge whether the base URL is safe
|
|
6
|
+
# to send to.
|
|
7
|
+
# @exports AIGit::Config: PROVIDER, DEFAULT_MODEL, DEFAULT_BASE_URL,
|
|
8
|
+
# ENDPOINT, LOOPBACK_HOST, CONFIG_DIR_NAME, CONFIG_FILENAMES,
|
|
9
|
+
# SETTING_KEYS, TRUTHY_VALUES, .provider, .config_dir,
|
|
10
|
+
# .config_path, .settings, .reset!, .model_name, .base_url,
|
|
11
|
+
# .no_color?, .endpoint, .base_uri, .valid_uri?,
|
|
12
|
+
# .loopback_base_url?, .insecure_remote_base_url?.
|
|
13
|
+
# @dependencies uri: parses and validates the configured base URL;
|
|
14
|
+
# yaml: parses ~/.ai_git/config.yml.
|
|
15
|
+
# @sideEffects Reads ~/.ai_git/config.yml on first use and memoizes it;
|
|
16
|
+
# raises a string on a malformed file, an unknown setting, or a
|
|
17
|
+
# malformed URL.
|
|
18
|
+
# @notes The file is read once per process, so .reset! exists to drop the
|
|
19
|
+
# memo. An unknown key raises rather than being ignored, so a
|
|
20
|
+
# typo never silently leaves the default in place. Only a loopback
|
|
21
|
+
# host keeps the diff on this machine, so every other host counts
|
|
22
|
+
# as remote for the plain-http refusal.
|
|
23
|
+
|
|
24
|
+
require "uri"
|
|
25
|
+
require "yaml"
|
|
2
26
|
|
|
3
27
|
module AIGit
|
|
4
28
|
module Config
|
|
@@ -6,6 +30,11 @@ module AIGit
|
|
|
6
30
|
DEFAULT_MODEL = "ggml-org/gemma-4-E4B-it-GGUF:Q8_0"
|
|
7
31
|
DEFAULT_BASE_URL = "http://127.0.0.1:8080"
|
|
8
32
|
ENDPOINT = "/v1/chat/completions"
|
|
33
|
+
LOOPBACK_HOST = /\A(localhost|127(\.\d{1,3}){3}|::1|0:0:0:0:0:0:0:1)\z/i.freeze
|
|
34
|
+
CONFIG_DIR_NAME = ".ai_git"
|
|
35
|
+
CONFIG_FILENAMES = %w[config.yml config.yaml].freeze
|
|
36
|
+
SETTING_KEYS = %w[model_name base_url no_color].freeze
|
|
37
|
+
TRUTHY_VALUES = [true, "true", "yes", "on", "1"].freeze
|
|
9
38
|
|
|
10
39
|
module_function
|
|
11
40
|
|
|
@@ -13,16 +42,107 @@ module AIGit
|
|
|
13
42
|
PROVIDER
|
|
14
43
|
end
|
|
15
44
|
|
|
45
|
+
def config_dir
|
|
46
|
+
File.join(Dir.home, CONFIG_DIR_NAME)
|
|
47
|
+
rescue ArgumentError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def config_path
|
|
52
|
+
dir = config_dir
|
|
53
|
+
return nil if dir.nil?
|
|
54
|
+
|
|
55
|
+
CONFIG_FILENAMES.map { |name| File.join(dir, name) }.find { |path| File.file?(path) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def settings
|
|
59
|
+
@settings ||= load_settings
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def reset!
|
|
63
|
+
@settings = nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def load_settings
|
|
67
|
+
path = config_path
|
|
68
|
+
return {} if path.nil?
|
|
69
|
+
|
|
70
|
+
validate_settings(parse_file(path), path)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def parse_file(path)
|
|
74
|
+
YAML.safe_load_file(path) || {}
|
|
75
|
+
rescue Psych::SyntaxError => e
|
|
76
|
+
raise "Invalid YAML in #{path}: #{e.message}"
|
|
77
|
+
rescue SystemCallError => e
|
|
78
|
+
raise "Cannot read #{path}: #{e.message}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_settings(data, path)
|
|
82
|
+
raise "Invalid config in #{path}: expected a mapping of settings." unless data.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
data = data.transform_keys(&:to_s)
|
|
85
|
+
unknown = data.keys - SETTING_KEYS
|
|
86
|
+
return data if unknown.empty?
|
|
87
|
+
|
|
88
|
+
raise "Unknown setting#{'s' if unknown.length > 1} in #{path}: #{unknown.join(', ')}. " \
|
|
89
|
+
"Known settings: #{SETTING_KEYS.join(', ')}."
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def string_setting(key, default)
|
|
93
|
+
value = settings[key]
|
|
94
|
+
return default if value.nil?
|
|
95
|
+
|
|
96
|
+
raise "Invalid #{key} in #{config_path}: expected a non-empty string." unless string_value?(value)
|
|
97
|
+
|
|
98
|
+
value.strip
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def string_value?(value)
|
|
102
|
+
value.is_a?(String) && !value.strip.empty?
|
|
103
|
+
end
|
|
104
|
+
|
|
16
105
|
def model_name
|
|
17
|
-
|
|
106
|
+
string_setting("model_name", DEFAULT_MODEL)
|
|
18
107
|
end
|
|
19
108
|
|
|
20
109
|
def base_url
|
|
21
|
-
|
|
110
|
+
string_setting("base_url", DEFAULT_BASE_URL)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def no_color?
|
|
114
|
+
value = settings["no_color"]
|
|
115
|
+
value = value.downcase if value.is_a?(String)
|
|
116
|
+
TRUTHY_VALUES.include?(value)
|
|
22
117
|
end
|
|
23
118
|
|
|
24
119
|
def endpoint
|
|
25
120
|
ENDPOINT
|
|
26
121
|
end
|
|
122
|
+
|
|
123
|
+
def base_uri
|
|
124
|
+
uri = URI.parse(base_url)
|
|
125
|
+
raise invalid_base_url_error unless valid_uri?(uri)
|
|
126
|
+
|
|
127
|
+
uri
|
|
128
|
+
rescue URI::InvalidURIError
|
|
129
|
+
raise invalid_base_url_error
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def invalid_base_url_error
|
|
133
|
+
"Invalid base_url #{base_url.inspect}: expected an http(s) URL. See `ai_git config`."
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def valid_uri?(uri)
|
|
137
|
+
%w[http https].include?(uri.scheme) && !uri.host.to_s.empty?
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def loopback_base_url?
|
|
141
|
+
base_uri.host.to_s.delete("[]").match?(LOOPBACK_HOST)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def insecure_remote_base_url?
|
|
145
|
+
!loopback_base_url? && base_uri.scheme == "http"
|
|
146
|
+
end
|
|
27
147
|
end
|
|
28
148
|
end
|
data/lib/ai_git/git.rb
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# lib/ai_git/git.rb
|
|
3
|
+
#
|
|
4
|
+
# @purpose Wrap the git porcelain this tool drives: inspect the staged
|
|
5
|
+
# tree, then commit and push on the user's behalf.
|
|
6
|
+
# @exports AIGit::Git: NOT_A_REPOSITORY, MAX_ERROR_DETAIL, .repository?,
|
|
7
|
+
# .ensure_repository!, .staged_files, .diff, .current_branch,
|
|
8
|
+
# .detached_head?, .commit_with_message, .push_current_branch.
|
|
9
|
+
# @dependencies git: every operation shells out to the binary;
|
|
10
|
+
# open3: captures stdout, stderr, and exit status together;
|
|
11
|
+
# tempfile: holds the commit message passed to `git commit -F`.
|
|
12
|
+
# @sideEffects Spawns git subprocesses; writes a tempfile; mutates the
|
|
13
|
+
# repository and the remote on commit and push.
|
|
14
|
+
# @notes Raises a bare string message; bin/ai_git renders it and exits 1.
|
|
2
15
|
|
|
3
16
|
require "open3"
|
|
4
17
|
require "tempfile"
|
|
@@ -7,39 +20,65 @@ module AIGit
|
|
|
7
20
|
module Git
|
|
8
21
|
module_function
|
|
9
22
|
|
|
23
|
+
NOT_A_REPOSITORY = "Not a git repository (or any of the parent directories)."
|
|
24
|
+
MAX_ERROR_DETAIL = 500
|
|
25
|
+
|
|
26
|
+
def repository?
|
|
27
|
+
_stdout, _stderr, status = Open3.capture3("git", "rev-parse", "--git-dir")
|
|
28
|
+
status.success?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ensure_repository!
|
|
32
|
+
raise NOT_A_REPOSITORY unless repository?
|
|
33
|
+
end
|
|
34
|
+
|
|
10
35
|
def staged_files
|
|
11
|
-
|
|
36
|
+
ensure_repository!
|
|
37
|
+
capture("git", "diff", "--cached", "--name-only")
|
|
12
38
|
end
|
|
13
39
|
|
|
14
40
|
def diff
|
|
15
|
-
|
|
41
|
+
ensure_repository!
|
|
42
|
+
capture("git", "diff", "--cached")
|
|
16
43
|
end
|
|
17
44
|
|
|
18
45
|
def current_branch
|
|
19
|
-
|
|
46
|
+
ensure_repository!
|
|
47
|
+
stdout, _stderr, status = Open3.capture3("git", "symbolic-ref", "--quiet", "--short", "HEAD")
|
|
48
|
+
return nil unless status.success?
|
|
49
|
+
|
|
50
|
+
branch = stdout.chomp
|
|
51
|
+
branch.empty? ? nil : branch
|
|
20
52
|
end
|
|
21
53
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
54
|
+
def detached_head?
|
|
55
|
+
current_branch.nil?
|
|
56
|
+
end
|
|
33
57
|
|
|
34
|
-
|
|
58
|
+
def capture(*argv)
|
|
59
|
+
stdout, stderr, status = Open3.capture3(*argv)
|
|
60
|
+
return stdout if status.success?
|
|
61
|
+
|
|
62
|
+
raise command_error(argv, stderr, status)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def run_command(cmd, *args)
|
|
66
|
+
argv = [cmd, *args.map(&:to_s)]
|
|
67
|
+
_stdout, stderr, status = Open3.capture3(*argv)
|
|
35
68
|
|
|
36
69
|
return if status.success?
|
|
37
70
|
|
|
38
|
-
raise
|
|
71
|
+
raise command_error(argv, stderr, status)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def command_error(argv, stderr, status)
|
|
75
|
+
detail = stderr.to_s.strip
|
|
76
|
+
detail = "#{detail[0, MAX_ERROR_DETAIL]}…" if detail.length > MAX_ERROR_DETAIL
|
|
77
|
+
header = "Command failed: #{argv.join(' ')} (exit #{status.exitstatus})"
|
|
78
|
+
|
|
79
|
+
detail.empty? ? header : "#{header}\n#{detail}"
|
|
39
80
|
end
|
|
40
81
|
|
|
41
|
-
# Commit using a temp file so the message can contain anything (quotes,
|
|
42
|
-
# backticks, dollar signs) without shell-escaping concerns.
|
|
43
82
|
def commit_with_message(message)
|
|
44
83
|
Tempfile.create("ai_git_commit_msg") do |file|
|
|
45
84
|
file.write(message)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# lib/ai_git/options.rb
|
|
3
|
+
#
|
|
4
|
+
# @purpose Parse the default command's flags into a value object that the
|
|
5
|
+
# command queries instead of re-reading the argument vector.
|
|
6
|
+
# @exports AIGit::Options: .parse, #dry_run?, #push?, #assume_yes?,
|
|
7
|
+
# #force?, #help?, and the matching accessors.
|
|
8
|
+
# @sideEffects None.
|
|
9
|
+
# @notes An unrecognized argument raises rather than being ignored, so a
|
|
10
|
+
# mistyped flag never silently commits and pushes.
|
|
11
|
+
|
|
12
|
+
module AIGit
|
|
13
|
+
class Options
|
|
14
|
+
attr_accessor :dry_run, :push, :assume_yes, :force, :help
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@dry_run = false
|
|
18
|
+
@push = true
|
|
19
|
+
@assume_yes = false
|
|
20
|
+
@force = false
|
|
21
|
+
@help = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.parse(argv)
|
|
25
|
+
argv.to_a.each_with_object(new) do |arg, options|
|
|
26
|
+
case arg
|
|
27
|
+
when "-n", "--dry-run" then options.dry_run = true
|
|
28
|
+
when "--no-push" then options.push = false
|
|
29
|
+
when "-y", "--yes" then options.assume_yes = true
|
|
30
|
+
when "-f", "--force" then options.force = true
|
|
31
|
+
when "-h", "--help" then options.help = true
|
|
32
|
+
else raise "Unknown option: #{arg}. Run `ai_git --help` for usage."
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def dry_run?
|
|
38
|
+
@dry_run
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def push?
|
|
42
|
+
@push
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def assume_yes?
|
|
46
|
+
@assume_yes
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def force?
|
|
50
|
+
@force
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def help?
|
|
54
|
+
@help
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# lib/ai_git/prompt.rb
|
|
3
|
+
#
|
|
4
|
+
# @purpose Own the interactive confirmation step: ask what to do with the
|
|
5
|
+
# generated message, and open the user's editor on request.
|
|
6
|
+
# @exports AIGit::Prompt: ACTIONS, QUESTION, .interactive?, .ask_action,
|
|
7
|
+
# .edit.
|
|
8
|
+
# @dependencies ai_git/ui: bolds the question before it is printed;
|
|
9
|
+
# shellwords: splits $VISUAL or $EDITOR into command and args;
|
|
10
|
+
# tempfile: holds the message the editor opens.
|
|
11
|
+
# @sideEffects Reads stdin and writes to stdout; spawns the editor process;
|
|
12
|
+
# writes a tempfile; reads $VISUAL and $EDITOR.
|
|
13
|
+
# @notes A closed stdin, where gets returns nil, is treated as an abort
|
|
14
|
+
# so a piped run never blocks waiting on an answer.
|
|
15
|
+
|
|
16
|
+
require "shellwords"
|
|
17
|
+
require "tempfile"
|
|
18
|
+
|
|
19
|
+
require_relative "ui"
|
|
20
|
+
|
|
21
|
+
module AIGit
|
|
22
|
+
module Prompt
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
ACTIONS = {
|
|
26
|
+
"" => :accept, "a" => :accept, "accept" => :accept, "y" => :accept, "yes" => :accept,
|
|
27
|
+
"e" => :edit, "edit" => :edit,
|
|
28
|
+
"r" => :regenerate, "regen" => :regenerate, "regenerate" => :regenerate,
|
|
29
|
+
"q" => :abort, "quit" => :abort, "n" => :abort, "no" => :abort, "abort" => :abort
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
QUESTION = "Commit this message? [A]ccept / [e]dit / [r]egenerate / [q]uit: "
|
|
33
|
+
|
|
34
|
+
def interactive?
|
|
35
|
+
$stdin.tty? && $stdout.tty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def ask_action
|
|
39
|
+
loop do
|
|
40
|
+
$stdout.print AIGit::UI.bold(QUESTION)
|
|
41
|
+
$stdout.flush
|
|
42
|
+
|
|
43
|
+
answer = $stdin.gets
|
|
44
|
+
return :abort if answer.nil?
|
|
45
|
+
|
|
46
|
+
action = ACTIONS[answer.strip.downcase]
|
|
47
|
+
return action if action
|
|
48
|
+
|
|
49
|
+
AIGit::UI.info("Please answer a, e, r or q.")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def edit(message)
|
|
54
|
+
editor = ENV["VISUAL"] || ENV["EDITOR"]
|
|
55
|
+
raise "Cannot edit: set $EDITOR or $VISUAL first." if editor.to_s.strip.empty?
|
|
56
|
+
|
|
57
|
+
Tempfile.create(["ai_git_commit_msg", ".txt"]) do |file|
|
|
58
|
+
file.write(message)
|
|
59
|
+
file.flush
|
|
60
|
+
|
|
61
|
+
raise "Editor #{editor} exited with an error." unless system(*Shellwords.split(editor), file.path)
|
|
62
|
+
|
|
63
|
+
File.read(file.path)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# lib/ai_git/secrets.rb
|
|
3
|
+
#
|
|
4
|
+
# @purpose Screen the staged paths and diff for credentials before the
|
|
5
|
+
# diff leaves the machine, splitting blocking hits from warnings.
|
|
6
|
+
# @exports AIGit::Secrets: RISKY_PATHS, RISKY_CONTENT,
|
|
7
|
+
# SUSPICIOUS_ASSIGNMENT, .scan.
|
|
8
|
+
# @sideEffects None.
|
|
9
|
+
# @notes Only added lines are scanned: removing a secret is not a leak.
|
|
10
|
+
# A bare key/secret/password assignment warns rather than blocks,
|
|
11
|
+
# because the pattern also matches ordinary code.
|
|
12
|
+
|
|
13
|
+
module AIGit
|
|
14
|
+
module Secrets
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
RISKY_PATHS = {
|
|
18
|
+
"an environment file" => %r{(\A|/)\.env(\.[^/]+)?\z},
|
|
19
|
+
"an SSH private key" => %r{(\A|/)id_(rsa|dsa|ecdsa|ed25519)\z},
|
|
20
|
+
"a private key file" => /\.(pem|key|p12|pfx|jks|keystore)\z/i,
|
|
21
|
+
"a credentials file" => %r{(\A|/)(credentials|\.netrc|\.npmrc|\.pypirc|\.htpasswd)\z},
|
|
22
|
+
"a secrets file" => %r{(\A|/)secrets?\.(ya?ml|json|toml)\z}i
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
RISKY_CONTENT = {
|
|
26
|
+
"a private key block" => /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
27
|
+
"an AWS access key id" => /\bAKIA[0-9A-Z]{16}\b/,
|
|
28
|
+
"a GitHub token" => /\b(gh[posur]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,})\b/,
|
|
29
|
+
"an OpenAI-style API key" => /\bsk-[A-Za-z0-9_-]{20,}\b/,
|
|
30
|
+
"a Slack token" => /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/,
|
|
31
|
+
"a Google API key" => /\bAIza[0-9A-Za-z_-]{35}\b/
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
SUSPICIOUS_ASSIGNMENT = /
|
|
35
|
+
\b(api[_-]?key|secret|password|passwd|token|access[_-]?key)\b
|
|
36
|
+
\s*[:=]\s*["'][^"']{8,}["']
|
|
37
|
+
/ix.freeze
|
|
38
|
+
|
|
39
|
+
def scan(staged_files, diff)
|
|
40
|
+
{ blocking: blocking_findings(staged_files, diff), warnings: warning_findings(diff) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def blocking_findings(staged_files, diff)
|
|
44
|
+
paths = staged_files.to_s.lines.map(&:strip).reject(&:empty?)
|
|
45
|
+
added = added_lines(diff)
|
|
46
|
+
|
|
47
|
+
findings = paths.flat_map do |path|
|
|
48
|
+
RISKY_PATHS.filter_map { |label, pattern| "#{path} looks like #{label}" if path.match?(pattern) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
findings + RISKY_CONTENT.filter_map do |label, pattern|
|
|
52
|
+
"added lines contain what looks like #{label}" if added.match?(pattern)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def warning_findings(diff)
|
|
57
|
+
return [] unless added_lines(diff).match?(SUSPICIOUS_ASSIGNMENT)
|
|
58
|
+
|
|
59
|
+
["added lines assign a value to a key/secret/password/token name"]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def added_lines(diff)
|
|
63
|
+
diff.to_s.lines.select { |line| line.start_with?("+") && !line.start_with?("+++") }.join
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/ai_git/ui.rb
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# lib/ai_git/ui.rb
|
|
3
|
+
#
|
|
4
|
+
# @purpose Render every line the CLI prints, adding ANSI color only when
|
|
5
|
+
# the terminal and the configuration both allow it.
|
|
6
|
+
# @exports AIGit::UI: CODES, .color?, .no_color?, .paint, .bold, .dim,
|
|
7
|
+
# .kv, .heading, .info, .success, .warning, .error.
|
|
8
|
+
# @dependencies ai_git/config: supplies the no_color setting from
|
|
9
|
+
# ~/.ai_git/config.yml.
|
|
10
|
+
# @sideEffects Writes to stdout and stderr; inspects $stdout.tty?.
|
|
11
|
+
# @notes Color is off whenever the config file sets no_color or stdout
|
|
12
|
+
# is not a terminal, so piped output stays plain. A config file
|
|
13
|
+
# that fails to load also turns color off rather than raising, so
|
|
14
|
+
# that error itself can still be printed.
|
|
15
|
+
|
|
16
|
+
require_relative "config"
|
|
2
17
|
|
|
3
18
|
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
19
|
module UI
|
|
7
20
|
module_function
|
|
8
21
|
|
|
@@ -10,15 +23,18 @@ module AIGit
|
|
|
10
23
|
bold: 1, dim: 2, red: 31, green: 32, yellow: 33, blue: 34, cyan: 36, gray: 90
|
|
11
24
|
}.freeze
|
|
12
25
|
|
|
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
26
|
def color?
|
|
16
|
-
return false if
|
|
17
|
-
return false if ENV["AI_GIT_NO_COLOR"] && !ENV["AI_GIT_NO_COLOR"].empty?
|
|
27
|
+
return false if no_color?
|
|
18
28
|
|
|
19
29
|
$stdout.tty?
|
|
20
30
|
end
|
|
21
31
|
|
|
32
|
+
def no_color?
|
|
33
|
+
AIGit::Config.no_color?
|
|
34
|
+
rescue StandardError
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
22
38
|
def paint(text, *styles)
|
|
23
39
|
return text.to_s unless color?
|
|
24
40
|
|
|
@@ -52,6 +68,10 @@ module AIGit
|
|
|
52
68
|
puts paint(text, :green)
|
|
53
69
|
end
|
|
54
70
|
|
|
71
|
+
def warning(text)
|
|
72
|
+
warn paint(text, :yellow)
|
|
73
|
+
end
|
|
74
|
+
|
|
55
75
|
def error(text)
|
|
56
76
|
$stderr.puts paint(text, :red) # rubocop:disable Style/StderrPuts
|
|
57
77
|
end
|
data/lib/ai_git/version.rb
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# lib/ai_git/version.rb
|
|
3
|
+
#
|
|
4
|
+
# @purpose Hold the single source of truth for the gem's version number,
|
|
5
|
+
# read by the gemspec, the CLI, and the release check.
|
|
6
|
+
# @exports AIGit::VERSION.
|
|
7
|
+
# @sideEffects None.
|
|
2
8
|
|
|
3
9
|
module AIGit
|
|
4
|
-
VERSION = "0.
|
|
10
|
+
VERSION = "1.0.1"
|
|
5
11
|
end
|
data/lib/ai_git.rb
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# lib/ai_git.rb
|
|
3
|
+
#
|
|
4
|
+
# @purpose Library entry point and CLI router: load every component, then
|
|
5
|
+
# dispatch the argument vector to the matching subcommand.
|
|
6
|
+
# @exports AIGit: SUBCOMMANDS, HELP_FLAGS, VERSION_FLAGS, USAGE, .start.
|
|
7
|
+
# @dependencies ai_git/version, ai_git/config, ai_git/ui, ai_git/ai_client,
|
|
8
|
+
# ai_git/git: the components the subcommands build on;
|
|
9
|
+
# ai_git/commands/default, ai_git/commands/config: the two
|
|
10
|
+
# subcommands .start dispatches to.
|
|
11
|
+
# @sideEffects Prints usage or the version to stdout; warns and exits 1 on an
|
|
12
|
+
# unknown subcommand; .start runs the selected subcommand.
|
|
13
|
+
# @notes A first argument starting with "-" is left in place for the
|
|
14
|
+
# default command's parser, so bare flags need no subcommand.
|
|
2
15
|
|
|
3
16
|
require_relative "ai_git/version"
|
|
4
17
|
require_relative "ai_git/config"
|
|
@@ -20,20 +33,30 @@ module AIGit
|
|
|
20
33
|
VERSION_FLAGS = %w[-v --version].freeze
|
|
21
34
|
|
|
22
35
|
USAGE = <<~USAGE
|
|
23
|
-
Usage: ai_git [subcommand]
|
|
36
|
+
Usage: ai_git [subcommand] [options]
|
|
24
37
|
|
|
25
38
|
Subcommands:
|
|
26
39
|
(none) Generate a commit message, commit, and push staged files
|
|
27
40
|
config Show the resolved provider configuration
|
|
28
41
|
|
|
29
|
-
|
|
42
|
+
Options:
|
|
43
|
+
-n, --dry-run Print the generated message and change nothing
|
|
44
|
+
--no-push Commit locally without pushing
|
|
45
|
+
-y, --yes Skip the confirmation prompt (unattended)
|
|
46
|
+
-f, --force Proceed despite secret or remote-server warnings
|
|
30
47
|
-h, --help Show this message
|
|
31
48
|
-v, --version Print version
|
|
32
49
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
50
|
+
On a terminal ai_git asks before committing: accept, edit, regenerate or
|
|
51
|
+
quit. Piped or scripted runs commit and push unattended.
|
|
52
|
+
|
|
53
|
+
Configuration (~/.ai_git/config.yml, or config.yaml):
|
|
54
|
+
model_name: ggml-org/gemma-4-E4B-it-GGUF:Q8_0 Model to prompt
|
|
55
|
+
base_url: http://127.0.0.1:8080 llama.cpp server
|
|
56
|
+
no_color: true Disable colored output
|
|
57
|
+
|
|
58
|
+
Run `ai_git config` to see the resolved settings and the file they
|
|
59
|
+
come from.
|
|
37
60
|
USAGE
|
|
38
61
|
|
|
39
62
|
def start(args)
|
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.
|
|
4
|
+
version: 1.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaíque Kandy Koga
|
|
@@ -19,12 +19,17 @@ files:
|
|
|
19
19
|
- LICENSE
|
|
20
20
|
- README.md
|
|
21
21
|
- bin/ai_git
|
|
22
|
+
- doc/RELEASE.md
|
|
23
|
+
- doc/USAGE.md
|
|
22
24
|
- lib/ai_git.rb
|
|
23
25
|
- lib/ai_git/ai_client.rb
|
|
24
26
|
- lib/ai_git/commands/config.rb
|
|
25
27
|
- lib/ai_git/commands/default.rb
|
|
26
28
|
- lib/ai_git/config.rb
|
|
27
29
|
- lib/ai_git/git.rb
|
|
30
|
+
- lib/ai_git/options.rb
|
|
31
|
+
- lib/ai_git/prompt.rb
|
|
32
|
+
- lib/ai_git/secrets.rb
|
|
28
33
|
- lib/ai_git/ui.rb
|
|
29
34
|
- lib/ai_git/version.rb
|
|
30
35
|
homepage: https://github.com/kaiquekandykoga/ai_git
|
|
@@ -48,7 +53,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
48
53
|
- !ruby/object:Gem::Version
|
|
49
54
|
version: '0'
|
|
50
55
|
requirements: []
|
|
51
|
-
rubygems_version: 4.0.
|
|
56
|
+
rubygems_version: 4.0.16
|
|
52
57
|
specification_version: 4
|
|
53
58
|
summary: AI-powered Git commit messages using a local LLM
|
|
54
59
|
test_files: []
|