ai_git 0.2.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE +28 -0
- data/README.md +8 -0
- data/bin/ai_git +20 -1
- data/doc/RELEASE.md +77 -0
- data/doc/USAGE.md +107 -0
- data/lib/ai_git/ai_client.rb +165 -0
- data/lib/ai_git/commands/config.rb +55 -0
- data/lib/ai_git/commands/default.rb +256 -0
- data/lib/ai_git/config.rb +130 -26
- data/lib/ai_git/git.rb +77 -8
- data/lib/ai_git/options.rb +57 -0
- data/lib/ai_git/prompt.rb +67 -0
- data/lib/ai_git/secrets.rb +66 -0
- data/lib/ai_git/ui.rb +79 -0
- data/lib/ai_git/version.rb +7 -1
- data/lib/ai_git.rb +64 -7
- metadata +20 -7
- data/lib/ai_git/default.rb +0 -177
- data/lib/ai_git/review.rb +0 -173
|
@@ -0,0 +1,256 @@
|
|
|
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.
|
|
22
|
+
|
|
23
|
+
require_relative "../ai_client"
|
|
24
|
+
require_relative "../config"
|
|
25
|
+
require_relative "../git"
|
|
26
|
+
require_relative "../options"
|
|
27
|
+
require_relative "../prompt"
|
|
28
|
+
require_relative "../secrets"
|
|
29
|
+
require_relative "../ui"
|
|
30
|
+
|
|
31
|
+
module AIGit
|
|
32
|
+
module Commands
|
|
33
|
+
module Default
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
def generate_commit_message(diff, model_name, temperature: 0.3)
|
|
37
|
+
raise "No staged changes to generate commit message for" if diff.to_s.strip.empty?
|
|
38
|
+
|
|
39
|
+
message = AIClient.complete(
|
|
40
|
+
prompt: build_prompt(diff),
|
|
41
|
+
model_name: model_name,
|
|
42
|
+
temperature: temperature
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
message = normalize_message(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."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def normalize_message(message)
|
|
57
|
+
text = message.to_s.gsub(/\n{3,}/, "\n\n").strip
|
|
58
|
+
lines = text.lines.map(&:chomp)
|
|
59
|
+
return text if lines.length < 2
|
|
60
|
+
|
|
61
|
+
lines.insert(1, "") unless lines[1].empty?
|
|
62
|
+
lines.join("\n").gsub(/\n{3,}/, "\n\n").strip
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def call(argv = [])
|
|
66
|
+
options = AIGit::Options.parse(argv)
|
|
67
|
+
return puts(AIGit::USAGE) if options.help?
|
|
68
|
+
|
|
69
|
+
staged = AIGit::Git.staged_files
|
|
70
|
+
raise "No staged files. Use `git add` first." if staged.strip.empty?
|
|
71
|
+
|
|
72
|
+
run(staged, options)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def run(staged, options)
|
|
76
|
+
diff = AIGit::Git.diff
|
|
77
|
+
branch = AIGit::Git.current_branch
|
|
78
|
+
model_name = AIGit::Config.model_name
|
|
79
|
+
|
|
80
|
+
check_base_url!(options)
|
|
81
|
+
check_secrets!(staged, diff, options)
|
|
82
|
+
|
|
83
|
+
print_header(AIGit::Config.provider, model_name, staged, branch)
|
|
84
|
+
|
|
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?
|
|
90
|
+
|
|
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
|
|
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)
|
|
124
|
+
AIGit::Git.commit_with_message(message)
|
|
125
|
+
AIGit::UI.success("Committed.")
|
|
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
|
+
|
|
131
|
+
AIGit::Git.push_current_branch
|
|
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
|
|
157
|
+
|
|
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."
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def print_header(provider, model_name, staged, branch)
|
|
177
|
+
AIGit::UI.kv("AI Provider", provider)
|
|
178
|
+
AIGit::UI.kv("Model", model_name)
|
|
179
|
+
AIGit::UI.kv("Staged Files", staged.to_s.strip.gsub("\n", ", "))
|
|
180
|
+
AIGit::UI.kv("Branch", branch || "(detached HEAD)")
|
|
181
|
+
puts
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def print_message(message)
|
|
185
|
+
puts
|
|
186
|
+
puts AIGit::UI.bold("Commit message:")
|
|
187
|
+
puts
|
|
188
|
+
puts message
|
|
189
|
+
puts
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def build_prompt(diff)
|
|
193
|
+
standard_prompt(diff)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def standard_prompt(diff)
|
|
197
|
+
<<~PROMPT
|
|
198
|
+
You are an expert Git commit message writer. Output ONLY the commit message — no explanations, no markdown, no backticks, no preamble.
|
|
199
|
+
|
|
200
|
+
Here are the changes:
|
|
201
|
+
#{diff}
|
|
202
|
+
|
|
203
|
+
STRICT OUTPUT FORMAT (follow exactly):
|
|
204
|
+
|
|
205
|
+
<short imperative title, max 72 chars, summarizing the main change>
|
|
206
|
+
|
|
207
|
+
<blank line>
|
|
208
|
+
|
|
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.>
|
|
213
|
+
|
|
214
|
+
RULES:
|
|
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".
|
|
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.
|
|
219
|
+
- No filler phrases ("this commit", "this PR", "as per discussion").
|
|
220
|
+
- No line should exceed 72 characters.
|
|
221
|
+
|
|
222
|
+
EXAMPLES OF GOOD OUTPUT:
|
|
223
|
+
|
|
224
|
+
Add JWT-based login with refresh token support
|
|
225
|
+
|
|
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.
|
|
234
|
+
|
|
235
|
+
Adds a new middleware layer on private routes, so any route not
|
|
236
|
+
yet wired to it remains unauthenticated until migrated.
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
Prevent nil crash when user preferences are missing
|
|
241
|
+
|
|
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.
|
|
245
|
+
|
|
246
|
+
Falls back to system defaults whenever a user's preferences are
|
|
247
|
+
absent, so report generation no longer assumes the record exists.
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
Now generate the commit message:
|
|
252
|
+
PROMPT
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
data/lib/ai_git/config.rb
CHANGED
|
@@ -1,44 +1,148 @@
|
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
29
|
+
PROVIDER = "llama_cpp"
|
|
30
|
+
DEFAULT_MODEL = "ggml-org/gemma-4-E4B-it-GGUF:Q8_0"
|
|
31
|
+
DEFAULT_BASE_URL = "http://127.0.0.1:8080"
|
|
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
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
def provider
|
|
42
|
+
PROVIDER
|
|
43
|
+
end
|
|
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
|
|
19
57
|
|
|
20
|
-
def
|
|
21
|
-
|
|
58
|
+
def settings
|
|
59
|
+
@settings ||= load_settings
|
|
22
60
|
end
|
|
23
61
|
|
|
24
|
-
def
|
|
25
|
-
|
|
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
|
+
|
|
105
|
+
def model_name
|
|
106
|
+
string_setting("model_name", DEFAULT_MODEL)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def 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)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def endpoint
|
|
120
|
+
ENDPOINT
|
|
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
|
|
26
130
|
end
|
|
27
131
|
|
|
28
|
-
def
|
|
29
|
-
|
|
132
|
+
def invalid_base_url_error
|
|
133
|
+
"Invalid base_url #{base_url.inspect}: expected an http(s) URL. See `ai_git config`."
|
|
30
134
|
end
|
|
31
135
|
|
|
32
|
-
def
|
|
33
|
-
|
|
136
|
+
def valid_uri?(uri)
|
|
137
|
+
%w[http https].include?(uri.scheme) && !uri.host.to_s.empty?
|
|
34
138
|
end
|
|
35
139
|
|
|
36
|
-
def
|
|
37
|
-
|
|
140
|
+
def loopback_base_url?
|
|
141
|
+
base_uri.host.to_s.delete("[]").match?(LOOPBACK_HOST)
|
|
38
142
|
end
|
|
39
143
|
|
|
40
|
-
def
|
|
41
|
-
|
|
144
|
+
def insecure_remote_base_url?
|
|
145
|
+
!loopback_base_url? && base_uri.scheme == "http"
|
|
42
146
|
end
|
|
43
147
|
end
|
|
44
148
|
end
|
data/lib/ai_git/git.rb
CHANGED
|
@@ -1,26 +1,95 @@
|
|
|
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.
|
|
15
|
+
|
|
16
|
+
require "open3"
|
|
17
|
+
require "tempfile"
|
|
2
18
|
|
|
3
|
-
require "English"
|
|
4
19
|
module AIGit
|
|
5
20
|
module Git
|
|
6
21
|
module_function
|
|
7
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
|
+
|
|
8
35
|
def staged_files
|
|
9
|
-
|
|
36
|
+
ensure_repository!
|
|
37
|
+
capture("git", "diff", "--cached", "--name-only")
|
|
10
38
|
end
|
|
11
39
|
|
|
12
40
|
def diff
|
|
13
|
-
|
|
41
|
+
ensure_repository!
|
|
42
|
+
capture("git", "diff", "--cached")
|
|
14
43
|
end
|
|
15
44
|
|
|
16
45
|
def current_branch
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def detached_head?
|
|
55
|
+
current_branch.nil?
|
|
56
|
+
end
|
|
57
|
+
|
|
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)
|
|
68
|
+
|
|
69
|
+
return if status.success?
|
|
70
|
+
|
|
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}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def commit_with_message(message)
|
|
83
|
+
Tempfile.create("ai_git_commit_msg") do |file|
|
|
84
|
+
file.write(message)
|
|
85
|
+
file.flush
|
|
86
|
+
|
|
87
|
+
run_command("git", "commit", "-F", file.path)
|
|
88
|
+
end
|
|
19
89
|
end
|
|
20
90
|
|
|
21
|
-
def
|
|
22
|
-
|
|
23
|
-
raise "Command failed: #{cmd} #{args}" if $CHILD_STATUS.exitstatus != 0
|
|
91
|
+
def push_current_branch
|
|
92
|
+
run_command("git", "push", "-u", "origin", "HEAD")
|
|
24
93
|
end
|
|
25
94
|
end
|
|
26
95
|
end
|
|
@@ -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
|