hunkify 0.1.0 → 0.2.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/lib/hunkify/anthropic_api.rb +65 -1
- data/lib/hunkify/cli.rb +2 -0
- data/lib/hunkify/ui.rb +167 -3
- data/lib/hunkify/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0ba6dab53be5bab36d05e9535bc0590a858638840987a0a3c9ee37253279c1fa
|
|
4
|
+
data.tar.gz: 30d6ac6b5f76e1ec04d04d2815f733c9bd514393d9cbe5823b0670107a0bf875
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ea9993ade4e70d1fa2a2d88a3ce3cc0769209685bc54102a286601768c15f31e8706dccc5e1f61fa919b4bf886421df2ba7940e7a8277a494e9d2e2a93d65f29
|
|
7
|
+
data.tar.gz: 07c1b7fdff6146ff9425921b46cc0ba8ae004dd4faa94a54688dd0b16169be61d574aea71b4ef7b0c5d749684e721612eaf6f003530e571895022ef1d0233967
|
|
@@ -17,7 +17,20 @@ module Hunkify
|
|
|
17
17
|
- One commit = one unique intent (feat, fix, refactor, style, etc.)
|
|
18
18
|
- Hunks in different files CAN belong to the same commit if they serve the same intent
|
|
19
19
|
- Hunks in the SAME file can belong to DIFFERENT commits if they are semantically distinct
|
|
20
|
-
-
|
|
20
|
+
- STRONGLY prefer fine-grained, atomic commits over large bundled ones.
|
|
21
|
+
When in doubt, SPLIT rather than merge.
|
|
22
|
+
- Heuristics to split:
|
|
23
|
+
* Different modules/components/features → different commits
|
|
24
|
+
* Core logic vs. tests → different commits (one feat commit + one test commit)
|
|
25
|
+
* Core logic vs. docs → different commits
|
|
26
|
+
* Core logic vs. config/build files → different commits
|
|
27
|
+
* Unrelated fixes bundled with a feature → separate them
|
|
28
|
+
* Each file introducing a new, independent capability usually deserves
|
|
29
|
+
its own commit
|
|
30
|
+
- Only bundle hunks together when they genuinely cannot be reviewed or
|
|
31
|
+
reverted independently.
|
|
32
|
+
- Err on the side of MORE commits. A PR with 8 small focused commits is
|
|
33
|
+
better than one with 3 large ones.
|
|
21
34
|
|
|
22
35
|
RESPONSE FORMAT (strict JSON, no surrounding text):
|
|
23
36
|
{
|
|
@@ -98,5 +111,56 @@ module Hunkify
|
|
|
98
111
|
|
|
99
112
|
JSON.parse(cleaned)
|
|
100
113
|
end
|
|
114
|
+
|
|
115
|
+
SUGGEST_SYSTEM_PROMPT = <<~PROMPT
|
|
116
|
+
You are a Git expert. You are given one or more hunks that the user wants
|
|
117
|
+
to bundle into a single commit. Produce ONE conventional commit message
|
|
118
|
+
following these rules:
|
|
119
|
+
|
|
120
|
+
- Gitmoji + type + scope: ":sparkles: feat(scope): short description"
|
|
121
|
+
- Types: feat, fix, refactor, style, test, docs, config, build, perf, security
|
|
122
|
+
- In English, imperative, no leading capital, no trailing period
|
|
123
|
+
- Max 72 characters
|
|
124
|
+
- If a user context is provided, use it to steer scope/wording. If it looks
|
|
125
|
+
like a ticket ID, use it as the scope.
|
|
126
|
+
|
|
127
|
+
AVAILABLE GITMOJIS:
|
|
128
|
+
:sparkles: feat | :bug: fix | :recycle: refactor | :lipstick: style
|
|
129
|
+
:white_check_mark: test | :memo: docs | :wrench: config | :package: build
|
|
130
|
+
:zap: perf | :lock: security
|
|
131
|
+
|
|
132
|
+
RESPOND ONLY WITH THE MESSAGE. No markdown, no quotes, no explanation.
|
|
133
|
+
PROMPT
|
|
134
|
+
|
|
135
|
+
def self.suggest_message(hunks, context: nil)
|
|
136
|
+
api_key = ENV["ANTHROPIC_API_KEY"]
|
|
137
|
+
raise "ANTHROPIC_API_KEY missing!" if api_key.nil? || api_key.empty?
|
|
138
|
+
|
|
139
|
+
user_ctx = context && !context.empty? ? "\nUser context: #{context}" : ""
|
|
140
|
+
summary = hunks.map(&:to_summary).join("\n\n---\n\n")
|
|
141
|
+
user_message = "#{user_ctx}\n\nHunks to bundle into a single commit:\n\n#{summary}"
|
|
142
|
+
|
|
143
|
+
uri = URI(API_URL)
|
|
144
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
145
|
+
http.use_ssl = true
|
|
146
|
+
http.read_timeout = 30
|
|
147
|
+
|
|
148
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
149
|
+
request["Content-Type"] = "application/json"
|
|
150
|
+
request["x-api-key"] = api_key
|
|
151
|
+
request["anthropic-version"] = "2023-06-01"
|
|
152
|
+
request.body = JSON.generate({
|
|
153
|
+
model: MODEL,
|
|
154
|
+
max_tokens: 128,
|
|
155
|
+
system: SUGGEST_SYSTEM_PROMPT,
|
|
156
|
+
messages: [{role: "user", content: user_message}]
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
response = http.request(request)
|
|
160
|
+
body = JSON.parse(response.body)
|
|
161
|
+
raise "API Error #{response.code}: #{body["error"]&.dig("message")}" unless response.code == "200"
|
|
162
|
+
|
|
163
|
+
body.dig("content", 0, "text").to_s.strip.lines.first.to_s.strip
|
|
164
|
+
end
|
|
101
165
|
end
|
|
102
166
|
end
|
data/lib/hunkify/cli.rb
CHANGED
data/lib/hunkify/ui.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "color"
|
|
4
|
+
require "readline"
|
|
4
5
|
|
|
5
6
|
module Hunkify
|
|
6
7
|
module UI
|
|
@@ -41,6 +42,170 @@ module Hunkify
|
|
|
41
42
|
end
|
|
42
43
|
end
|
|
43
44
|
|
|
45
|
+
def reassign_loop(commits_data, hunks_by_id, context: nil)
|
|
46
|
+
loop do
|
|
47
|
+
print " #{Color.yellow("[m]")} Move hunk #{Color.yellow("[n]")} New commit #{Color.yellow("[Enter]")} Continue #{Color.yellow("[q]")} Quit: "
|
|
48
|
+
input = $stdin.gets.chomp.downcase
|
|
49
|
+
|
|
50
|
+
case input
|
|
51
|
+
when ""
|
|
52
|
+
return
|
|
53
|
+
when "q"
|
|
54
|
+
puts Color.red("\n Cancelled.")
|
|
55
|
+
exit 0
|
|
56
|
+
when "m"
|
|
57
|
+
move_hunk(commits_data, hunks_by_id, context: context)
|
|
58
|
+
when "n"
|
|
59
|
+
new_commit(commits_data, hunks_by_id, context: context)
|
|
60
|
+
else
|
|
61
|
+
puts Color.dim(" (unknown command)")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def prompt_new_message(hunk_ids, hunks_by_id, context:)
|
|
67
|
+
hunks = hunk_ids.map { |id| hunks_by_id[id] }.compact
|
|
68
|
+
print Color.dim(" Asking Claude for a suggestion... ")
|
|
69
|
+
begin
|
|
70
|
+
suggestion = AnthropicAPI.suggest_message(hunks, context: context)
|
|
71
|
+
rescue => e
|
|
72
|
+
puts Color.red("failed (#{e.message})")
|
|
73
|
+
suggestion = nil
|
|
74
|
+
end
|
|
75
|
+
puts
|
|
76
|
+
|
|
77
|
+
if suggestion && !suggestion.empty?
|
|
78
|
+
puts " #{Color.dim("suggested:")} #{Color.green(suggestion)}"
|
|
79
|
+
print " #{Color.yellow("[Enter]")} Accept #{Color.yellow("[type]")} Override: "
|
|
80
|
+
else
|
|
81
|
+
print " Commit message: "
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
input = $stdin.gets.chomp
|
|
85
|
+
input.empty? ? suggestion : input
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def print_hunk(hunk, max_lines: 25)
|
|
89
|
+
added = hunk.lines.count { |l| l.start_with?("+") }
|
|
90
|
+
removed = hunk.lines.count { |l| l.start_with?("-") }
|
|
91
|
+
|
|
92
|
+
title = "Hunk [#{hunk.id}] · #{hunk.file_path} #{Color.green("+#{added}")} #{Color.red("-#{removed}")}"
|
|
93
|
+
# visible width: strip ANSI to compute padding
|
|
94
|
+
visible = title.gsub(/\e\[[0-9;]*m/, "")
|
|
95
|
+
inner_w = [visible.length + 4, 60].max
|
|
96
|
+
|
|
97
|
+
puts
|
|
98
|
+
puts Color.dim(" ╭─ ") + title + " " + Color.dim("─" * [inner_w - visible.length - 4, 1].max) + Color.dim("╮")
|
|
99
|
+
puts Color.dim(" │ ") + Color.dim(hunk.hunk_header)
|
|
100
|
+
|
|
101
|
+
lines = hunk.lines
|
|
102
|
+
truncated = lines.size > max_lines
|
|
103
|
+
shown = truncated ? lines.first(max_lines) : lines
|
|
104
|
+
|
|
105
|
+
shown.each do |line|
|
|
106
|
+
colored =
|
|
107
|
+
if line.start_with?("+")
|
|
108
|
+
Color.green(line)
|
|
109
|
+
elsif line.start_with?("-")
|
|
110
|
+
Color.red(line)
|
|
111
|
+
else
|
|
112
|
+
line
|
|
113
|
+
end
|
|
114
|
+
puts Color.dim(" │ ") + colored
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
if truncated
|
|
118
|
+
puts Color.dim(" │ … #{lines.size - max_lines} more line(s)")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
puts Color.dim(" ╰" + "─" * (inner_w + 2) + "╯")
|
|
122
|
+
puts
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def move_hunk(commits_data, hunks_by_id, context: nil)
|
|
126
|
+
print " Hunk id? "
|
|
127
|
+
hunk_id = $stdin.gets.chomp.to_i
|
|
128
|
+
unless hunks_by_id.key?(hunk_id)
|
|
129
|
+
puts Color.red(" ✗ Unknown hunk id.")
|
|
130
|
+
return
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
print_hunk(hunks_by_id[hunk_id])
|
|
134
|
+
|
|
135
|
+
print " Target commit (1-#{commits_data.size}, 'new', or 'c' to cancel): "
|
|
136
|
+
target = $stdin.gets.chomp.downcase
|
|
137
|
+
|
|
138
|
+
if target.empty? || target == "c" || target == "cancel"
|
|
139
|
+
puts Color.dim(" Cancelled.")
|
|
140
|
+
return
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
commits_data.each { |c| c["hunk_ids"].delete(hunk_id) }
|
|
144
|
+
|
|
145
|
+
if target == "new"
|
|
146
|
+
msg = prompt_new_message([hunk_id], hunks_by_id, context: context)
|
|
147
|
+
if msg.nil? || msg.empty?
|
|
148
|
+
puts Color.red(" ✗ Empty message, reassignment cancelled.")
|
|
149
|
+
reinsert_hunk(commits_data, hunk_id)
|
|
150
|
+
return
|
|
151
|
+
end
|
|
152
|
+
commits_data << {"message" => msg, "hunk_ids" => [hunk_id], "reasoning" => "manual"}
|
|
153
|
+
else
|
|
154
|
+
idx = target.to_i - 1
|
|
155
|
+
unless (0...commits_data.size).cover?(idx)
|
|
156
|
+
puts Color.red(" ✗ Invalid target.")
|
|
157
|
+
reinsert_hunk(commits_data, hunk_id)
|
|
158
|
+
return
|
|
159
|
+
end
|
|
160
|
+
commits_data[idx]["hunk_ids"] << hunk_id
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
commits_data.reject! { |c| c["hunk_ids"].empty? }
|
|
164
|
+
puts
|
|
165
|
+
print_grouping(commits_data, hunks_by_id)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def new_commit(commits_data, hunks_by_id, context: nil)
|
|
169
|
+
print " Hunk ids (space-separated): "
|
|
170
|
+
ids = $stdin.gets.chomp.split.map(&:to_i)
|
|
171
|
+
ids = ids.select { |id| hunks_by_id.key?(id) }
|
|
172
|
+
if ids.empty?
|
|
173
|
+
puts Color.red(" ✗ No valid hunk ids.")
|
|
174
|
+
return
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
msg = prompt_new_message(ids, hunks_by_id, context: context)
|
|
178
|
+
if msg.nil? || msg.empty?
|
|
179
|
+
puts Color.red(" ✗ Empty message, cancelled.")
|
|
180
|
+
return
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
commits_data.each { |c| c["hunk_ids"] -= ids }
|
|
184
|
+
commits_data << {"message" => msg, "hunk_ids" => ids, "reasoning" => "manual"}
|
|
185
|
+
commits_data.reject! { |c| c["hunk_ids"].empty? }
|
|
186
|
+
puts
|
|
187
|
+
print_grouping(commits_data, hunks_by_id)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def prefill_readline(prompt, default)
|
|
191
|
+
Readline.pre_input_hook = lambda do
|
|
192
|
+
Readline.insert_text(" #{default}")
|
|
193
|
+
Readline.redisplay
|
|
194
|
+
Readline.pre_input_hook = nil
|
|
195
|
+
end
|
|
196
|
+
result = Readline.readline(prompt, false)
|
|
197
|
+
result&.sub(/\A /, "")
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def reinsert_hunk(commits_data, hunk_id)
|
|
201
|
+
# Hunk was removed in advance; put it back somewhere safe.
|
|
202
|
+
if commits_data.any?
|
|
203
|
+
commits_data.first["hunk_ids"] << hunk_id
|
|
204
|
+
else
|
|
205
|
+
commits_data << {"message" => "unassigned", "hunk_ids" => [hunk_id], "reasoning" => "manual"}
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
44
209
|
def prompt_edit_commit(commit_data, _index, _total)
|
|
45
210
|
print " #{Color.yellow("[Enter]")} Confirm #{Color.yellow("[e]")} Edit message #{Color.yellow("[s]")} Skip #{Color.yellow("[q]")} Quit: "
|
|
46
211
|
input = $stdin.gets.chomp.downcase
|
|
@@ -49,9 +214,8 @@ module Hunkify
|
|
|
49
214
|
when ""
|
|
50
215
|
commit_data["message"]
|
|
51
216
|
when "e"
|
|
52
|
-
|
|
53
|
-
new_msg
|
|
54
|
-
new_msg.empty? ? commit_data["message"] : new_msg
|
|
217
|
+
new_msg = prefill_readline(" ✏️ New message: ", commit_data["message"])
|
|
218
|
+
new_msg.to_s.empty? ? commit_data["message"] : new_msg
|
|
55
219
|
when "s"
|
|
56
220
|
nil
|
|
57
221
|
when "q"
|
data/lib/hunkify/version.rb
CHANGED