ai_git 1.0.1 → 2.0.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 +4 -4
- data/CHANGELOG.md +69 -0
- data/README.md +45 -0
- data/bin/ai_git +2 -2
- data/doc/USAGE.md +27 -8
- data/lib/ai_git/ai_client.rb +30 -10
- data/lib/ai_git/commands/{default.rb → commit.rb} +7 -5
- data/lib/ai_git/commands/config.rb +10 -3
- data/lib/ai_git/commands/help.rb +133 -0
- data/lib/ai_git/config.rb +3 -3
- data/lib/ai_git/git.rb +18 -5
- data/lib/ai_git/prompt.rb +1 -1
- data/lib/ai_git/secrets.rb +1 -1
- data/lib/ai_git/ui.rb +1 -1
- data/lib/ai_git/version.rb +1 -1
- data/lib/ai_git.rb +27 -50
- metadata +10 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 31071defb6673e900e4ff0701b7299dec4d17a3eb0cdf6674c4c115a70bfa242
|
|
4
|
+
data.tar.gz: f0e0c388832e033b9b09fdc77bac62a14a8984491c3b8074eda84c221de1af7d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b585195bd070425d3530f455ffe1d73f05e6352af264ce5342e27890afca8e30004d8ac8328da5440a3d3fe83bbc7633aa7cf8527267930ab254a91e0b3db4f9
|
|
7
|
+
data.tar.gz: dd56c54bfd0512f03bd30eadb5533c4d63644e2c5701bf415b14a27b863c1ecbf6cddc898249872c49cba95bcc4c70a25609b870f7f95b312503d40a16b2a7e6
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `ai_git` are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [2.0.0] - 2026-09-11
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `ai_git help [subcommand]` documents every subcommand and flag.
|
|
14
|
+
- Dependabot watches Bundler and GitHub Actions weekly.
|
|
15
|
+
- `CHANGELOG.md`, `.ruby-version`, `spec.email`, `required_ruby_version`, and
|
|
16
|
+
the `homepage_uri` / `documentation_uri` / `changelog_uri` gem metadata.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **Breaking:** every action is now named. `ai_git commit` is the only path
|
|
21
|
+
that writes, and a bare `ai_git` does nothing; scripts that relied on the
|
|
22
|
+
implicit commit must call `ai_git commit`.
|
|
23
|
+
- `required_ruby_version` is `>= 3.1`, and CI runs the suite on 3.1, 3.2, 3.3,
|
|
24
|
+
3.4 and 4.0 across Ubuntu and macOS.
|
|
25
|
+
- Command handling moved into dedicated `AIGit::Commands` modules.
|
|
26
|
+
- `.gitignore` replaced the inherited C/CMake rules with Ruby ones.
|
|
27
|
+
- `Gemfile.lock` is no longer tracked; the gem resolves fresh on every install.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Real commit titles and non-UTF-8 diffs are no longer dropped.
|
|
32
|
+
- The stricter RuboCop configuration and the offenses it surfaced.
|
|
33
|
+
|
|
34
|
+
## [1.0.1] - 2026-08-29
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- `~/.ai_git/config.yml` holds `model_name`, `base_url`, and `no_color`.
|
|
39
|
+
- Release automation: pushing a `lib/ai_git/version.rb` bump to `master` tags
|
|
40
|
+
the release and publishes to RubyGems via trusted publishing (OIDC).
|
|
41
|
+
- `doc/USAGE.md` and file headers across the source tree.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- Configuration moved from environment variables to the YAML config file.
|
|
46
|
+
|
|
47
|
+
### Removed
|
|
48
|
+
|
|
49
|
+
- The `NO_COLOR` environment variable, superseded by the config file.
|
|
50
|
+
|
|
51
|
+
## 1.0.0 - 2026-08-29
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- Brakes on the write path: a confirmation prompt plus `--dry-run`,
|
|
56
|
+
`--no-push`, `--yes`, and `--force`.
|
|
57
|
+
- Staged-secret scanning that blocks the run when a `.env`, private key, or
|
|
58
|
+
token-shaped string is staged.
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- Correctness bugs in git reads, which are now checked, and in empty model
|
|
63
|
+
responses, which now fail loudly.
|
|
64
|
+
|
|
65
|
+
Releases before 1.0.0 predate this changelog; see the git history for them.
|
|
66
|
+
|
|
67
|
+
[Unreleased]: https://github.com/kaiquekandykoga/ai_git/compare/v2.0.0...HEAD
|
|
68
|
+
[2.0.0]: https://github.com/kaiquekandykoga/ai_git/compare/v1.0.1...v2.0.0
|
|
69
|
+
[1.0.1]: https://github.com/kaiquekandykoga/ai_git/releases/tag/v1.0.1
|
data/README.md
CHANGED
|
@@ -2,6 +2,51 @@
|
|
|
2
2
|
|
|
3
3
|
AI-powered Git commit messages using a local LLM
|
|
4
4
|
|
|
5
|
+
`ai_git` is a Ruby command-line tool that writes your commit messages for you.
|
|
6
|
+
Stage your changes, run `ai_git commit`, and it reads the staged diff, asks a
|
|
7
|
+
local [llama.cpp](https://github.com/ggml-org/llama.cpp) server for a commit
|
|
8
|
+
message, shows it to you, and — once you accept — commits and pushes to
|
|
9
|
+
`origin`.
|
|
10
|
+
|
|
11
|
+
Every action is named: a bare `ai_git` does nothing, and `ai_git help` lists
|
|
12
|
+
what it can do.
|
|
13
|
+
|
|
14
|
+
The generated message follows the conventional shape of a good commit: a short
|
|
15
|
+
imperative title under 72 characters, a blank line, then plain prose explaining
|
|
16
|
+
why the change was necessary and what problem it solves, rather than restating
|
|
17
|
+
the diff.
|
|
18
|
+
|
|
19
|
+
## Why a local model
|
|
20
|
+
|
|
21
|
+
The full staged diff goes to whatever `base_url` you configure, and the default
|
|
22
|
+
is your own machine (`http://127.0.0.1:8080`), so nothing leaves it and no API
|
|
23
|
+
key is needed. Point it at another host and ai_git warns before every run; plain
|
|
24
|
+
`http://` to a non-loopback host is refused outright. Before generating, it also
|
|
25
|
+
screens the staged change for credentials — `.env` files, private keys,
|
|
26
|
+
AWS/GitHub/Slack-shaped tokens — and refuses to send them without `--force`.
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
gem install ai_git
|
|
32
|
+
|
|
33
|
+
./llama-server --port 8080 # any OpenAI-compatible local server
|
|
34
|
+
git add <files>
|
|
35
|
+
ai_git commit
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
Commit this message? [A]ccept / [e]dit / [r]egenerate / [q]uit:
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Accept it, open it in `$EDITOR`, ask for another one, or quit. The prompt only
|
|
43
|
+
appears on a terminal, so piped and scripted runs stay unattended. Use
|
|
44
|
+
`--dry-run` to see the message without committing, or `--no-push` to commit
|
|
45
|
+
locally.
|
|
46
|
+
|
|
47
|
+
Run `ai_git help` for every subcommand, or `ai_git help commit` for what one
|
|
48
|
+
of them does and the flags it takes.
|
|
49
|
+
|
|
5
50
|
## Documentation
|
|
6
51
|
|
|
7
52
|
- [Usage](doc/USAGE.md) — requirements, install, configuration, flags, and privacy
|
data/bin/ai_git
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
#
|
|
5
5
|
# @purpose Executable entry point for the ai_git CLI: load the library and
|
|
6
6
|
# hand ARGV to AIGit.start.
|
|
7
|
-
# @exports ai_git: the `config`
|
|
8
|
-
# -
|
|
7
|
+
# @exports ai_git: the `commit`, `config` and `help` subcommands and the
|
|
8
|
+
# -h/--help and -v/--version flags.
|
|
9
9
|
# @dependencies lib/ai_git: supplies AIGit.start and AIGit::UI.error.
|
|
10
10
|
# @sideEffects Runs the whole CLI; exits 130 on interrupt and 1 on any other
|
|
11
11
|
# error, after printing the message to stderr.
|
data/doc/USAGE.md
CHANGED
|
@@ -43,7 +43,7 @@ ai_git talks to a local [llama.cpp](https://github.com/ggml-org/llama.cpp) serve
|
|
|
43
43
|
```bash
|
|
44
44
|
# Start llama.cpp's server, then run ai_git with defaults
|
|
45
45
|
./llama-server --port 8080
|
|
46
|
-
ai_git
|
|
46
|
+
ai_git commit
|
|
47
47
|
|
|
48
48
|
# Or point at a custom model/port
|
|
49
49
|
mkdir -p ~/.ai_git
|
|
@@ -55,13 +55,16 @@ YAML
|
|
|
55
55
|
|
|
56
56
|
## Run
|
|
57
57
|
|
|
58
|
+
Every action is named by a subcommand. A bare `ai_git` does nothing and changes
|
|
59
|
+
nothing; it only points you at `ai_git help`.
|
|
60
|
+
|
|
58
61
|
```bash
|
|
59
62
|
git add <files>
|
|
60
|
-
ai_git
|
|
63
|
+
ai_git commit
|
|
61
64
|
```
|
|
62
65
|
|
|
63
|
-
`ai_git` generates a commit message from your staged changes, then asks
|
|
64
|
-
do with it:
|
|
66
|
+
`ai_git commit` generates a commit message from your staged changes, then asks
|
|
67
|
+
what to do with it:
|
|
65
68
|
|
|
66
69
|
```
|
|
67
70
|
Commit this message? [A]ccept / [e]dit / [r]egenerate / [q]uit:
|
|
@@ -72,16 +75,19 @@ terminal — piped or scripted runs stay unattended, as does `--yes`.
|
|
|
72
75
|
|
|
73
76
|
## Flags
|
|
74
77
|
|
|
78
|
+
These belong to `ai_git commit`:
|
|
79
|
+
|
|
75
80
|
| Flag | Description |
|
|
76
81
|
|------|-------------|
|
|
77
82
|
| `-n`, `--dry-run` | Print the generated message and change nothing |
|
|
78
83
|
| `--no-push` | Commit locally without pushing |
|
|
79
84
|
| `-y`, `--yes` | Skip the confirmation prompt (unattended) |
|
|
80
85
|
| `-f`, `--force` | Proceed despite secret or remote-server warnings |
|
|
86
|
+
| `-h`, `--help` | Describe the subcommand and its flags |
|
|
81
87
|
|
|
82
88
|
```bash
|
|
83
|
-
ai_git --dry-run # see what it would write, commit nothing
|
|
84
|
-
ai_git --no-push # commit locally, publish later yourself
|
|
89
|
+
ai_git commit --dry-run # see what it would write, commit nothing
|
|
90
|
+
ai_git commit --no-push # commit locally, publish later yourself
|
|
85
91
|
```
|
|
86
92
|
|
|
87
93
|
## Privacy
|
|
@@ -101,7 +107,20 @@ stage.
|
|
|
101
107
|
|
|
102
108
|
| Subcommand | Description |
|
|
103
109
|
|------------|-------------|
|
|
104
|
-
| `ai_git` | Generate a commit message, commit, and push staged files |
|
|
110
|
+
| `ai_git commit` | Generate a commit message, commit, and push staged files |
|
|
105
111
|
| `ai_git config` | Show the resolved provider configuration |
|
|
106
|
-
| `ai_git
|
|
112
|
+
| `ai_git help` | List every subcommand and the top-level options |
|
|
113
|
+
| `ai_git help <subcommand>` | Describe one subcommand and the flags it takes |
|
|
114
|
+
| `ai_git --help` | Same overview as `ai_git help` |
|
|
107
115
|
| `ai_git --version` | Print version |
|
|
116
|
+
|
|
117
|
+
`ai_git` on its own is not one of them: with no subcommand it prints where to
|
|
118
|
+
look and exits without touching the repository. Naming an unknown subcommand,
|
|
119
|
+
or a flag where a subcommand belongs, prints the usage and exits `1`.
|
|
120
|
+
|
|
121
|
+
Each subcommand also answers `--help` for itself:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
ai_git help commit # or: ai_git commit --help
|
|
125
|
+
ai_git help config # or: ai_git config --help
|
|
126
|
+
```
|
data/lib/ai_git/ai_client.rb
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
# the reply.
|
|
7
7
|
# @exports AIGit::AIClient: READ_TIMEOUT_SECONDS, OPEN_TIMEOUT_SECONDS,
|
|
8
8
|
# MAX_ATTEMPTS, RETRY_BASE_DELAY, TRANSIENT_STATUSES,
|
|
9
|
-
# RETRYABLE_ERRORS,
|
|
9
|
+
# RETRYABLE_ERRORS, LABELLED_PREAMBLE, BARE_LANGUAGE_TAG,
|
|
10
|
+
# ANNOUNCING_PREAMBLE, .complete, .sanitize.
|
|
10
11
|
# @dependencies ai_git/config: supplies the base URL, endpoint, and provider
|
|
11
12
|
# name used in requests and error messages;
|
|
12
13
|
# json: encodes the request body and parses the response;
|
|
@@ -16,7 +17,9 @@
|
|
|
16
17
|
# @notes Retries with exponential backoff on the listed connection
|
|
17
18
|
# errors and status codes only; any other status raises at once.
|
|
18
19
|
# Sanitizing also unescapes a reply whose only newlines are
|
|
19
|
-
# literal backslash-n, which some models emit
|
|
20
|
+
# literal backslash-n, which some models emit, and only drops
|
|
21
|
+
# a leading line that is a whole preamble sentence, so a title
|
|
22
|
+
# opening with "Output" or "The changes" is left alone.
|
|
20
23
|
|
|
21
24
|
require "json"
|
|
22
25
|
require "net/http"
|
|
@@ -39,10 +42,20 @@ module AIGit
|
|
|
39
42
|
Net::OpenTimeout, Net::ReadTimeout, SocketError, EOFError
|
|
40
43
|
].freeze
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
# A preamble is a whole announcing sentence, never a bare leading word: an
|
|
46
|
+
# ordinary title such as "Output the resolved settings" must survive.
|
|
47
|
+
LABELLED_PREAMBLE = /\A(here|output|generated|result|response|note)\s*:/i
|
|
48
|
+
BARE_LANGUAGE_TAG = /\A(json|markdown|text|plaintext)\z/i
|
|
49
|
+
ANNOUNCING_PREAMBLE = /
|
|
50
|
+
\A(
|
|
51
|
+
(here(\s+is|'s|\s+are)?|below\s+is)\b.*\b(commit\s+message|message)\b
|
|
52
|
+
| based\s+on\s+the\s+(changes|diff|staged\s+\w+)\b.*:\s*\z
|
|
53
|
+
| the\s+(commit\s+message|review)\s+is\b
|
|
54
|
+
| (generated|suggested|proposed)\s+commit\s+message\b
|
|
55
|
+
)
|
|
56
|
+
/ix
|
|
57
|
+
CODE_FENCE = /\A`{3,}/
|
|
58
|
+
ESCAPED_MESSAGE = /\A[^\n]*\\n\\n[^\n]*\z/
|
|
46
59
|
|
|
47
60
|
def complete(prompt:, model_name:, temperature:)
|
|
48
61
|
sanitize(openai_complete(prompt, model_name, temperature))
|
|
@@ -117,7 +130,7 @@ module AIGit
|
|
|
117
130
|
hint = response.code.to_i == 404 ? " Check the model name and base URL (see `ai_git config`)." : ""
|
|
118
131
|
|
|
119
132
|
"#{provider} returned HTTP #{response.code} at #{uri}.#{hint}" \
|
|
120
|
-
"#{
|
|
133
|
+
"#{"\n#{body}" unless body.empty?}"
|
|
121
134
|
end
|
|
122
135
|
|
|
123
136
|
def connection_error_message(error)
|
|
@@ -139,7 +152,7 @@ module AIGit
|
|
|
139
152
|
def unescape_newlines(text)
|
|
140
153
|
return text unless text.match?(ESCAPED_MESSAGE)
|
|
141
154
|
|
|
142
|
-
text.gsub(
|
|
155
|
+
text.gsub("\\n", "\n")
|
|
143
156
|
end
|
|
144
157
|
|
|
145
158
|
def strip_code_fences(lines)
|
|
@@ -155,11 +168,18 @@ module AIGit
|
|
|
155
168
|
end
|
|
156
169
|
|
|
157
170
|
def strip_preamble(lines)
|
|
158
|
-
lines.drop_while { |line| line.strip.empty? || preamble?(line) }
|
|
171
|
+
stripped = lines.drop_while { |line| line.strip.empty? || preamble?(line) }
|
|
172
|
+
|
|
173
|
+
# Never let the preamble rule eat the entire reply: a message that looks
|
|
174
|
+
# like nothing but preamble is more likely a title we misread.
|
|
175
|
+
stripped.any? { |line| !line.strip.empty? } ? stripped : lines
|
|
159
176
|
end
|
|
160
177
|
|
|
161
178
|
def preamble?(line)
|
|
162
|
-
line.strip
|
|
179
|
+
text = line.strip
|
|
180
|
+
text.match?(LABELLED_PREAMBLE) ||
|
|
181
|
+
text.match?(BARE_LANGUAGE_TAG) ||
|
|
182
|
+
text.match?(ANNOUNCING_PREAMBLE)
|
|
163
183
|
end
|
|
164
184
|
end
|
|
165
185
|
end
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
-
# lib/ai_git/commands/
|
|
2
|
+
# lib/ai_git/commands/commit.rb
|
|
3
3
|
#
|
|
4
|
-
# @purpose Implement the
|
|
4
|
+
# @purpose Implement the `commit` subcommand end to end: guard the staged
|
|
5
5
|
# diff, generate a commit message, confirm it, then commit and
|
|
6
6
|
# push.
|
|
7
|
-
# @exports AIGit::Commands::
|
|
7
|
+
# @exports AIGit::Commands::Commit: .call, .run, .generate_commit_message,
|
|
8
8
|
# .normalize_message, .build_prompt.
|
|
9
9
|
# @dependencies ai_git/options: parses the flags .call receives;
|
|
10
10
|
# ai_git/git: reads the staged tree, commits, and pushes;
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
# ai_git/config: supplies the model name and base-URL checks;
|
|
13
13
|
# ai_git/ai_client: generates the message;
|
|
14
14
|
# ai_git/prompt: runs the interactive confirmation and editor;
|
|
15
|
+
# ai_git/commands/help: supplies the text --help prints;
|
|
15
16
|
# ai_git/ui: prints the header, the message, and the outcome.
|
|
16
17
|
# @sideEffects Reads the repository, commits, pushes to origin, makes network
|
|
17
18
|
# requests, spawns the editor, and writes to stdout and stderr;
|
|
@@ -27,10 +28,11 @@ require_relative "../options"
|
|
|
27
28
|
require_relative "../prompt"
|
|
28
29
|
require_relative "../secrets"
|
|
29
30
|
require_relative "../ui"
|
|
31
|
+
require_relative "help"
|
|
30
32
|
|
|
31
33
|
module AIGit
|
|
32
34
|
module Commands
|
|
33
|
-
module
|
|
35
|
+
module Commit
|
|
34
36
|
module_function
|
|
35
37
|
|
|
36
38
|
def generate_commit_message(diff, model_name, temperature: 0.3)
|
|
@@ -64,7 +66,7 @@ module AIGit
|
|
|
64
66
|
|
|
65
67
|
def call(argv = [])
|
|
66
68
|
options = AIGit::Options.parse(argv)
|
|
67
|
-
return puts(AIGit::
|
|
69
|
+
return puts(AIGit::Commands::Help.topic("commit")) if options.help?
|
|
68
70
|
|
|
69
71
|
staged = AIGit::Git.staged_files
|
|
70
72
|
raise "No staged files. Use `git add` first." if staged.strip.empty?
|
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
# @dependencies ai_git/config: supplies every value printed and the path of
|
|
9
9
|
# the config file;
|
|
10
10
|
# ai_git/ai_client: supplies the read timeout shown;
|
|
11
|
+
# ai_git/commands/help: supplies the text --help prints;
|
|
11
12
|
# ai_git/ui: formats the heading and the key/value lines.
|
|
12
|
-
# @sideEffects Writes the resolved configuration to stdout;
|
|
13
|
-
# when the config file
|
|
13
|
+
# @sideEffects Writes the resolved configuration or the help topic to stdout;
|
|
14
|
+
# raises a string on an unknown argument or when the config file
|
|
15
|
+
# cannot be resolved.
|
|
14
16
|
# @notes Every value is resolved before the first line is printed, so a
|
|
15
17
|
# broken config file reports its error instead of a half-printed
|
|
16
18
|
# listing.
|
|
@@ -18,13 +20,18 @@
|
|
|
18
20
|
require_relative "../ai_client"
|
|
19
21
|
require_relative "../config"
|
|
20
22
|
require_relative "../ui"
|
|
23
|
+
require_relative "help"
|
|
21
24
|
|
|
22
25
|
module AIGit
|
|
23
26
|
module Commands
|
|
24
27
|
module Config
|
|
25
28
|
module_function
|
|
26
29
|
|
|
27
|
-
def call(
|
|
30
|
+
def call(argv = [])
|
|
31
|
+
argument = argv.to_a.first
|
|
32
|
+
return puts(AIGit::Commands::Help.topic("config")) if AIGit::HELP_FLAGS.include?(argument)
|
|
33
|
+
raise "Unknown option: #{argument}. Run `ai_git help config` for usage." unless argument.nil?
|
|
34
|
+
|
|
28
35
|
rows = resolved_rows(AIGit::Config)
|
|
29
36
|
|
|
30
37
|
AIGit::UI.heading("ai_git configuration")
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# lib/ai_git/commands/help.rb
|
|
3
|
+
#
|
|
4
|
+
# @purpose Implement the `help` subcommand and own every line of help
|
|
5
|
+
# text: list what ai_git can do, or describe one subcommand.
|
|
6
|
+
# @exports AIGit::Commands::Help: USAGE, NO_SUBCOMMAND, COMMIT_TOPIC,
|
|
7
|
+
# CONFIG_TOPIC, HELP_TOPIC, TOPICS, ALIASES, .call, .topic.
|
|
8
|
+
# @sideEffects Writes the usage or one topic to stdout; raises a string when
|
|
9
|
+
# the named topic is unknown.
|
|
10
|
+
# @notes The router, the bare invocation and a subcommand's own --help
|
|
11
|
+
# all read from here, so the help a user sees has one source.
|
|
12
|
+
|
|
13
|
+
module AIGit
|
|
14
|
+
module Commands
|
|
15
|
+
module Help
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
USAGE = <<~USAGE
|
|
19
|
+
Usage: ai_git <subcommand> [options]
|
|
20
|
+
|
|
21
|
+
Subcommands:
|
|
22
|
+
commit Generate a commit message, commit, and push staged files
|
|
23
|
+
config Show the resolved provider configuration
|
|
24
|
+
help Describe a subcommand, or list them all
|
|
25
|
+
|
|
26
|
+
Options:
|
|
27
|
+
-h, --help Show this message
|
|
28
|
+
-v, --version Print version
|
|
29
|
+
|
|
30
|
+
Run `ai_git help <subcommand>` for what it does and which flags it
|
|
31
|
+
takes, for example `ai_git help commit`.
|
|
32
|
+
|
|
33
|
+
Configuration (~/.ai_git/config.yml, or config.yaml):
|
|
34
|
+
model_name: ggml-org/gemma-4-E4B-it-GGUF:Q8_0 Model to prompt
|
|
35
|
+
base_url: http://127.0.0.1:8080 llama.cpp server
|
|
36
|
+
no_color: true Disable colored output
|
|
37
|
+
|
|
38
|
+
Run `ai_git config` to see the resolved settings and the file they
|
|
39
|
+
come from.
|
|
40
|
+
USAGE
|
|
41
|
+
|
|
42
|
+
NO_SUBCOMMAND = "Nothing to do: ai_git takes a subcommand. " \
|
|
43
|
+
"Run `ai_git help` to see what it can do."
|
|
44
|
+
|
|
45
|
+
COMMIT_TOPIC = <<~TOPIC
|
|
46
|
+
Usage: ai_git commit [options]
|
|
47
|
+
|
|
48
|
+
Generate a commit message from the staged diff, then commit and push.
|
|
49
|
+
|
|
50
|
+
ai_git reads the staged files and their diff, asks the configured
|
|
51
|
+
llama.cpp server for a message, and prints what comes back: a short
|
|
52
|
+
imperative title, a blank line, then prose explaining why the change
|
|
53
|
+
was necessary rather than restating the diff.
|
|
54
|
+
|
|
55
|
+
On a terminal it then asks what to do with that message:
|
|
56
|
+
|
|
57
|
+
Commit this message? [A]ccept / [e]dit / [r]egenerate / [q]uit:
|
|
58
|
+
|
|
59
|
+
Accepting commits and pushes to origin. Piped or scripted runs skip
|
|
60
|
+
the question and commit unattended, as does --yes.
|
|
61
|
+
|
|
62
|
+
Options:
|
|
63
|
+
-n, --dry-run Print the generated message and change nothing
|
|
64
|
+
--no-push Commit locally without pushing
|
|
65
|
+
-y, --yes Skip the confirmation prompt (unattended)
|
|
66
|
+
-f, --force Proceed despite secret or remote-server warnings
|
|
67
|
+
-h, --help Show this message
|
|
68
|
+
|
|
69
|
+
Before the diff leaves the machine, ai_git refuses to send it
|
|
70
|
+
unencrypted to a non-loopback host, and refuses to send likely
|
|
71
|
+
secrets — .env files, private keys, AWS/GitHub/Slack-shaped tokens.
|
|
72
|
+
Pass --force to override either refusal.
|
|
73
|
+
|
|
74
|
+
Requires staged changes: run `git add` first.
|
|
75
|
+
TOPIC
|
|
76
|
+
|
|
77
|
+
CONFIG_TOPIC = <<~TOPIC
|
|
78
|
+
Usage: ai_git config
|
|
79
|
+
|
|
80
|
+
Show the resolved provider configuration: the provider, model name,
|
|
81
|
+
base URL, endpoint and read timeout ai_git will use, and the path of
|
|
82
|
+
the file those settings came from.
|
|
83
|
+
|
|
84
|
+
Settings live in ~/.ai_git/config.yml (config.yaml is read too). The
|
|
85
|
+
file is optional — without it every setting falls back to its
|
|
86
|
+
default. An unknown key or a malformed value fails the run with an
|
|
87
|
+
error naming the file.
|
|
88
|
+
|
|
89
|
+
This subcommand takes no options and only reads: nothing is
|
|
90
|
+
committed, pushed, or sent to the model server.
|
|
91
|
+
TOPIC
|
|
92
|
+
|
|
93
|
+
HELP_TOPIC = <<~TOPIC
|
|
94
|
+
Usage: ai_git help [subcommand]
|
|
95
|
+
|
|
96
|
+
With no argument, list every subcommand, the top-level options and
|
|
97
|
+
the configuration file ai_git reads.
|
|
98
|
+
|
|
99
|
+
Name a subcommand to see what it does and which flags it takes:
|
|
100
|
+
|
|
101
|
+
ai_git help commit
|
|
102
|
+
ai_git help config
|
|
103
|
+
|
|
104
|
+
`ai_git -h` and `ai_git --help` print the same overview as a bare
|
|
105
|
+
`ai_git help`, and every subcommand accepts --help for its own.
|
|
106
|
+
TOPIC
|
|
107
|
+
|
|
108
|
+
TOPICS = {
|
|
109
|
+
"commit" => COMMIT_TOPIC,
|
|
110
|
+
"config" => CONFIG_TOPIC,
|
|
111
|
+
"help" => HELP_TOPIC
|
|
112
|
+
}.freeze
|
|
113
|
+
|
|
114
|
+
ALIASES = { "-h" => "help", "--help" => "help" }.freeze
|
|
115
|
+
|
|
116
|
+
def call(argv = [])
|
|
117
|
+
name = argv.to_a.first
|
|
118
|
+
return puts(USAGE) if name.nil?
|
|
119
|
+
|
|
120
|
+
puts(topic(name))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def topic(name)
|
|
124
|
+
key = name.to_s
|
|
125
|
+
TOPICS.fetch(ALIASES.fetch(key, key)) { raise unknown_topic_error(key) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def unknown_topic_error(name)
|
|
129
|
+
"Unknown help topic: #{name}. Known topics: #{TOPICS.keys.join(", ")}."
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
data/lib/ai_git/config.rb
CHANGED
|
@@ -30,7 +30,7 @@ module AIGit
|
|
|
30
30
|
DEFAULT_MODEL = "ggml-org/gemma-4-E4B-it-GGUF:Q8_0"
|
|
31
31
|
DEFAULT_BASE_URL = "http://127.0.0.1:8080"
|
|
32
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
|
|
33
|
+
LOOPBACK_HOST = /\A(localhost|127(\.\d{1,3}){3}|::1|0:0:0:0:0:0:0:1)\z/i
|
|
34
34
|
CONFIG_DIR_NAME = ".ai_git"
|
|
35
35
|
CONFIG_FILENAMES = %w[config.yml config.yaml].freeze
|
|
36
36
|
SETTING_KEYS = %w[model_name base_url no_color].freeze
|
|
@@ -85,8 +85,8 @@ module AIGit
|
|
|
85
85
|
unknown = data.keys - SETTING_KEYS
|
|
86
86
|
return data if unknown.empty?
|
|
87
87
|
|
|
88
|
-
raise "Unknown setting#{
|
|
89
|
-
"Known settings: #{SETTING_KEYS.join(
|
|
88
|
+
raise "Unknown setting#{"s" if unknown.length > 1} in #{path}: #{unknown.join(", ")}. " \
|
|
89
|
+
"Known settings: #{SETTING_KEYS.join(", ")}."
|
|
90
90
|
end
|
|
91
91
|
|
|
92
92
|
def string_setting(key, default)
|
data/lib/ai_git/git.rb
CHANGED
|
@@ -3,15 +3,18 @@
|
|
|
3
3
|
#
|
|
4
4
|
# @purpose Wrap the git porcelain this tool drives: inspect the staged
|
|
5
5
|
# tree, then commit and push on the user's behalf.
|
|
6
|
-
# @exports AIGit::Git: NOT_A_REPOSITORY, MAX_ERROR_DETAIL,
|
|
7
|
-
# .ensure_repository!, .staged_files, .diff,
|
|
8
|
-
# .detached_head?, .
|
|
6
|
+
# @exports AIGit::Git: NOT_A_REPOSITORY, MAX_ERROR_DETAIL, REPLACEMENT,
|
|
7
|
+
# .repository?, .ensure_repository!, .staged_files, .diff,
|
|
8
|
+
# .current_branch, .detached_head?, .scrub,
|
|
9
|
+
# .commit_with_message, .push_current_branch.
|
|
9
10
|
# @dependencies git: every operation shells out to the binary;
|
|
10
11
|
# open3: captures stdout, stderr, and exit status together;
|
|
11
12
|
# tempfile: holds the commit message passed to `git commit -F`.
|
|
12
13
|
# @sideEffects Spawns git subprocesses; writes a tempfile; mutates the
|
|
13
14
|
# repository and the remote on commit and push.
|
|
14
15
|
# @notes Raises a bare string message; bin/ai_git renders it and exits 1.
|
|
16
|
+
# Captured output is scrubbed to valid UTF-8 so a diff of a
|
|
17
|
+
# non-UTF-8 file cannot break JSON encoding downstream.
|
|
15
18
|
|
|
16
19
|
require "open3"
|
|
17
20
|
require "tempfile"
|
|
@@ -22,6 +25,7 @@ module AIGit
|
|
|
22
25
|
|
|
23
26
|
NOT_A_REPOSITORY = "Not a git repository (or any of the parent directories)."
|
|
24
27
|
MAX_ERROR_DETAIL = 500
|
|
28
|
+
REPLACEMENT = "\uFFFD"
|
|
25
29
|
|
|
26
30
|
def repository?
|
|
27
31
|
_stdout, _stderr, status = Open3.capture3("git", "rev-parse", "--git-dir")
|
|
@@ -57,11 +61,20 @@ module AIGit
|
|
|
57
61
|
|
|
58
62
|
def capture(*argv)
|
|
59
63
|
stdout, stderr, status = Open3.capture3(*argv)
|
|
60
|
-
return stdout if status.success?
|
|
64
|
+
return scrub(stdout) if status.success?
|
|
61
65
|
|
|
62
66
|
raise command_error(argv, stderr, status)
|
|
63
67
|
end
|
|
64
68
|
|
|
69
|
+
# git hands back the bytes it stored, whatever they are. A Latin-1 file
|
|
70
|
+
# makes the diff invalid UTF-8, which the JSON encoder in the HTTP client
|
|
71
|
+
# refuses; replace the offending bytes here, at the source.
|
|
72
|
+
def scrub(text)
|
|
73
|
+
string = text.to_s
|
|
74
|
+
string = string.dup.force_encoding(Encoding::UTF_8) unless string.encoding == Encoding::UTF_8
|
|
75
|
+
string.valid_encoding? ? string : string.scrub(REPLACEMENT)
|
|
76
|
+
end
|
|
77
|
+
|
|
65
78
|
def run_command(cmd, *args)
|
|
66
79
|
argv = [cmd, *args.map(&:to_s)]
|
|
67
80
|
_stdout, stderr, status = Open3.capture3(*argv)
|
|
@@ -74,7 +87,7 @@ module AIGit
|
|
|
74
87
|
def command_error(argv, stderr, status)
|
|
75
88
|
detail = stderr.to_s.strip
|
|
76
89
|
detail = "#{detail[0, MAX_ERROR_DETAIL]}…" if detail.length > MAX_ERROR_DETAIL
|
|
77
|
-
header = "Command failed: #{argv.join(
|
|
90
|
+
header = "Command failed: #{argv.join(" ")} (exit #{status.exitstatus})"
|
|
78
91
|
|
|
79
92
|
detail.empty? ? header : "#{header}\n#{detail}"
|
|
80
93
|
end
|
data/lib/ai_git/prompt.rb
CHANGED
|
@@ -51,7 +51,7 @@ module AIGit
|
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
def edit(message)
|
|
54
|
-
editor = ENV["VISUAL"] || ENV
|
|
54
|
+
editor = ENV["VISUAL"] || ENV.fetch("EDITOR", nil)
|
|
55
55
|
raise "Cannot edit: set $EDITOR or $VISUAL first." if editor.to_s.strip.empty?
|
|
56
56
|
|
|
57
57
|
Tempfile.create(["ai_git_commit_msg", ".txt"]) do |file|
|
data/lib/ai_git/secrets.rb
CHANGED
|
@@ -34,7 +34,7 @@ module AIGit
|
|
|
34
34
|
SUSPICIOUS_ASSIGNMENT = /
|
|
35
35
|
\b(api[_-]?key|secret|password|passwd|token|access[_-]?key)\b
|
|
36
36
|
\s*[:=]\s*["'][^"']{8,}["']
|
|
37
|
-
/ix
|
|
37
|
+
/ix
|
|
38
38
|
|
|
39
39
|
def scan(staged_files, diff)
|
|
40
40
|
{ blocking: blocking_findings(staged_files, diff), warnings: warning_findings(diff) }
|
data/lib/ai_git/ui.rb
CHANGED
data/lib/ai_git/version.rb
CHANGED
data/lib/ai_git.rb
CHANGED
|
@@ -2,80 +2,57 @@
|
|
|
2
2
|
# lib/ai_git.rb
|
|
3
3
|
#
|
|
4
4
|
# @purpose Library entry point and CLI router: load every component, then
|
|
5
|
-
# dispatch the argument vector to the
|
|
6
|
-
# @exports AIGit: SUBCOMMANDS, HELP_FLAGS, VERSION_FLAGS,
|
|
5
|
+
# dispatch the argument vector to the named subcommand.
|
|
6
|
+
# @exports AIGit: SUBCOMMANDS, HELP_FLAGS, VERSION_FLAGS, .start.
|
|
7
7
|
# @dependencies ai_git/version, ai_git/config, ai_git/ui, ai_git/ai_client,
|
|
8
8
|
# ai_git/git: the components the subcommands build on;
|
|
9
|
-
# ai_git/commands/
|
|
10
|
-
# subcommands .start dispatches to.
|
|
11
|
-
# @sideEffects Prints usage
|
|
12
|
-
# unknown subcommand; .start runs the
|
|
13
|
-
#
|
|
14
|
-
#
|
|
9
|
+
# ai_git/commands/commit, ai_git/commands/config,
|
|
10
|
+
# ai_git/commands/help: the subcommands .start dispatches to.
|
|
11
|
+
# @sideEffects Prints the usage, the version or a hint to stdout; warns and
|
|
12
|
+
# exits 1 on an unknown subcommand or option; .start runs the
|
|
13
|
+
# selected subcommand.
|
|
14
|
+
# @notes Every action needs its subcommand named: an empty vector
|
|
15
|
+
# prints where to look and changes nothing, so a bare `ai_git`
|
|
16
|
+
# can no longer commit by accident.
|
|
15
17
|
|
|
16
18
|
require_relative "ai_git/version"
|
|
17
19
|
require_relative "ai_git/config"
|
|
18
20
|
require_relative "ai_git/ui"
|
|
19
21
|
require_relative "ai_git/ai_client"
|
|
20
22
|
require_relative "ai_git/git"
|
|
21
|
-
require_relative "ai_git/commands/
|
|
23
|
+
require_relative "ai_git/commands/commit"
|
|
22
24
|
require_relative "ai_git/commands/config"
|
|
25
|
+
require_relative "ai_git/commands/help"
|
|
23
26
|
|
|
24
27
|
module AIGit
|
|
25
28
|
module_function
|
|
26
29
|
|
|
27
30
|
SUBCOMMANDS = {
|
|
31
|
+
"commit" => AIGit::Commands::Commit,
|
|
28
32
|
"config" => AIGit::Commands::Config,
|
|
29
|
-
"
|
|
33
|
+
"help" => AIGit::Commands::Help
|
|
30
34
|
}.freeze
|
|
31
35
|
|
|
32
|
-
HELP_FLAGS = %w[-h --help
|
|
36
|
+
HELP_FLAGS = %w[-h --help].freeze
|
|
33
37
|
VERSION_FLAGS = %w[-v --version].freeze
|
|
34
38
|
|
|
35
|
-
USAGE = <<~USAGE
|
|
36
|
-
Usage: ai_git [subcommand] [options]
|
|
37
|
-
|
|
38
|
-
Subcommands:
|
|
39
|
-
(none) Generate a commit message, commit, and push staged files
|
|
40
|
-
config Show the resolved provider configuration
|
|
41
|
-
|
|
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
|
|
47
|
-
-h, --help Show this message
|
|
48
|
-
-v, --version Print version
|
|
49
|
-
|
|
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.
|
|
60
|
-
USAGE
|
|
61
|
-
|
|
62
39
|
def start(args)
|
|
63
40
|
args = args.dup
|
|
64
41
|
first = args.first
|
|
65
42
|
|
|
66
|
-
return puts(
|
|
67
|
-
return
|
|
43
|
+
return puts(AIGit::Commands::Help::NO_SUBCOMMAND) if first.nil?
|
|
44
|
+
return AIGit::Commands::Help.call([]) if HELP_FLAGS.include?(first)
|
|
45
|
+
return puts(VERSION) if VERSION_FLAGS.include?(first)
|
|
68
46
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
warn USAGE
|
|
74
|
-
exit 1
|
|
75
|
-
end
|
|
76
|
-
command = args.shift
|
|
77
|
-
end
|
|
47
|
+
unknown_argument!(first) unless SUBCOMMANDS.key?(first)
|
|
48
|
+
|
|
49
|
+
SUBCOMMANDS[args.shift].call(args)
|
|
50
|
+
end
|
|
78
51
|
|
|
79
|
-
|
|
52
|
+
def unknown_argument!(argument)
|
|
53
|
+
label = argument.start_with?("-") ? "option" : "subcommand"
|
|
54
|
+
warn "Unknown #{label}: #{argument}"
|
|
55
|
+
warn AIGit::Commands::Help::USAGE
|
|
56
|
+
exit 1
|
|
80
57
|
end
|
|
81
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:
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaíque Kandy Koga
|
|
@@ -11,11 +11,13 @@ date: 1980-01-02 00:00:00.000000000 Z
|
|
|
11
11
|
dependencies: []
|
|
12
12
|
description: Generate Git commit messages from staged changes via a local llama.cpp
|
|
13
13
|
server, then commit and push.
|
|
14
|
+
email: kaiquekandykoga@gmail.com
|
|
14
15
|
executables:
|
|
15
16
|
- ai_git
|
|
16
17
|
extensions: []
|
|
17
18
|
extra_rdoc_files: []
|
|
18
19
|
files:
|
|
20
|
+
- CHANGELOG.md
|
|
19
21
|
- LICENSE
|
|
20
22
|
- README.md
|
|
21
23
|
- bin/ai_git
|
|
@@ -23,8 +25,9 @@ files:
|
|
|
23
25
|
- doc/USAGE.md
|
|
24
26
|
- lib/ai_git.rb
|
|
25
27
|
- lib/ai_git/ai_client.rb
|
|
28
|
+
- lib/ai_git/commands/commit.rb
|
|
26
29
|
- lib/ai_git/commands/config.rb
|
|
27
|
-
- lib/ai_git/commands/
|
|
30
|
+
- lib/ai_git/commands/help.rb
|
|
28
31
|
- lib/ai_git/config.rb
|
|
29
32
|
- lib/ai_git/git.rb
|
|
30
33
|
- lib/ai_git/options.rb
|
|
@@ -36,7 +39,10 @@ homepage: https://github.com/kaiquekandykoga/ai_git
|
|
|
36
39
|
licenses:
|
|
37
40
|
- BSD-3-Clause
|
|
38
41
|
metadata:
|
|
39
|
-
|
|
42
|
+
homepage_uri: https://github.com/kaiquekandykoga/ai_git
|
|
43
|
+
source_code_uri: https://github.com/kaiquekandykoga/ai_git/tree/master
|
|
44
|
+
documentation_uri: https://github.com/kaiquekandykoga/ai_git/blob/master/doc/USAGE.md
|
|
45
|
+
changelog_uri: https://github.com/kaiquekandykoga/ai_git/blob/master/CHANGELOG.md
|
|
40
46
|
bug_tracker_uri: https://github.com/kaiquekandykoga/ai_git/issues
|
|
41
47
|
rubygems_mfa_required: 'true'
|
|
42
48
|
rdoc_options: []
|
|
@@ -46,7 +52,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
46
52
|
requirements:
|
|
47
53
|
- - ">="
|
|
48
54
|
- !ruby/object:Gem::Version
|
|
49
|
-
version: '
|
|
55
|
+
version: '3.1'
|
|
50
56
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
51
57
|
requirements:
|
|
52
58
|
- - ">="
|