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.
@@ -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
- PROVIDERS = {
6
- "jan" => {
7
- default_model: "Jan-v3.5-4B-Q4_K_XL",
8
- base_url: "http://127.0.0.1:1337",
9
- endpoint: "/v1/chat/completions",
10
- request_format: :openai
11
- },
12
- "ollama" => {
13
- default_model: "gemma4:e4b",
14
- base_url: "http://localhost:11434",
15
- endpoint: "/api/generate",
16
- request_format: :ollama
17
- }
18
- }.freeze
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 self.provider
21
- ENV["AI_GIT_AI_PROVIDER"] || "jan"
58
+ def settings
59
+ @settings ||= load_settings
22
60
  end
23
61
 
24
- def self.model_name
25
- ENV["AI_GIT_MODEL_NAME"] || PROVIDERS[provider][:default_model]
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 self.base_url
29
- ENV["AI_GIT_BASE_URL"] || PROVIDERS[provider][:base_url]
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 self.endpoint
33
- PROVIDERS[provider][:endpoint]
136
+ def valid_uri?(uri)
137
+ %w[http https].include?(uri.scheme) && !uri.host.to_s.empty?
34
138
  end
35
139
 
36
- def self.request_format
37
- PROVIDERS[provider][:request_format]
140
+ def loopback_base_url?
141
+ base_uri.host.to_s.delete("[]").match?(LOOPBACK_HOST)
38
142
  end
39
143
 
40
- def self.config
41
- PROVIDERS[provider]
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
- `git diff --cached --name-only`
36
+ ensure_repository!
37
+ capture("git", "diff", "--cached", "--name-only")
10
38
  end
11
39
 
12
40
  def diff
13
- `git diff --cached`
41
+ ensure_repository!
42
+ capture("git", "diff", "--cached")
14
43
  end
15
44
 
16
45
  def current_branch
17
- result = `git rev-parse --abbrev-ref HEAD`
18
- result.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
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 run_command(cmd, args)
22
- system("#{cmd} #{args} > /dev/null 2>&1")
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