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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fb023f0a7a794ab912a9502ce5155883ecad2171832690124534e4ded1ce551
4
- data.tar.gz: bb16265d754dffabf9718b410d38d8f71e00cfc249e2a97ed41cc82cfefcca2c
3
+ metadata.gz: 31071defb6673e900e4ff0701b7299dec4d17a3eb0cdf6674c4c115a70bfa242
4
+ data.tar.gz: f0e0c388832e033b9b09fdc77bac62a14a8984491c3b8074eda84c221de1af7d
5
5
  SHA512:
6
- metadata.gz: beb8cf739a17dc85da3e07bcd75dca5108e30f1aaef7249ac663435322440b0b4ada1768607e97faf15aab35cc629a01b58bacaddd491a3e71171838258ce1a0
7
- data.tar.gz: 8a9e339b686103ecc350e3844f594f1b0bf0e7297e8305da1754399cfa71955c56eca197742781847213f71beb9b74e333276ca32c39b2edf81a1d5247d24155
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` subcommand and the -n/--dry-run, --no-push,
8
- # -y/--yes, -f/--force, -h/--help, -v/--version flags.
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 what to
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 --help` | Show usage |
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
+ ```
@@ -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, .complete, .sanitize.
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
- 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
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
- "#{body.empty? ? '' : "\n#{body}"}"
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(/\\n/, "\n")
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.match?(PREAMBLE_PREFIXES)
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/default.rb
2
+ # lib/ai_git/commands/commit.rb
3
3
  #
4
- # @purpose Implement the default subcommand end to end: guard the staged
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::Default: .call, .run, .generate_commit_message,
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 Default
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::USAGE) if options.help?
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; raises a string
13
- # when the config file cannot be resolved.
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(_argv = [])
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.freeze
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#{'s' if unknown.length > 1} in #{path}: #{unknown.join(', ')}. " \
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, .repository?,
7
- # .ensure_repository!, .staged_files, .diff, .current_branch,
8
- # .detached_head?, .commit_with_message, .push_current_branch.
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(' ')} (exit #{status.exitstatus})"
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["EDITOR"]
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|
@@ -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.freeze
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
@@ -41,7 +41,7 @@ module AIGit
41
41
  codes = styles.map { |style| CODES[style] }.compact
42
42
  return text.to_s if codes.empty?
43
43
 
44
- "\e[#{codes.join(';')}m#{text}\e[0m"
44
+ "\e[#{codes.join(";")}m#{text}\e[0m"
45
45
  end
46
46
 
47
47
  def bold(text)
@@ -7,5 +7,5 @@
7
7
  # @sideEffects None.
8
8
 
9
9
  module AIGit
10
- VERSION = "1.0.1"
10
+ VERSION = "2.0.0"
11
11
  end
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 matching subcommand.
6
- # @exports AIGit: SUBCOMMANDS, HELP_FLAGS, VERSION_FLAGS, USAGE, .start.
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/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.
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/default"
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
- "default" => AIGit::Commands::Default
33
+ "help" => AIGit::Commands::Help
30
34
  }.freeze
31
35
 
32
- HELP_FLAGS = %w[-h --help help].freeze
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(USAGE) if first && HELP_FLAGS.include?(first)
67
- return puts(VERSION) if first && VERSION_FLAGS.include?(first)
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
- command = "default"
70
- if first && !first.start_with?("-")
71
- unless SUBCOMMANDS.key?(first)
72
- warn "Unknown subcommand: #{first}"
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
- SUBCOMMANDS[command].call(args)
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: 1.0.1
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/default.rb
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
- source_code_uri: https://github.com/kaiquekandykoga/ai_git
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: '0'
55
+ version: '3.1'
50
56
  required_rubygems_version: !ruby/object:Gem::Requirement
51
57
  requirements:
52
58
  - - ">="