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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 374b11ac3893bb113123d303f53df7cb2a613ec21e3aa89919e908d6b1e33225
4
- data.tar.gz: cbc1210aee6014e214044a026997b679f519fd03ae58ad3f06fba3b37de98fd4
3
+ metadata.gz: 488c3d31406ccddec198dfa1f48e26ae6fe88ac4830e4dc3152ed8378a718a07
4
+ data.tar.gz: ea034c42d3dac0e85ef205243ed9da7875f280b3c756e42bfa37afb8dbe99fd8
5
5
  SHA512:
6
- metadata.gz: 9ca7b307b7494da84e3279d56a32c7111098890dfe48b6c052811f4dd4d096c133bbe2fe7669211c6ce2d4d62b074b87f3f84f97ec8505eecddcc39ada2ed11c
7
- data.tar.gz: 66921d70a096260089119bf16f4bdc847bfba0cf67b23cf97e802455886f5fa875c9ad15158fc2c952708f462656bce63754383ceb4ec1585fcf0bb06004c07b
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 Claude.
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 Claude to group them by intent, and produces conventional commits (with gitmojis) ready to apply.
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 Claude Haiku 4.5 — fast, cheap, accurate.
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
- - An [Anthropic API key](https://console.anthropic.com/)
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
- Export your Anthropic API key (add to your `~/.zshrc` or `~/.bashrc`):
35
+ Authenticate OpenCode with GitHub Copilot:
36
36
 
37
37
  ```bash
38
- export ANTHROPIC_API_KEY=sk-ant-...
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. Claude proposes a grouping into logical commits.
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 "anthropic_api"
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 Claude #{AnthropicAPI::MODEL}...")
46
+ puts Color.dim(" Analyzing and grouping via OpenCode #{OpenCode::MODEL}...")
47
47
  puts
48
48
 
49
49
  begin
50
- result = AnthropicAPI.group_hunks(hunks, context: context)
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 "uri"
4
+ require "open3"
6
5
 
7
6
  module Hunkify
8
- module AnthropicAPI
9
- API_URL = "https://api.anthropic.com/v1/messages"
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
- - Prefer atomic and independent commits
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.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?
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
- 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
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 Claude.")}"
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 Claude for a suggestion... ")
68
+ print Color.dim(" Asking OpenCode for a suggestion... ")
69
69
  begin
70
- suggestion = AnthropicAPI.suggest_message(hunks, context: context)
70
+ suggestion = OpenCode.suggest_message(hunks, context: context)
71
71
  rescue => e
72
72
  puts Color.red("failed (#{e.message})")
73
73
  suggestion = nil
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hunkify
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
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/anthropic_api"
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.2.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 Claude to group them into logical
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 Claude.
54
+ summary: Split staged changes into atomic commits using GitHub Copilot.
55
55
  test_files: []