commiti 1.3.2 → 1.3.3
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/README.md +4 -0
- data/bin/commiti +6 -0
- data/lib/commiti.rb +1 -0
- data/lib/flows/commit_flow.rb +7 -3
- data/lib/flows/pr_flow.rb +2 -2
- data/lib/services/git/commit/change_grouping.rb +6 -0
- data/lib/services/git/commit/commit_execution.rb +2 -2
- data/lib/services/git/commit/commit_staging.rb +1 -1
- data/lib/services/git/commit/group_editor.rb +254 -0
- data/lib/services/helpers/interactive_prompt.rb +14 -4
- data/lib/services/helpers/spinner.rb +4 -2
- data/lib/services/helpers/terminal_ui.rb +104 -8
- data/lib/services/message_generator.rb +3 -3
- data/lib/services/message_presenter.rb +3 -8
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7853991e7fa65bd7c35642c50b00aead27bc7b3a4c11ffec7bb38be03a9e96ca
|
|
4
|
+
data.tar.gz: a42e9fbfec33a80cf95befe00c14f3e2bd23e7d334606589e21d2e1b6ab31a6f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 82ed71618c42e1fbb745bfc04974a754b43396f232d26923c214a34738860d0a7566c90c22caa8e60dee383d953dcce0937e908a3bd81c1d1519e4cb84b3790a
|
|
7
|
+
data.tar.gz: b45f9669306182469b502bc98a2a1754c9ad3e9320b8801ac2a72bd3f412d0d01f348e8ac75fd5a82b002f84356b9e0ebb99df9c941ac6cc4607b8f53912c2ce
|
data/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Commiti
|
|
2
2
|
|
|
3
|
+
[](coverage/index.html) [](#)
|
|
4
|
+
|
|
3
5
|
AI-powered commit message and pull request description generator for Git repositories, using Google AI models.
|
|
4
6
|
|
|
5
7
|
## What It Does
|
|
@@ -100,6 +102,8 @@ Never commit `.env` to git.
|
|
|
100
102
|
By default, Commiti creates a single commit from staged changes.
|
|
101
103
|
Use `--auto-split` to let Commiti group connected file changes into multiple atomic commits.
|
|
102
104
|
|
|
105
|
+
When auto-split detects multiple groups, Commiti can optionally open a quick group editor so you can move files between groups before committing.
|
|
106
|
+
|
|
103
107
|
1. Shows `git status --short`.
|
|
104
108
|
2. Asks for confirmation before staging (`git add -A`).
|
|
105
109
|
3. Ensures there are staged changes.
|
data/bin/commiti
CHANGED
|
@@ -47,6 +47,12 @@ OptionParser.new do |opts|
|
|
|
47
47
|
end.parse!
|
|
48
48
|
|
|
49
49
|
begin
|
|
50
|
+
model_label = ENV.fetch('COMMITI_MODEL', Commiti::GoogleClient::DEFAULT_MODEL)
|
|
51
|
+
flow_label = options[:type] == :pr ? 'PR flow' : 'Commit flow'
|
|
52
|
+
base_label = options[:type] == :pr ? "Base: #{options[:base_branch] || 'main'}" : nil
|
|
53
|
+
meta = [flow_label, "Model: #{model_label}", base_label].compact.join(' • ')
|
|
54
|
+
puts Commiti::TerminalUI.banner(title: 'Commiti', subtitle: 'AI commit & PR generator', meta: meta)
|
|
55
|
+
|
|
50
56
|
flow = if options[:type] == :pr
|
|
51
57
|
Commiti::Flows::PrFlow.new(options: options)
|
|
52
58
|
else
|
data/lib/commiti.rb
CHANGED
|
@@ -20,6 +20,7 @@ require_relative 'services/message_presenter'
|
|
|
20
20
|
require_relative 'services/git/commit/commit_staging'
|
|
21
21
|
require_relative 'services/git/commit/commit_execution'
|
|
22
22
|
require_relative 'services/git/commit/change_grouping'
|
|
23
|
+
require_relative 'services/git/commit/group_editor'
|
|
23
24
|
require_relative 'flows/base_flow'
|
|
24
25
|
require_relative 'flows/commit_flow'
|
|
25
26
|
require_relative 'flows/pr_flow'
|
data/lib/flows/commit_flow.rb
CHANGED
|
@@ -61,7 +61,12 @@ module Commiti
|
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
def run_grouped_context(context:, client:, model:)
|
|
64
|
-
groups = context[:change_groups]
|
|
64
|
+
groups = Commiti::GroupEditor.edit(context[:change_groups])
|
|
65
|
+
if groups.length <= 1
|
|
66
|
+
single_context = groups.first ? build_context(diff: group_diff(groups.first), client:, model:) : context
|
|
67
|
+
return run_single_group_context(context: single_context, client:, model:)
|
|
68
|
+
end
|
|
69
|
+
|
|
65
70
|
run_stage('Unstaging current index for grouped commit execution') { Commiti::GitWriter.unstage_all! }
|
|
66
71
|
|
|
67
72
|
puts "\n#{Commiti::TerminalUI.status(:info, "Auto-split detected #{groups.length} connected change groups.")}"
|
|
@@ -75,8 +80,7 @@ module Commiti
|
|
|
75
80
|
run_stage("Staging files for group #{index + 1}/#{total}") { Commiti::GitWriter.stage_files!(group[:files]) }
|
|
76
81
|
return :continue unless run_stage('Checking staged changes') { Commiti::GitWriter.staged_changes? }
|
|
77
82
|
|
|
78
|
-
puts "\n#{Commiti::TerminalUI.
|
|
79
|
-
group[:files].each { |path| puts "- #{path}" }
|
|
83
|
+
puts "\n#{Commiti::TerminalUI.panel("Group #{index + 1}/#{total} files", Commiti::TerminalUI.bullets(group[:files]))}\n"
|
|
80
84
|
|
|
81
85
|
group_context = build_context(diff: group_diff(group), client:, model:)
|
|
82
86
|
Commiti::MessagePresenter.print_summarization_notice(group_context[:summarized_result])
|
data/lib/flows/pr_flow.rb
CHANGED
|
@@ -27,7 +27,7 @@ module Commiti
|
|
|
27
27
|
prompt_text = 'Create PR and open it in browser now?'
|
|
28
28
|
|
|
29
29
|
unless Commiti::InteractivePrompt.ask_yes_no(prompt_text, default: :no)
|
|
30
|
-
puts "\
|
|
30
|
+
puts "\n#{Commiti::TerminalUI.status(:warn, 'PR creation skipped.')}\n\n"
|
|
31
31
|
return
|
|
32
32
|
end
|
|
33
33
|
|
|
@@ -66,7 +66,7 @@ module Commiti
|
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
run_stage('Opening browser') { Commiti::PrOpener.open_in_browser(pr_url) }
|
|
69
|
-
puts "\
|
|
69
|
+
puts "\n#{Commiti::TerminalUI.panel('Opened PR page', pr_url)}\n\n"
|
|
70
70
|
end
|
|
71
71
|
end
|
|
72
72
|
end
|
|
@@ -18,6 +18,12 @@ module Commiti
|
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
def self.related?(left, right)
|
|
22
|
+
return false if left.to_s.strip.empty? || right.to_s.strip.empty?
|
|
23
|
+
|
|
24
|
+
connected?(left, right)
|
|
25
|
+
end
|
|
26
|
+
|
|
21
27
|
def self.connected_components(paths)
|
|
22
28
|
visited = {}
|
|
23
29
|
ordered_components = []
|
|
@@ -39,7 +39,7 @@ module Commiti
|
|
|
39
39
|
return commit_message(working_message, run_stage: run_stage) if errors.empty?
|
|
40
40
|
|
|
41
41
|
puts "\n#{Commiti::TerminalUI.status(:warn, 'Current message needs fixes before commit:')}"
|
|
42
|
-
errors.each { |error| puts
|
|
42
|
+
errors.each { |error| puts Commiti::TerminalUI.bullet(error) }
|
|
43
43
|
|
|
44
44
|
unless Commiti::InteractivePrompt.ask_yes_no('Open editor to fix now?', default: :yes)
|
|
45
45
|
print_skip_message
|
|
@@ -88,7 +88,7 @@ module Commiti
|
|
|
88
88
|
return edited if errors.empty?
|
|
89
89
|
|
|
90
90
|
puts "\n#{Commiti::TerminalUI.status(:warn, 'Edited message needs fixes:')}"
|
|
91
|
-
errors.each { |error| puts
|
|
91
|
+
errors.each { |error| puts Commiti::TerminalUI.bullet(error) }
|
|
92
92
|
return edited unless Commiti::InteractivePrompt.ask_yes_no('Re-open editor now?', default: :yes)
|
|
93
93
|
|
|
94
94
|
working = edited
|
|
@@ -11,7 +11,7 @@ module Commiti
|
|
|
11
11
|
status = run_stage.call('Reading git status') { Commiti::GitWriter.status_short }
|
|
12
12
|
raise 'No changes found in working tree.' if status.strip.empty?
|
|
13
13
|
|
|
14
|
-
puts "\n#{Commiti::TerminalUI.
|
|
14
|
+
puts "\n#{Commiti::TerminalUI.panel('Current git status', status)}\n"
|
|
15
15
|
return unless Commiti::InteractivePrompt.ask_yes_no('Run git add -A now?', default: :no)
|
|
16
16
|
|
|
17
17
|
run_stage.call('Staging changes (git add -A)') { Commiti::GitWriter.stage_all! }
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Commiti
|
|
4
|
+
module GroupEditor
|
|
5
|
+
HELP_TEXT = <<~TEXT.freeze
|
|
6
|
+
Select files by number, then choose where to move them.
|
|
7
|
+
|
|
8
|
+
Examples:
|
|
9
|
+
1,3,5 (select files 1, 3, 5)
|
|
10
|
+
2-4 (select files 2 through 4)
|
|
11
|
+
|
|
12
|
+
Targets:
|
|
13
|
+
1..N move to an existing group
|
|
14
|
+
n new group
|
|
15
|
+
a auto-reassign (best matching group or new)
|
|
16
|
+
c cancel
|
|
17
|
+
TEXT
|
|
18
|
+
|
|
19
|
+
def self.edit(groups)
|
|
20
|
+
return groups if groups.empty?
|
|
21
|
+
return groups unless Commiti::InteractivePrompt.ask_yes_no('Edit auto-split groups before committing?', default: :no)
|
|
22
|
+
|
|
23
|
+
working = deep_copy(groups)
|
|
24
|
+
chunk_map = build_chunk_map(working)
|
|
25
|
+
all_paths = working.flat_map { |group| group[:files] }.uniq
|
|
26
|
+
path_order = all_paths.each_with_index.to_h
|
|
27
|
+
|
|
28
|
+
puts "\n#{Commiti::TerminalUI.panel('Group editor', HELP_TEXT)}\n"
|
|
29
|
+
|
|
30
|
+
loop do
|
|
31
|
+
index_map, indexed_groups = index_groups(working)
|
|
32
|
+
print_groups(indexed_groups, total: working.length)
|
|
33
|
+
|
|
34
|
+
input = Commiti::InteractivePrompt.ask_text('Move which files? (numbers, Enter to finish)')
|
|
35
|
+
break if input.nil? || input.empty?
|
|
36
|
+
|
|
37
|
+
selected_indices = parse_indices(input, max_index: index_map.length)
|
|
38
|
+
if selected_indices.empty?
|
|
39
|
+
puts Commiti::TerminalUI.status(:warn, 'No valid file numbers selected.')
|
|
40
|
+
next
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
selected_paths = selected_indices.map { |index| index_map[index] }.compact
|
|
44
|
+
target = Commiti::InteractivePrompt.ask_text(
|
|
45
|
+
"Move to group [1-#{working.length}], n=new, a=auto, c=cancel"
|
|
46
|
+
).to_s.strip.downcase
|
|
47
|
+
|
|
48
|
+
next if target.empty? || target == 'c'
|
|
49
|
+
|
|
50
|
+
case target
|
|
51
|
+
when 'a'
|
|
52
|
+
auto_reassign(working, selected_paths, chunk_map)
|
|
53
|
+
when 'n'
|
|
54
|
+
move_to_new_group(working, selected_paths, chunk_map)
|
|
55
|
+
else
|
|
56
|
+
group_index = integer_or_nil(target)
|
|
57
|
+
unless group_index && working[group_index - 1]
|
|
58
|
+
puts Commiti::TerminalUI.status(:warn, "Group #{target} does not exist.")
|
|
59
|
+
next
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
move_to_group(working, group_index, selected_paths)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
normalize_groups(working, chunk_map, path_order)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
normalize_groups(working, chunk_map, path_order)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.print_groups(indexed_groups, total:)
|
|
72
|
+
panels = indexed_groups.map do |group|
|
|
73
|
+
title = "Group #{group[:index]}/#{total}"
|
|
74
|
+
body = if group[:entries].empty?
|
|
75
|
+
Commiti::TerminalUI.muted('No files')
|
|
76
|
+
else
|
|
77
|
+
group[:entries].map { |entry| "#{entry[:index]}. #{entry[:path]}" }.join("\n")
|
|
78
|
+
end
|
|
79
|
+
Commiti::TerminalUI.panel(title, body)
|
|
80
|
+
end
|
|
81
|
+
puts "\n#{panels.join("\n\n")}\n"
|
|
82
|
+
end
|
|
83
|
+
private_class_method :print_groups
|
|
84
|
+
|
|
85
|
+
def self.index_groups(groups)
|
|
86
|
+
index_map = {}
|
|
87
|
+
indexed_groups = []
|
|
88
|
+
counter = 1
|
|
89
|
+
|
|
90
|
+
groups.each_with_index do |group, index|
|
|
91
|
+
entries = group[:files].map do |path|
|
|
92
|
+
entry = { index: counter, path: path }
|
|
93
|
+
index_map[counter] = path
|
|
94
|
+
counter += 1
|
|
95
|
+
entry
|
|
96
|
+
end
|
|
97
|
+
indexed_groups << { index: index + 1, entries: entries }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
[index_map, indexed_groups]
|
|
101
|
+
end
|
|
102
|
+
private_class_method :index_groups
|
|
103
|
+
|
|
104
|
+
def self.parse_indices(text, max_index:)
|
|
105
|
+
raw = text.to_s.strip
|
|
106
|
+
return [] if raw.empty?
|
|
107
|
+
|
|
108
|
+
indices = []
|
|
109
|
+
invalid_tokens = []
|
|
110
|
+
|
|
111
|
+
raw.split(/[,\s]+/).each do |token|
|
|
112
|
+
next if token.empty?
|
|
113
|
+
|
|
114
|
+
if token.match?(/\A\d+-\d+\z/)
|
|
115
|
+
start_value, end_value = token.split('-').map(&:to_i)
|
|
116
|
+
range = start_value <= end_value ? (start_value..end_value) : (end_value..start_value)
|
|
117
|
+
indices.concat(range.to_a)
|
|
118
|
+
elsif token.match?(/\A\d+\z/)
|
|
119
|
+
indices << token.to_i
|
|
120
|
+
else
|
|
121
|
+
invalid_tokens << token
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
invalid_tokens.each do |token|
|
|
126
|
+
puts Commiti::TerminalUI.status(:warn, "Invalid token: #{token}")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
indices = indices.uniq
|
|
130
|
+
valid = indices.select { |value| value.between?(1, max_index) }
|
|
131
|
+
(indices - valid).each do |value|
|
|
132
|
+
puts Commiti::TerminalUI.status(:warn, "File number #{value} is out of range.")
|
|
133
|
+
end
|
|
134
|
+
valid
|
|
135
|
+
end
|
|
136
|
+
private_class_method :parse_indices
|
|
137
|
+
|
|
138
|
+
def self.move_to_group(groups, group_index, paths)
|
|
139
|
+
target = groups[group_index - 1]
|
|
140
|
+
paths.each do |path|
|
|
141
|
+
remove_path_from_groups(groups, path)
|
|
142
|
+
target[:files] << path unless target[:files].include?(path)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
private_class_method :move_to_group
|
|
146
|
+
|
|
147
|
+
def self.move_to_new_group(groups, paths, chunk_map)
|
|
148
|
+
paths.each { |path| remove_path_from_groups(groups, path) }
|
|
149
|
+
new_groups = build_groups_for_paths(paths, chunk_map)
|
|
150
|
+
groups.concat(new_groups)
|
|
151
|
+
end
|
|
152
|
+
private_class_method :move_to_new_group
|
|
153
|
+
|
|
154
|
+
def self.auto_reassign(groups, paths, chunk_map)
|
|
155
|
+
origin_groups = find_origin_groups(groups, paths)
|
|
156
|
+
paths.each { |path| remove_path_from_groups(groups, path) }
|
|
157
|
+
|
|
158
|
+
remaining = []
|
|
159
|
+
paths.each do |path|
|
|
160
|
+
excluded = origin_groups[path] ? [origin_groups[path]] : []
|
|
161
|
+
target = best_matching_group(path, groups, excluded_groups: excluded)
|
|
162
|
+
if target
|
|
163
|
+
target[:files] << path unless target[:files].include?(path)
|
|
164
|
+
else
|
|
165
|
+
remaining << path
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
groups.concat(build_groups_for_paths(remaining, chunk_map)) if remaining.any?
|
|
170
|
+
end
|
|
171
|
+
private_class_method :auto_reassign
|
|
172
|
+
|
|
173
|
+
def self.find_origin_groups(groups, paths)
|
|
174
|
+
origin = {}
|
|
175
|
+
groups.each do |group|
|
|
176
|
+
group[:files].each do |path|
|
|
177
|
+
origin[path] = group if paths.include?(path)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
origin
|
|
181
|
+
end
|
|
182
|
+
private_class_method :find_origin_groups
|
|
183
|
+
|
|
184
|
+
def self.remove_path_from_groups(groups, path)
|
|
185
|
+
groups.each { |group| group[:files].delete(path) }
|
|
186
|
+
end
|
|
187
|
+
private_class_method :remove_path_from_groups
|
|
188
|
+
|
|
189
|
+
def self.best_matching_group(path, groups, excluded_groups:)
|
|
190
|
+
candidates = groups - excluded_groups
|
|
191
|
+
best = nil
|
|
192
|
+
best_score = 0
|
|
193
|
+
|
|
194
|
+
candidates.each do |group|
|
|
195
|
+
score = group[:files].count { |existing| Commiti::ChangeGrouping.related?(path, existing) }
|
|
196
|
+
next if score <= best_score
|
|
197
|
+
|
|
198
|
+
best_score = score
|
|
199
|
+
best = group
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
best_score.positive? ? best : nil
|
|
203
|
+
end
|
|
204
|
+
private_class_method :best_matching_group
|
|
205
|
+
|
|
206
|
+
def self.build_groups_for_paths(paths, chunk_map)
|
|
207
|
+
line_chunks = paths.map { |path| chunk_map[path] }.compact
|
|
208
|
+
return [] if line_chunks.empty?
|
|
209
|
+
|
|
210
|
+
Commiti::ChangeGrouping.group(line_chunks).map do |group|
|
|
211
|
+
{
|
|
212
|
+
id: group[:id],
|
|
213
|
+
files: group[:files],
|
|
214
|
+
chunks: group[:chunks]
|
|
215
|
+
}
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
private_class_method :build_groups_for_paths
|
|
219
|
+
|
|
220
|
+
def self.build_chunk_map(groups)
|
|
221
|
+
groups.flat_map { |group| group[:chunks] }
|
|
222
|
+
.each_with_object({}) { |chunk, acc| acc[chunk[:path]] = chunk }
|
|
223
|
+
end
|
|
224
|
+
private_class_method :build_chunk_map
|
|
225
|
+
|
|
226
|
+
def self.normalize_groups(groups, chunk_map, path_order)
|
|
227
|
+
groups.reject! { |group| group[:files].empty? }
|
|
228
|
+
groups.each do |group|
|
|
229
|
+
group[:files] = group[:files].uniq.sort_by { |path| path_order[path] || path_order.length }
|
|
230
|
+
group[:chunks] = group[:files].filter_map { |path| chunk_map[path] }
|
|
231
|
+
end
|
|
232
|
+
groups.each_with_index { |group, index| group[:id] = index + 1 }
|
|
233
|
+
end
|
|
234
|
+
private_class_method :normalize_groups
|
|
235
|
+
|
|
236
|
+
def self.integer_or_nil(value)
|
|
237
|
+
return nil unless value.to_s.match?(/\A\d+\z/)
|
|
238
|
+
|
|
239
|
+
value.to_i
|
|
240
|
+
end
|
|
241
|
+
private_class_method :integer_or_nil
|
|
242
|
+
|
|
243
|
+
def self.deep_copy(groups)
|
|
244
|
+
groups.map do |group|
|
|
245
|
+
{
|
|
246
|
+
id: group[:id],
|
|
247
|
+
files: group[:files].dup,
|
|
248
|
+
chunks: group[:chunks].map(&:dup)
|
|
249
|
+
}
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
private_class_method :deep_copy
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -12,7 +12,7 @@ module Commiti
|
|
|
12
12
|
|
|
13
13
|
def self.ask_yes_no(question, default: :no)
|
|
14
14
|
suffix = default == :yes ? '[Y/n]' : '[y/N]'
|
|
15
|
-
input = read_input("#{question} #{suffix} ")
|
|
15
|
+
input = read_input("#{Commiti::TerminalUI.prompt(question)} #{Commiti::TerminalUI.muted(suffix)} ")
|
|
16
16
|
return default == :yes ? :yes : nil if input.nil?
|
|
17
17
|
|
|
18
18
|
value = input.strip.downcase
|
|
@@ -24,7 +24,7 @@ module Commiti
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def self.ask_commit_action
|
|
27
|
-
input = read_input('Commit with this message? [y/e/N]
|
|
27
|
+
input = read_input("#{Commiti::TerminalUI.prompt('Commit with this message?')} #{Commiti::TerminalUI.muted('[y/e/N]')} ")
|
|
28
28
|
return :no if input.nil?
|
|
29
29
|
|
|
30
30
|
value = input.strip.downcase
|
|
@@ -38,17 +38,27 @@ module Commiti
|
|
|
38
38
|
return 0 if count <= 1
|
|
39
39
|
|
|
40
40
|
loop do
|
|
41
|
-
input = read_input(
|
|
41
|
+
input = read_input(
|
|
42
|
+
"#{Commiti::TerminalUI.prompt('Select candidate')} " \
|
|
43
|
+
"#{Commiti::TerminalUI.muted("[1-#{count}] (default: #{default})")}: "
|
|
44
|
+
)
|
|
42
45
|
return default - 1 if input.nil?
|
|
43
46
|
|
|
44
47
|
value = input.strip
|
|
45
48
|
return default - 1 if value.empty?
|
|
46
49
|
return value.to_i - 1 if value.match?(/\A\d+\z/) && value.to_i.between?(1, count)
|
|
47
50
|
|
|
48
|
-
puts "Please type a number between 1 and #{count}."
|
|
51
|
+
puts Commiti::TerminalUI.status(:warn, "Please type a number between 1 and #{count}.")
|
|
49
52
|
end
|
|
50
53
|
end
|
|
51
54
|
|
|
55
|
+
def self.ask_text(question)
|
|
56
|
+
input = read_input("#{Commiti::TerminalUI.prompt(question)} ")
|
|
57
|
+
return nil if input.nil?
|
|
58
|
+
|
|
59
|
+
input.to_s.strip
|
|
60
|
+
end
|
|
61
|
+
|
|
52
62
|
def self.edit_message(initial_message)
|
|
53
63
|
# Keep the temp file closed while the external editor runs.
|
|
54
64
|
# On Windows, open handles can prevent editors like Notepad from
|
|
@@ -16,7 +16,8 @@ module Commiti
|
|
|
16
16
|
index = 0
|
|
17
17
|
until done
|
|
18
18
|
frame = Commiti::TerminalUI.color(FRAMES[index % FRAMES.length], :cyan)
|
|
19
|
-
|
|
19
|
+
line = "#{frame} #{message}"
|
|
20
|
+
print "\r#{Commiti::TerminalUI.pad_right(line, Commiti::TerminalUI.width)}"
|
|
20
21
|
$stdout.flush
|
|
21
22
|
index += 1
|
|
22
23
|
sleep INTERVAL_SECONDS
|
|
@@ -31,7 +32,8 @@ module Commiti
|
|
|
31
32
|
done = true
|
|
32
33
|
spinner_thread.join
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
final_line = final_status_line(error, message)
|
|
36
|
+
print "\r#{Commiti::TerminalUI.pad_right(final_line, Commiti::TerminalUI.width)}\n"
|
|
35
37
|
$stdout.flush
|
|
36
38
|
end
|
|
37
39
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'io/console'
|
|
4
|
+
|
|
3
5
|
module Commiti
|
|
4
6
|
module TerminalUI
|
|
5
7
|
COLORS = {
|
|
@@ -8,17 +10,39 @@ module Commiti
|
|
|
8
10
|
yellow: 33,
|
|
9
11
|
blue: 34,
|
|
10
12
|
cyan: 36,
|
|
13
|
+
magenta: 35,
|
|
11
14
|
gray: 90,
|
|
12
15
|
bold: 1
|
|
13
16
|
}.freeze
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
success: '
|
|
17
|
-
fail: '
|
|
18
|
+
UNICODE_ICONS = {
|
|
19
|
+
success: '✔',
|
|
20
|
+
fail: '✖',
|
|
18
21
|
info: 'ℹ',
|
|
19
22
|
warn: '⚠'
|
|
20
23
|
}.freeze
|
|
21
24
|
|
|
25
|
+
ASCII_ICONS = {
|
|
26
|
+
success: '+',
|
|
27
|
+
fail: 'x',
|
|
28
|
+
info: 'i',
|
|
29
|
+
warn: '!'
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
UNICODE_MARKERS = {
|
|
33
|
+
prompt: '›',
|
|
34
|
+
header: '▸',
|
|
35
|
+
bullet: '•',
|
|
36
|
+
rule: '─'
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
ASCII_MARKERS = {
|
|
40
|
+
prompt: '>',
|
|
41
|
+
header: '>',
|
|
42
|
+
bullet: '-',
|
|
43
|
+
rule: '-'
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
22
46
|
def self.supports_ansi?
|
|
23
47
|
return false unless $stdout.tty?
|
|
24
48
|
return false if ENV.key?('NO_COLOR')
|
|
@@ -27,6 +51,23 @@ module Commiti
|
|
|
27
51
|
term != 'dumb'
|
|
28
52
|
end
|
|
29
53
|
|
|
54
|
+
def self.supports_unicode?
|
|
55
|
+
encoding = $stdout.external_encoding || Encoding.default_external
|
|
56
|
+
encoding.name.upcase.include?('UTF-8')
|
|
57
|
+
rescue StandardError
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.width
|
|
62
|
+
cols = IO.console&.winsize&.last
|
|
63
|
+
cols = ENV.fetch('COLUMNS', nil) if cols.nil? || cols <= 0
|
|
64
|
+
cols = cols.to_i
|
|
65
|
+
cols = 80 if cols <= 0
|
|
66
|
+
[cols, 120].min
|
|
67
|
+
rescue StandardError
|
|
68
|
+
80
|
|
69
|
+
end
|
|
70
|
+
|
|
30
71
|
def self.color(text, *styles)
|
|
31
72
|
return text unless supports_ansi?
|
|
32
73
|
|
|
@@ -37,22 +78,77 @@ module Commiti
|
|
|
37
78
|
end
|
|
38
79
|
|
|
39
80
|
def self.status(kind, text)
|
|
40
|
-
symbol =
|
|
81
|
+
symbol = icon_for(kind)
|
|
41
82
|
color_style = case kind
|
|
42
83
|
when :success then :green
|
|
43
84
|
when :fail then :red
|
|
44
85
|
when :warn then :yellow
|
|
45
86
|
else :blue
|
|
46
87
|
end
|
|
47
|
-
"#{color(symbol, color_style)} #{text}"
|
|
88
|
+
"#{color(symbol, color_style, :bold)} #{text}"
|
|
48
89
|
end
|
|
49
90
|
|
|
50
|
-
def self.separator(length =
|
|
51
|
-
|
|
91
|
+
def self.separator(length = nil)
|
|
92
|
+
char = marker(:rule)
|
|
93
|
+
color(char * (length || width), :gray)
|
|
52
94
|
end
|
|
53
95
|
|
|
54
96
|
def self.header(text)
|
|
55
|
-
color(text, :bold, :cyan)
|
|
97
|
+
"#{color(marker(:header), :cyan, :bold)} #{color(text, :bold, :cyan)}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.prompt(text)
|
|
101
|
+
"#{color(marker(:prompt), :cyan, :bold)} #{text}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.muted(text)
|
|
105
|
+
color(text, :gray)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.panel(title, body)
|
|
109
|
+
[
|
|
110
|
+
separator,
|
|
111
|
+
header(title),
|
|
112
|
+
separator,
|
|
113
|
+
body.to_s.rstrip,
|
|
114
|
+
separator
|
|
115
|
+
].join("\n")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.bullet(text)
|
|
119
|
+
"#{marker(:bullet)} #{text}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.bullets(items)
|
|
123
|
+
items.map { |item| bullet(item) }.join("\n")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.pad_right(text, length)
|
|
127
|
+
padding = [length - visible_length(text), 0].max
|
|
128
|
+
"#{text}#{' ' * padding}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.visible_length(text)
|
|
132
|
+
strip_ansi(text).length
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def self.strip_ansi(text)
|
|
136
|
+
text.to_s.gsub(/\e\[[0-9;]*m/, '')
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.banner(title:, subtitle: nil, meta: nil)
|
|
140
|
+
body_lines = [subtitle, meta].compact.join("\n")
|
|
141
|
+
panel(title, body_lines)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def self.icon_for(kind)
|
|
145
|
+
(supports_unicode? ? UNICODE_ICONS : ASCII_ICONS).fetch(kind, '*')
|
|
146
|
+
end
|
|
147
|
+
private_class_method :icon_for
|
|
148
|
+
|
|
149
|
+
def self.marker(kind)
|
|
150
|
+
(supports_unicode? ? UNICODE_MARKERS : ASCII_MARKERS).fetch(kind, '*')
|
|
56
151
|
end
|
|
152
|
+
private_class_method :marker
|
|
57
153
|
end
|
|
58
154
|
end
|
|
@@ -18,7 +18,7 @@ module Commiti
|
|
|
18
18
|
|
|
19
19
|
def generate_candidates(client:, prompt:, diff_metadata:, count:, model:)
|
|
20
20
|
(1..count).map do |index|
|
|
21
|
-
puts "\
|
|
21
|
+
puts "\n#{Commiti::TerminalUI.status(:info, "Generating candidate #{index}/#{count}...")}"
|
|
22
22
|
generate_with_quality_check(client: client, prompt: prompt, diff_metadata: diff_metadata, model: model)
|
|
23
23
|
end
|
|
24
24
|
end
|
|
@@ -35,8 +35,8 @@ module Commiti
|
|
|
35
35
|
return normalize_commit_message(message, diff_metadata: diff_metadata) if reason.nil? && flow_type == :commit
|
|
36
36
|
return message if reason.nil?
|
|
37
37
|
|
|
38
|
-
puts "\
|
|
39
|
-
puts "Retrying once with stronger constraints
|
|
38
|
+
puts "\n#{Commiti::TerminalUI.status(:warn, "Generated output looked weak: #{reason}")}"
|
|
39
|
+
puts "#{Commiti::TerminalUI.status(:info, 'Retrying once with stronger constraints...')}\n"
|
|
40
40
|
|
|
41
41
|
retried_message = clean_output(generate_from_client(
|
|
42
42
|
client: client,
|
|
@@ -35,18 +35,13 @@ module Commiti
|
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
def self.print_message(message)
|
|
39
|
-
puts "\n#{Commiti::TerminalUI.
|
|
40
|
-
puts Commiti::TerminalUI.header('Generated output')
|
|
41
|
-
puts Commiti::TerminalUI.separator
|
|
42
|
-
puts message
|
|
43
|
-
puts "#{Commiti::TerminalUI.separator}\n"
|
|
38
|
+
def self.print_message(message, title: 'Generated output')
|
|
39
|
+
puts "\n#{Commiti::TerminalUI.panel(title, message)}\n"
|
|
44
40
|
end
|
|
45
41
|
|
|
46
42
|
def self.print_candidates(candidates)
|
|
47
43
|
candidates.each_with_index do |candidate, index|
|
|
48
|
-
|
|
49
|
-
print_message(candidate)
|
|
44
|
+
print_message(candidate, title: "Candidate #{index + 1}")
|
|
50
45
|
end
|
|
51
46
|
end
|
|
52
47
|
private_class_method :print_candidates
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: commiti
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.3.
|
|
4
|
+
version: 1.3.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Setoju
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-05-
|
|
11
|
+
date: 2026-05-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: dotenv
|
|
@@ -61,6 +61,7 @@ files:
|
|
|
61
61
|
- lib/services/git/commit/change_grouping.rb
|
|
62
62
|
- lib/services/git/commit/commit_execution.rb
|
|
63
63
|
- lib/services/git/commit/commit_staging.rb
|
|
64
|
+
- lib/services/git/commit/group_editor.rb
|
|
64
65
|
- lib/services/git/diff_parser.rb
|
|
65
66
|
- lib/services/git/git_reader.rb
|
|
66
67
|
- lib/services/git/git_writer.rb
|