hunkify 0.2.0 → 0.3.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 +4 -4
- data/README.md +7 -7
- data/lib/hunkify/cli.rb +3 -3
- data/lib/hunkify/{anthropic_api.rb → opencode.rb} +44 -84
- data/lib/hunkify/ui.rb +3 -3
- data/lib/hunkify/version.rb +1 -1
- data/lib/hunkify.rb +1 -1
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 488c3d31406ccddec198dfa1f48e26ae6fe88ac4830e4dc3152ed8378a718a07
|
|
4
|
+
data.tar.gz: ea034c42d3dac0e85ef205243ed9da7875f280b3c756e42bfa37afb8dbe99fd8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 44222ddc4ec5685814d177030618a964555fd4562b77cd58bc1c3ea06fd5a989de48fb5fc31fc3b529c1357c28763a57d68908c07556612bbe830b6d9ff2475d
|
|
7
|
+
data.tar.gz: 26a34a3f74cacdaca9b4a2f0a75bbd201266bce7b05ab7bf27c77c615122cab30db0e3f90480738318638fc96aa5a034d72dcba2903c7f995bb16fa53b0dbeba
|
data/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# hunkify
|
|
2
2
|
|
|
3
|
-
Split your staged changes into atomic, well-scoped commits — powered by
|
|
3
|
+
Split your staged changes into atomic, well-scoped commits — powered by GitHub Copilot through OpenCode.
|
|
4
4
|
|
|
5
|
-
Instead of cramming everything into a single `git commit -m "wip"`, `hunkify` parses your staged diff hunk by hunk, asks
|
|
5
|
+
Instead of cramming everything into a single `git commit -m "wip"`, `hunkify` parses your staged diff hunk by hunk, asks OpenCode to group them by intent using GitHub Copilot, and produces conventional commits (with gitmojis) ready to apply.
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
9
|
- **Hunk-level analysis** — each change block is treated independently, so one file can span multiple commits if needed.
|
|
10
|
-
- **AI-powered grouping** via
|
|
10
|
+
- **AI-powered grouping** via GitHub Copilot and OpenCode.
|
|
11
11
|
- **Gitmoji + conventional commits** — `:sparkles: feat(scope): …`, `:bug: fix(scope): …`, etc.
|
|
12
12
|
- **Interactive review** — confirm, edit, or skip each proposed commit before it's created.
|
|
13
13
|
- **Free-form context argument** — pass a ticket ID, feature name, or any directive to steer the output.
|
|
@@ -16,7 +16,7 @@ Instead of cramming everything into a single `git commit -m "wip"`, `hunkify` pa
|
|
|
16
16
|
## Requirements
|
|
17
17
|
|
|
18
18
|
- Ruby ≥ 2.7
|
|
19
|
-
-
|
|
19
|
+
- [OpenCode](https://opencode.ai/) authenticated with GitHub Copilot
|
|
20
20
|
|
|
21
21
|
## Installation
|
|
22
22
|
|
|
@@ -32,10 +32,10 @@ gem "hunkify"
|
|
|
32
32
|
|
|
33
33
|
## Setup
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
Authenticate OpenCode with GitHub Copilot:
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
|
|
38
|
+
opencode providers login
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
## Usage
|
|
@@ -56,7 +56,7 @@ hunkify "focus on refactoring"
|
|
|
56
56
|
### Workflow
|
|
57
57
|
|
|
58
58
|
1. `hunkify` parses your staged diff into hunks.
|
|
59
|
-
2.
|
|
59
|
+
2. OpenCode asks GitHub Copilot to propose a grouping into logical commits.
|
|
60
60
|
3. For each proposed commit, you can **confirm**, **edit the message**, **skip**, or **quit**.
|
|
61
61
|
4. After a final confirmation, the plan is applied — each commit is created via `git apply --cached`.
|
|
62
62
|
|
data/lib/hunkify/cli.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require_relative "color"
|
|
4
4
|
require_relative "diff_parser"
|
|
5
5
|
require_relative "git"
|
|
6
|
-
require_relative "
|
|
6
|
+
require_relative "opencode"
|
|
7
7
|
require_relative "ui"
|
|
8
8
|
|
|
9
9
|
module Hunkify
|
|
@@ -43,11 +43,11 @@ module Hunkify
|
|
|
43
43
|
|
|
44
44
|
hunks_by_id = hunks.each_with_object({}) { |h, acc| acc[h.id] = h }
|
|
45
45
|
|
|
46
|
-
puts Color.dim(" Analyzing and grouping via
|
|
46
|
+
puts Color.dim(" Analyzing and grouping via OpenCode #{OpenCode::MODEL}...")
|
|
47
47
|
puts
|
|
48
48
|
|
|
49
49
|
begin
|
|
50
|
-
result =
|
|
50
|
+
result = OpenCode.group_hunks(hunks, context: context)
|
|
51
51
|
rescue JSON::ParserError
|
|
52
52
|
puts Color.red(" ✗ Invalid AI response (malformed JSON). Try again.")
|
|
53
53
|
exit 1
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "net/http"
|
|
4
3
|
require "json"
|
|
5
|
-
require "
|
|
4
|
+
require "open3"
|
|
6
5
|
|
|
7
6
|
module Hunkify
|
|
8
|
-
module
|
|
9
|
-
|
|
10
|
-
MODEL = "claude-haiku-4-5-20251001"
|
|
7
|
+
module OpenCode
|
|
8
|
+
MODEL = "github-copilot/gpt-5.6-terra"
|
|
11
9
|
|
|
12
10
|
SYSTEM_PROMPT = <<~PROMPT
|
|
13
11
|
You are a Git expert. You are given a list of hunks (blocks of modifications)
|
|
@@ -17,7 +15,20 @@ module Hunkify
|
|
|
17
15
|
- One commit = one unique intent (feat, fix, refactor, style, etc.)
|
|
18
16
|
- Hunks in different files CAN belong to the same commit if they serve the same intent
|
|
19
17
|
- Hunks in the SAME file can belong to DIFFERENT commits if they are semantically distinct
|
|
20
|
-
-
|
|
18
|
+
- STRONGLY prefer fine-grained, atomic commits over large bundled ones.
|
|
19
|
+
When in doubt, SPLIT rather than merge.
|
|
20
|
+
- Heuristics to split:
|
|
21
|
+
* Different modules/components/features -> different commits
|
|
22
|
+
* Core logic vs. tests -> different commits (one feat commit + one test commit)
|
|
23
|
+
* Core logic vs. docs -> different commits
|
|
24
|
+
* Core logic vs. config/build files -> different commits
|
|
25
|
+
* Unrelated fixes bundled with a feature -> separate them
|
|
26
|
+
* Each file introducing a new, independent capability usually deserves
|
|
27
|
+
its own commit
|
|
28
|
+
- Only bundle hunks together when they genuinely cannot be reviewed or
|
|
29
|
+
reverted independently.
|
|
30
|
+
- Err on the side of MORE commits. A PR with 8 small focused commits is
|
|
31
|
+
better than one with 3 large ones.
|
|
21
32
|
|
|
22
33
|
RESPONSE FORMAT (strict JSON, no surrounding text):
|
|
23
34
|
{
|
|
@@ -26,11 +37,6 @@ module Hunkify
|
|
|
26
37
|
"message": ":sparkles: feat(scope): description in English",
|
|
27
38
|
"hunk_ids": [1, 3, 5],
|
|
28
39
|
"reasoning": "brief explanation of the grouping"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"message": ":bug: fix(scope): description in English",
|
|
32
|
-
"hunk_ids": [2, 4],
|
|
33
|
-
"reasoning": "brief explanation"
|
|
34
40
|
}
|
|
35
41
|
]
|
|
36
42
|
}
|
|
@@ -52,53 +58,6 @@ module Hunkify
|
|
|
52
58
|
RESPOND ONLY WITH THE JSON. No markdown, no explanation.
|
|
53
59
|
PROMPT
|
|
54
60
|
|
|
55
|
-
def self.group_hunks(hunks, context: nil)
|
|
56
|
-
api_key = ENV["ANTHROPIC_API_KEY"]
|
|
57
|
-
raise "ANTHROPIC_API_KEY missing! Add it to your ~/.zshrc or ~/.bashrc" if api_key.nil? || api_key.empty?
|
|
58
|
-
|
|
59
|
-
user_ctx = context && !context.empty? ? "\nUser context: #{context}" : ""
|
|
60
|
-
hunks_summary = hunks.map(&:to_summary).join("\n\n---\n\n")
|
|
61
|
-
user_message = "#{user_ctx}\n\nHere are the hunks to group:\n\n#{hunks_summary}"
|
|
62
|
-
|
|
63
|
-
uri = URI(API_URL)
|
|
64
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
65
|
-
http.use_ssl = true
|
|
66
|
-
http.read_timeout = 30
|
|
67
|
-
|
|
68
|
-
request = Net::HTTP::Post.new(uri.path)
|
|
69
|
-
request["Content-Type"] = "application/json"
|
|
70
|
-
request["x-api-key"] = api_key
|
|
71
|
-
request["anthropic-version"] = "2023-06-01"
|
|
72
|
-
request.body = JSON.generate({
|
|
73
|
-
model: MODEL,
|
|
74
|
-
max_tokens: 1024,
|
|
75
|
-
system: SYSTEM_PROMPT,
|
|
76
|
-
messages: [{role: "user", content: user_message}]
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
response = http.request(request)
|
|
80
|
-
body = JSON.parse(response.body)
|
|
81
|
-
|
|
82
|
-
raise "API Error #{response.code}: #{body["error"]&.dig("message")}" unless response.code == "200"
|
|
83
|
-
|
|
84
|
-
raw = body.dig("content", 0, "text")&.strip
|
|
85
|
-
|
|
86
|
-
if ENV["HUNKIFY_DEBUG"]
|
|
87
|
-
warn "\n--- RAW AI RESPONSE ---\n#{raw}\n-----------------------\n"
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
cleaned = raw
|
|
91
|
-
.gsub(/\A```(?:json)?\s*/i, "")
|
|
92
|
-
.gsub(/\s*```\z/, "")
|
|
93
|
-
.strip
|
|
94
|
-
|
|
95
|
-
if (match = cleaned.match(/(\{.+\})/m))
|
|
96
|
-
cleaned = match[1]
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
JSON.parse(cleaned)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
61
|
SUGGEST_SYSTEM_PROMPT = <<~PROMPT
|
|
103
62
|
You are a Git expert. You are given one or more hunks that the user wants
|
|
104
63
|
to bundle into a single commit. Produce ONE conventional commit message
|
|
@@ -119,35 +78,36 @@ module Hunkify
|
|
|
119
78
|
RESPOND ONLY WITH THE MESSAGE. No markdown, no quotes, no explanation.
|
|
120
79
|
PROMPT
|
|
121
80
|
|
|
122
|
-
def self.
|
|
123
|
-
|
|
124
|
-
|
|
81
|
+
def self.group_hunks(hunks, context: nil)
|
|
82
|
+
user_ctx = context && !context.empty? ? "\nUser context: #{context}" : ""
|
|
83
|
+
summary = hunks.map(&:to_summary).join("\n\n---\n\n")
|
|
84
|
+
raw = ask(SYSTEM_PROMPT, "#{user_ctx}\n\nHere are the hunks to group:\n\n#{summary}")
|
|
85
|
+
cleaned = raw.gsub(/\A```(?:json)?\s*/i, "").gsub(/\s*```\z/, "").strip
|
|
86
|
+
cleaned = Regexp.last_match(1) if cleaned.match(/(\{.+\})/m)
|
|
87
|
+
JSON.parse(cleaned)
|
|
88
|
+
end
|
|
125
89
|
|
|
90
|
+
def self.suggest_message(hunks, context: nil)
|
|
126
91
|
user_ctx = context && !context.empty? ? "\nUser context: #{context}" : ""
|
|
127
92
|
summary = hunks.map(&:to_summary).join("\n\n---\n\n")
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
93
|
+
ask(SUGGEST_SYSTEM_PROMPT, "#{user_ctx}\n\nHunks to bundle into a single commit:\n\n#{summary}").lines.first.to_s.strip
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.ask(system_prompt, user_message)
|
|
97
|
+
output, status = Open3.capture2e("opencode", "run", "--model", MODEL, "--format", "json", "#{system_prompt}\n\n#{user_message}")
|
|
98
|
+
raise "OpenCode failed: #{output.strip}" unless status.success?
|
|
99
|
+
|
|
100
|
+
raw = output.lines.filter_map do |line|
|
|
101
|
+
event = JSON.parse(line)
|
|
102
|
+
event.dig("part", "text") if event["type"] == "text"
|
|
103
|
+
rescue JSON::ParserError
|
|
104
|
+
nil
|
|
105
|
+
end.join.strip
|
|
106
|
+
|
|
107
|
+
raise "OpenCode returned no text response" if raw.empty?
|
|
108
|
+
|
|
109
|
+
warn "\n--- RAW AI RESPONSE ---\n#{raw}\n-----------------------\n" if ENV["HUNKIFY_DEBUG"]
|
|
110
|
+
raw
|
|
151
111
|
end
|
|
152
112
|
end
|
|
153
113
|
end
|
data/lib/hunkify/ui.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Hunkify
|
|
|
12
12
|
puts
|
|
13
13
|
puts " #{Color.magenta("▲")} #{Color.bold("hunkify")} #{Color.dim("v#{version}")}"
|
|
14
14
|
puts " #{Color.dim("─" * 40)}"
|
|
15
|
-
puts " #{Color.dim("Atomic commits, grouped by
|
|
15
|
+
puts " #{Color.dim("Atomic commits, grouped by GitHub Copilot.")}"
|
|
16
16
|
puts
|
|
17
17
|
end
|
|
18
18
|
|
|
@@ -65,9 +65,9 @@ module Hunkify
|
|
|
65
65
|
|
|
66
66
|
def prompt_new_message(hunk_ids, hunks_by_id, context:)
|
|
67
67
|
hunks = hunk_ids.map { |id| hunks_by_id[id] }.compact
|
|
68
|
-
print Color.dim(" Asking
|
|
68
|
+
print Color.dim(" Asking OpenCode for a suggestion... ")
|
|
69
69
|
begin
|
|
70
|
-
suggestion =
|
|
70
|
+
suggestion = OpenCode.suggest_message(hunks, context: context)
|
|
71
71
|
rescue => e
|
|
72
72
|
puts Color.red("failed (#{e.message})")
|
|
73
73
|
suggestion = nil
|
data/lib/hunkify/version.rb
CHANGED
data/lib/hunkify.rb
CHANGED
|
@@ -5,7 +5,7 @@ require_relative "hunkify/color"
|
|
|
5
5
|
require_relative "hunkify/hunk"
|
|
6
6
|
require_relative "hunkify/diff_parser"
|
|
7
7
|
require_relative "hunkify/git"
|
|
8
|
-
require_relative "hunkify/
|
|
8
|
+
require_relative "hunkify/opencode"
|
|
9
9
|
require_relative "hunkify/ui"
|
|
10
10
|
require_relative "hunkify/cli"
|
|
11
11
|
|
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.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tom SCHIAVI
|
|
@@ -9,8 +9,8 @@ bindir: bin
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies: []
|
|
12
|
-
description: hunkify analyzes staged hunks, asks
|
|
13
|
-
commits, and applies them via git apply --cached.
|
|
12
|
+
description: hunkify analyzes staged hunks, asks OpenCode with GitHub Copilot to group
|
|
13
|
+
them into logical commits, and applies them via git apply --cached.
|
|
14
14
|
executables:
|
|
15
15
|
- hunkify
|
|
16
16
|
extensions: []
|
|
@@ -20,12 +20,12 @@ files:
|
|
|
20
20
|
- README.md
|
|
21
21
|
- bin/hunkify
|
|
22
22
|
- lib/hunkify.rb
|
|
23
|
-
- lib/hunkify/anthropic_api.rb
|
|
24
23
|
- lib/hunkify/cli.rb
|
|
25
24
|
- lib/hunkify/color.rb
|
|
26
25
|
- lib/hunkify/diff_parser.rb
|
|
27
26
|
- lib/hunkify/git.rb
|
|
28
27
|
- lib/hunkify/hunk.rb
|
|
28
|
+
- lib/hunkify/opencode.rb
|
|
29
29
|
- lib/hunkify/ui.rb
|
|
30
30
|
- lib/hunkify/version.rb
|
|
31
31
|
homepage: https://github.com/tomschiavi/smartcommit
|
|
@@ -51,5 +51,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
51
51
|
requirements: []
|
|
52
52
|
rubygems_version: 4.0.8
|
|
53
53
|
specification_version: 4
|
|
54
|
-
summary: Split staged changes into atomic commits using
|
|
54
|
+
summary: Split staged changes into atomic commits using GitHub Copilot.
|
|
55
55
|
test_files: []
|