hunkify 0.1.0 → 0.2.0

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: 374b11ac3893bb113123d303f53df7cb2a613ec21e3aa89919e908d6b1e33225
4
+ data.tar.gz: cbc1210aee6014e214044a026997b679f519fd03ae58ad3f06fba3b37de98fd4
5
5
  SHA512:
6
- metadata.gz: 7a51c9e785a105b186221212c7eb5a5111a2da5a58462cb7359bedd8a3312ebb9f34f4f73dfd1ee0e244ffe01f7f772d4501b1439daf772443a0e0b8df445e5e
7
- data.tar.gz: 6f0fafc113ff3398179b232a198372e0fcd4249bac99981e5dedf704a638adb3472d58b43f02c7825cfbd95e6d0ba3f33f07dcbe76c042cf8a6a3d47ff769e03
6
+ metadata.gz: 9ca7b307b7494da84e3279d56a32c7111098890dfe48b6c052811f4dd4d096c133bbe2fe7669211c6ce2d4d62b074b87f3f84f97ec8505eecddcc39ada2ed11c
7
+ data.tar.gz: 66921d70a096260089119bf16f4bdc847bfba0cf67b23cf97e802455886f5fa875c9ad15158fc2c952708f462656bce63754383ceb4ec1585fcf0bb06004c07b
@@ -98,5 +98,56 @@ module Hunkify
98
98
 
99
99
  JSON.parse(cleaned)
100
100
  end
101
+
102
+ SUGGEST_SYSTEM_PROMPT = <<~PROMPT
103
+ You are a Git expert. You are given one or more hunks that the user wants
104
+ to bundle into a single commit. Produce ONE conventional commit message
105
+ following these rules:
106
+
107
+ - Gitmoji + type + scope: ":sparkles: feat(scope): short description"
108
+ - Types: feat, fix, refactor, style, test, docs, config, build, perf, security
109
+ - In English, imperative, no leading capital, no trailing period
110
+ - Max 72 characters
111
+ - If a user context is provided, use it to steer scope/wording. If it looks
112
+ like a ticket ID, use it as the scope.
113
+
114
+ AVAILABLE GITMOJIS:
115
+ :sparkles: feat | :bug: fix | :recycle: refactor | :lipstick: style
116
+ :white_check_mark: test | :memo: docs | :wrench: config | :package: build
117
+ :zap: perf | :lock: security
118
+
119
+ RESPOND ONLY WITH THE MESSAGE. No markdown, no quotes, no explanation.
120
+ PROMPT
121
+
122
+ def self.suggest_message(hunks, context: nil)
123
+ api_key = ENV["ANTHROPIC_API_KEY"]
124
+ raise "ANTHROPIC_API_KEY missing!" if api_key.nil? || api_key.empty?
125
+
126
+ user_ctx = context && !context.empty? ? "\nUser context: #{context}" : ""
127
+ summary = hunks.map(&:to_summary).join("\n\n---\n\n")
128
+ user_message = "#{user_ctx}\n\nHunks to bundle into a single commit:\n\n#{summary}"
129
+
130
+ uri = URI(API_URL)
131
+ http = Net::HTTP.new(uri.host, uri.port)
132
+ http.use_ssl = true
133
+ http.read_timeout = 30
134
+
135
+ request = Net::HTTP::Post.new(uri.path)
136
+ request["Content-Type"] = "application/json"
137
+ request["x-api-key"] = api_key
138
+ request["anthropic-version"] = "2023-06-01"
139
+ request.body = JSON.generate({
140
+ model: MODEL,
141
+ max_tokens: 128,
142
+ system: SUGGEST_SYSTEM_PROMPT,
143
+ messages: [{role: "user", content: user_message}]
144
+ })
145
+
146
+ response = http.request(request)
147
+ body = JSON.parse(response.body)
148
+ raise "API Error #{response.code}: #{body["error"]&.dig("message")}" unless response.code == "200"
149
+
150
+ body.dig("content", 0, "text").to_s.strip.lines.first.to_s.strip
151
+ end
101
152
  end
102
153
  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.0"
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom SCHIAVI