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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e698533dcbb5389f8ada1834ad78bbc65cbd6671e37874203de67c7376b11b51
4
- data.tar.gz: 858e8d1de39b0034506da5fbd7858029e6ca77e922c551ceee4da1c2b4feb732
3
+ metadata.gz: 2fb023f0a7a794ab912a9502ce5155883ecad2171832690124534e4ded1ce551
4
+ data.tar.gz: bb16265d754dffabf9718b410d38d8f71e00cfc249e2a97ed41cc82cfefcca2c
5
5
  SHA512:
6
- metadata.gz: 330b7566ca8ec91486faffd05ebefe651e543df74f39b796001d568793342cfa53689699baec35be9ec6a15b1f11505c60a06e17297e696593e752661465a6c2
7
- data.tar.gz: 357d164f5a63cfb02bd173902bee1c098a3b24590a2347f33a97b0bff259c786c87aee1fca5e79937ccf7e916df9214d21cd280884370c877e520dd8dc2aea5b
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
- ## Usage
5
+ ## Documentation
6
6
 
7
- #### Requirements
8
-
9
- - [llama.cpp](https://github.com/ggml-org/llama.cpp) running locally (e.g. `./llama-server --port 8080`)
10
-
11
- #### Install
12
-
13
- ```bash
14
- gem install ai_git
15
- ```
16
-
17
- #### Environment Variables
18
-
19
- | Variable | Description | Default |
20
- |----------|-------------|---------|
21
- | `AI_GIT_MODEL_NAME` | Model name | `ggml-org/gemma-4-E4B-it-GGUF:Q8_0` |
22
- | `AI_GIT_BASE_URL` | Base URL of the llama.cpp server | `http://127.0.0.1:8080` |
23
- | `NO_COLOR` | Disable colored terminal output when set | — |
24
-
25
- Run `ai_git config` to see exactly which model, URL and endpoint are resolved from your environment.
26
-
27
- ai_git talks to a local [llama.cpp](https://github.com/ggml-org/llama.cpp) server over its OpenAI-compatible
28
- `/v1/chat/completions` endpoint. No API key is needed.
29
-
30
- ##### Example
31
-
32
- ```bash
33
- # Start llama.cpp's server, then run ai_git with defaults
34
- ./llama-server --port 8080
35
- ai_git
36
-
37
- # Or point at a custom model/port
38
- export AI_GIT_MODEL_NAME=my-model
39
- export AI_GIT_BASE_URL=http://127.0.0.1:8081
40
- ```
41
-
42
- #### Run
43
-
44
- ```bash
45
- git add <files>
46
- ai_git
47
- ```
48
-
49
- `ai_git` generates a commit message from your staged changes, commits, and
50
- pushes to the current branch's upstream — no prompts, no flags.
51
-
52
- #### Subcommands
53
-
54
- | Subcommand | Description |
55
- |------------|-------------|
56
- | `ai_git` | Generate a commit message, commit, and push staged files |
57
- | `ai_git config` | Show the resolved provider configuration |
58
- | `ai_git --help` | Show usage |
59
- | `ai_git --version` | Print version |
60
-
61
- ## Development
62
-
63
- ```bash
64
- gem build ai_git.gemspec
65
- gem install ./ai_git-$(ruby -r./lib/ai_git/version -e 'print AIGit::VERSION').gem
66
- ```
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 |
@@ -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
- OUTPUT_NOISE_PREFIXES = /^(Here|Output|Generated|Based on|The changes)/i.freeze
26
- OUTPUT_NOISE_HEADERS = /^(Here is|The (commit message|review) is|```|json|markdown)/i.freeze
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
- cleaned = text.to_s
115
- .gsub(OUTPUT_NOISE_HEADERS, "")
116
- .gsub(/^>\s*/, "")
117
- .gsub(/\\n/, "\n")
118
- .strip
119
-
120
- cleaned.lines
121
- .map(&:strip)
122
- .reject { |line| line.match?(OUTPUT_NOISE_PREFIXES) }
123
- .join("\n")
124
- .strip
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
- cfg = AIGit::Config
28
+ rows = resolved_rows(AIGit::Config)
16
29
 
17
30
  AIGit::UI.heading("ai_git configuration")
18
- AIGit::UI.kv("Provider", cfg.provider)
19
- AIGit::UI.kv("Model", cfg.model_name)
20
- AIGit::UI.kv("Base URL", cfg.base_url)
21
- AIGit::UI.kv("Endpoint", cfg.endpoint)
22
- AIGit::UI.kv("Read timeout", "#{AIGit::AIClient::READ_TIMEOUT_SECONDS}s")
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? ? "chore: update code" : message
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(_argv = [])
39
- provider = AIGit::Config.provider
40
- model_name = AIGit::Config.model_name
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
- abort "Error: No staged files. Use `git add` first." if staged.to_s.strip.empty?
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
- print_header(provider, model_name, staged, branch)
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
- AIGit::UI.info(AIGit::UI.bold("Generating commit message…"))
53
- message = generate_commit_message(diff, model_name)
54
- print_message(message)
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
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
63
- AIGit::UI.kv("Done in", format("%.1fs", elapsed))
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
- ## Summary
100
- <2–4 bullet points covering the most important changes. Each bullet starts with a verb.>
101
-
102
- ## Why
103
- <1–3 sentences explaining the motivation or context behind the change. Omit if the reason is obvious.>
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
- - Summary bullets: describe WHAT changed, not HOW the code looks. Focus on behaviour and impact.
108
- - Why section: explain the problem being solved or the goal being achieved. Skip if it adds no value.
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
- ## Summary
117
- - Implement login endpoint with access and refresh token issuance
118
- - Add token refresh route with rotation and expiry validation
119
- - Protect private routes via middleware that verifies access tokens
120
- - Store refresh tokens using encrypted HTTP-only cookies
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
- ## Why
123
- Users were being logged out on every page reload. Refresh tokens allow
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
- ## Summary
131
- - Add nil guard in ReportGenerator#process before accessing preferences
132
- - Fall back to system defaults when preferences object is absent
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
- ## Why
135
- Reports were raising NoMethodError in production for users created
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
- ENV["AI_GIT_MODEL_NAME"] || DEFAULT_MODEL
106
+ string_setting("model_name", DEFAULT_MODEL)
18
107
  end
19
108
 
20
109
  def base_url
21
- ENV["AI_GIT_BASE_URL"] || DEFAULT_BASE_URL
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
- `git diff --cached --name-only`
36
+ ensure_repository!
37
+ capture("git", "diff", "--cached", "--name-only")
12
38
  end
13
39
 
14
40
  def diff
15
- `git diff --cached`
41
+ ensure_repository!
42
+ capture("git", "diff", "--cached")
16
43
  end
17
44
 
18
45
  def current_branch
19
- `git rev-parse --abbrev-ref HEAD`.chomp
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
- # Run a command with arguments as an array — no shell, so values are not
23
- # interpolated or word-split. Accepts either:
24
- # run_command("git", "status") (single string of args)
25
- # run_command("git", "commit", "-m", message) (variadic args, preferred)
26
- def run_command(cmd, *args)
27
- argv =
28
- if args.length == 1 && args.first.is_a?(String)
29
- args.first.split
30
- else
31
- args.map(&:to_s)
32
- end
54
+ def detached_head?
55
+ current_branch.nil?
56
+ end
33
57
 
34
- _stdout, stderr, status = Open3.capture3(cmd, *argv)
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 "Command failed: #{cmd} #{argv.join(' ')} (exit #{status.exitstatus})#{stderr.empty? ? '' : "\n#{stderr}"}"
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 ENV["NO_COLOR"] && !ENV["NO_COLOR"].empty?
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
@@ -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.3.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
- Flags:
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
- Environment variables:
34
- AI_GIT_MODEL_NAME Override the default model
35
- AI_GIT_BASE_URL Override the default base URL
36
- NO_COLOR Disable colored output
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.3.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.10
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: []