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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 462d60419d0723593916ed35414e3f70ba61df1284f13aeaf640d7d45e0aaed3
4
- data.tar.gz: 720035e85d1c6f7b8770a361e0e2a6cde7a98b6e9eeb3e1fffbe95054bfda91f
3
+ metadata.gz: 0ba6dab53be5bab36d05e9535bc0590a858638840987a0a3c9ee37253279c1fa
4
+ data.tar.gz: 30d6ac6b5f76e1ec04d04d2815f733c9bd514393d9cbe5823b0670107a0bf875
5
5
  SHA512:
6
- metadata.gz: 7a51c9e785a105b186221212c7eb5a5111a2da5a58462cb7359bedd8a3312ebb9f34f4f73dfd1ee0e244ffe01f7f772d4501b1439daf772443a0e0b8df445e5e
7
- data.tar.gz: 6f0fafc113ff3398179b232a198372e0fcd4249bac99981e5dedf704a638adb3472d58b43f02c7825cfbd95e6d0ba3f33f07dcbe76c042cf8a6a3d47ff769e03
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
- - Prefer atomic and independent commits
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
@@ -70,6 +70,8 @@ module Hunkify
70
70
 
71
71
  UI.print_grouping(commits_data, hunks_by_id)
72
72
 
73
+ UI.reassign_loop(commits_data, hunks_by_id, context: context)
74
+
73
75
  plan = []
74
76
  commits_data.each_with_index do |c, i|
75
77
  puts Color.bold(" Commit #{i + 1}/#{commits_data.size}:")
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
- print " ✏️ New message: "
53
- new_msg = $stdin.gets.chomp
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"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hunkify
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hunkify
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom SCHIAVI