feat: rewrite gh-commit in Go with local Ollama backend

Replaces the Python/Crush/DuckDB implementation with a CGO-free Go binary:
cobra CLI, modernc.org/sqlite storage, huh/lipgloss UI, and speed-tuned raw
/api/generate calls (think:false, keep_alive, capped num_predict) against a
local qwen3.5:2b. Ships as a gh extension via cli/gh-extension-precompile.
This commit is contained in:
t
2026-08-15 19:10:16 -04:00
parent 735229b150
commit 9647c854c9
31 changed files with 2731 additions and 1085 deletions
+194
View File
@@ -0,0 +1,194 @@
// Package ai talks to a local Ollama server. The rozoomcool SDK is used for
// client construction and model pulls; generation goes through a raw
// /api/generate POST because the SDK's Generate cannot pass think, options,
// or keep_alive — the levers this tool depends on for speed.
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
ollama "github.com/rozoomcool/go-ollama-sdk"
"github.com/prdlk/gh-commit/internal/diff"
"github.com/prdlk/gh-commit/internal/ui"
)
// Generation profiles: small budget for one-line commit messages, larger
// budget and context for scope JSON over big file trees.
const (
commitNumPredict = 96
commitNumCtx = 8192
scopeNumPredict = 1024
scopeNumCtx = 16384
keepAlive = "10m"
temperature = 0.2
topP = 0.9
)
// Client is a speed-tuned Ollama client for one model.
type Client struct {
host string
model string
timeout time.Duration
http *http.Client
sdk *ollama.OllamaClient
}
// New builds a client for host/model with a per-request timeout.
func New(host, model string, timeout time.Duration) *Client {
host = strings.TrimRight(host, "/")
return &Client{
host: host,
model: model,
timeout: timeout,
http: &http.Client{Timeout: timeout},
sdk: ollama.NewClient(host),
}
}
// Model returns the configured model tag.
func (c *Client) Model() string { return c.model }
type generateOptions struct {
Temperature float64 `json:"temperature"`
TopP float64 `json:"top_p"`
NumPredict int `json:"num_predict"`
NumCtx int `json:"num_ctx"`
}
type generateRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Stream bool `json:"stream"`
Think bool `json:"think"`
KeepAlive string `json:"keep_alive"`
Options generateOptions `json:"options"`
}
type generateResponse struct {
Response string `json:"response"`
Error string `json:"error"`
}
// generate POSTs a raw /api/generate request with the speed profile applied.
func (c *Client) generate(prompt string, numPredict, numCtx int) (string, error) {
body, err := json.Marshal(generateRequest{
Model: c.model,
Prompt: prompt,
Stream: false,
Think: false,
KeepAlive: keepAlive,
Options: generateOptions{
Temperature: temperature,
TopP: topP,
NumPredict: numPredict,
NumCtx: numCtx,
},
})
if err != nil {
return "", err
}
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.host+"/api/generate", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return "", fmt.Errorf("ollama request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var out generateResponse
if err := json.Unmarshal(data, &out); err != nil {
return "", fmt.Errorf("ollama returned unexpected payload: %w", err)
}
if resp.StatusCode != http.StatusOK {
if out.Error != "" {
return "", fmt.Errorf("ollama: %s", out.Error)
}
return "", fmt.Errorf("ollama returned HTTP %d", resp.StatusCode)
}
// think:false is sent, but strip any reasoning block as defense in depth.
return stripThink(out.Response), nil
}
// CommitMessage filters diff, prompts the model, and returns a cleaned
// commit message ("" when the model produced nothing usable).
func (c *Client) CommitMessage(rawDiff, scope string) (string, error) {
prompt := buildCommitPrompt(diff.Filter(rawDiff), scope)
out, err := c.generate(prompt, commitNumPredict, commitNumCtx)
if err != nil {
return "", err
}
return CleanCommitMessage(out), nil
}
// ScopesRaw prompts the model for a scope mapping and returns the raw
// response text (callers parse it so they can show diagnostics on failure).
func (c *Client) ScopesRaw(filetree string, existing map[string][]string) (string, error) {
return c.generate(buildScopePrompt(filetree, existing), scopeNumPredict, scopeNumCtx)
}
type tagsResponse struct {
Models []struct {
Name string `json:"name"`
} `json:"models"`
}
// EnsureReady verifies the Ollama server is answering and the model is
// available locally, offering to pull it when missing.
func (c *Client) EnsureReady() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.host+"/api/tags", nil)
if err != nil {
return err
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("the Ollama server is not reachable at %s — start it with 'ollama serve'", c.host)
}
defer func() { _ = resp.Body.Close() }()
var tags tagsResponse
if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil {
return fmt.Errorf("the Ollama server at %s gave an unexpected response — start it with 'ollama serve'", c.host)
}
for _, m := range tags.Models {
if m.Name == c.model || (!strings.Contains(c.model, ":") && m.Name == c.model+":latest") {
return nil
}
}
ui.Warnf("Model %s is not available locally", c.model)
if !ui.Confirm(fmt.Sprintf("Pull %s now?", c.model)) {
return fmt.Errorf("model %s is not available — run 'ollama pull %s'", c.model, c.model)
}
last := ""
if err := c.sdk.PullModel(c.model, func(status string) {
if status != "" && status != last {
ui.Dimf(" %s", status)
last = status
}
}); err != nil {
return fmt.Errorf("pulling %s: %w", c.model, err)
}
ui.Successf("✓ Pulled %s", c.model)
return nil
}
+110
View File
@@ -0,0 +1,110 @@
package ai
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
var (
thinkRe = regexp.MustCompile(`(?s)<think>.*?</think>`)
fenceOpenRe = regexp.MustCompile("^```[a-zA-Z]*\n")
fenceCloseRe = regexp.MustCompile("\n```$")
scopedMsgRe = regexp.MustCompile(`^\w+\([^)]+\): .+`)
plainMsgRe = regexp.MustCompile(`^\w+: .+`)
)
// stripThink removes <think>...</think> reasoning blocks.
func stripThink(text string) string {
return strings.TrimSpace(thinkRe.ReplaceAllString(text, ""))
}
// ExtractJSONObject returns the first balanced top-level {...} object in text,
// or "" when none is found.
func ExtractJSONObject(text string) string {
depth := 0
start := -1
for i := range len(text) {
switch text[i] {
case '{':
if start == -1 {
start = i
}
depth++
case '}':
if start != -1 {
depth--
if depth == 0 {
return text[start : i+1]
}
}
}
}
return ""
}
// CoerceScopeMap normalizes a decoded JSON/TOML mapping into scope -> paths.
// String values become single-element slices; empty paths are dropped.
// Returns nil when nothing usable remains.
func CoerceScopeMap(data map[string]any) map[string][]string {
scopes := map[string][]string{}
for name, paths := range data {
switch v := paths.(type) {
case string:
scopes[name] = []string{v}
case []any:
var out []string
for _, p := range v {
s := fmt.Sprint(p)
if strings.TrimSpace(s) != "" {
out = append(out, s)
}
}
scopes[name] = out
}
}
if len(scopes) == 0 {
return nil
}
return scopes
}
// ParseScopesResponse extracts and normalizes the scope mapping from a model
// response. Returns nil when no usable JSON object is present.
func ParseScopesResponse(text string) map[string][]string {
blob := ExtractJSONObject(text)
if blob == "" {
return nil
}
var data map[string]any
if err := json.Unmarshal([]byte(blob), &data); err != nil {
return nil
}
return CoerceScopeMap(data)
}
// CleanCommitMessage normalizes a raw model response into a commit message:
// trims, strips <think> blocks and code fences, prefers the first non-empty
// line when it matches conventional-commit shape, and otherwise falls back to
// the whole trimmed string (never blocks a commit on a regex).
func CleanCommitMessage(raw string) string {
msg := stripThink(strings.TrimSpace(raw))
msg = fenceOpenRe.ReplaceAllString(msg, "")
msg = fenceCloseRe.ReplaceAllString(msg, "")
msg = strings.TrimSpace(msg)
if msg == "" {
return ""
}
for _, line := range strings.Split(msg, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if scopedMsgRe.MatchString(line) || plainMsgRe.MatchString(line) {
return line
}
break
}
return msg
}
+148
View File
@@ -0,0 +1,148 @@
package ai
import (
"reflect"
"strings"
"testing"
)
func TestExtractJSONObject(t *testing.T) {
tests := []struct {
name, in, want string
}{
{"bare object", `{"a": 1}`, `{"a": 1}`},
{"leading prose", `Here you go: {"a": {"b": 2}} done`, `{"a": {"b": 2}}`},
{"nested braces balanced", `{"a": {"b": {"c": 3}}}`, `{"a": {"b": {"c": 3}}}`},
{"first object wins", `{"a": 1} {"b": 2}`, `{"a": 1}`},
{"no object", "no json here", ""},
{"unbalanced", `{"a": 1`, ""},
{"stray close before open", `} {"a": 1}`, `{"a": 1}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ExtractJSONObject(tt.in); got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestParseScopesResponse(t *testing.T) {
tests := []struct {
name string
in string
want map[string][]string
}{
{
"array values",
`{"core": ["src/", "lib/"], "docs": ["README.md"]}`,
map[string][]string{"core": {"src/", "lib/"}, "docs": {"README.md"}},
},
{
"string value coerced to slice",
`{"cli": "cmd/"}`,
map[string][]string{"cli": {"cmd/"}},
},
{
"empty paths dropped",
`{"core": ["src/", "", " "]}`,
map[string][]string{"core": {"src/"}},
},
{
"surrounding prose ignored",
"Sure! Here are the scopes:\n{\"api\": [\"api/\"]}\nHope that helps.",
map[string][]string{"api": {"api/"}},
},
{"not an object", `["a", "b"]`, nil},
{"invalid json", `{"a": }`, nil},
{"empty object", `{}`, nil},
{"no json at all", "nothing", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ParseScopesResponse(tt.in); !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %#v, want %#v", got, tt.want)
}
})
}
}
func TestCleanCommitMessage(t *testing.T) {
tests := []struct {
name, in, want string
}{
{"clean scoped message", "feat(core): add thing", "feat(core): add thing"},
{"clean unscoped message", "chore: tidy", "chore: tidy"},
{"whitespace trimmed", " fix(db): close conn \n", "fix(db): close conn"},
{
"code fence stripped",
"```\nfeat(ui): add button\n```",
"feat(ui): add button",
},
{
"language fence stripped",
"```text\nfix(api): handle 404\n```",
"fix(api): handle 404",
},
{
"think block stripped",
"<think>\nLet me reason about this diff...\n</think>\nfeat(ai): wire ollama",
"feat(ai): wire ollama",
},
{
"rambling reduced to first valid line",
"feat(core): add parser\n\nThis commit introduces a parser.",
"feat(core): add parser",
},
{
"invalid message falls back to whole string",
"I could not produce a message",
"I could not produce a message",
},
{
"multiline invalid first line falls back to whole string",
"Here is the commit:\nnot conventional",
"Here is the commit:\nnot conventional",
},
{"empty input", "", ""},
{"only think block", "<think>hmm</think>", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := CleanCommitMessage(tt.in); got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestBuildCommitPrompt(t *testing.T) {
p := buildCommitPrompt("DIFF_BODY", "core")
for _, want := range []string{"Use this scope: core", "DIFF_BODY", "Do not think. Answer immediately."} {
if !strings.Contains(p, want) {
t.Errorf("prompt missing %q", want)
}
}
if p2 := buildCommitPrompt("DIFF_BODY", ""); strings.Contains(p2, "Use this scope") {
t.Error("scope hint should be absent without a scope")
}
}
func TestBuildScopePrompt(t *testing.T) {
p := buildScopePrompt("a.go\nb.go", nil)
for _, want := range []string{"a.go\nb.go", `{"scope": ["path", ...], ...}`, "Do not think. Answer immediately."} {
if !strings.Contains(p, want) {
t.Errorf("new prompt missing %q", want)
}
}
if strings.Contains(p, "Existing scopes") {
t.Error("new prompt must not mention existing scopes")
}
p = buildScopePrompt("a.go", map[string][]string{"core": {"src/"}})
for _, want := range []string{"Existing scopes (JSON):", `"core"`, `"src/"`} {
if !strings.Contains(p, want) {
t.Errorf("update prompt missing %q", want)
}
}
}
+84
View File
@@ -0,0 +1,84 @@
package ai
import (
"encoding/json"
"strings"
)
// Prompt text is ported unchanged from the Python tool, plus one
// "Do not think" line per prompt as cheap insurance on small models.
const commitPrompt = `You are an expert conventional commit message writer.
Use one of these commit types: feat, fix, docs, style, refactor, breaking, test, perf, build, ci, chore, init.
Determine a precise commit message from the provided diff and scope.
The commit message must follow this format: <type>(<scope>): <description>
Examples:
- fix(core): resolve consensus timeout during high load
- feat(did): add WebAuthn biometric authentication support
- refactor(hway): extract Redis connection pooling to shared utility
- init(browser): setup client package for web API-specific logic
- perf(vault): optimize IPFS chunk size for large file uploads
- ci(actions): update GitHub Actions pipeline for parallel module testing
- docs(dwn): clarify data retention policies in API reference
- test(svc): add integration tests for domain verification flow
- breaking(ui): rename Button prop 'type' to 'variant'
- refactor(sdk): migrate off wallet implementation in favor of @sonr.io/enclave
- feat(react): create Enclave stateful hooks
Never explain anything. Return only the commit message.
Do not think. Answer immediately.
{scope_hint}### Git Diff
{diff}
`
const scopeInstructions = `You are a repository scope identifier for Conventional Commits.
You receive a repository file tree, one repo-relative path per line.
Map logical project areas to the repo-relative path prefixes (directories or files) they cover.
Scope names must read well in Conventional Commits, e.g. core, api, ui, cli, docs, tests, ci, config, scripts, deps.
Each scope maps to a JSON array of repo-relative path prefixes.
Group related paths together and cover the meaningful source areas.
Never create scopes for generated, vendored, or build-output paths.
If existing scopes are provided, keep the ones that still apply, drop scopes whose paths no longer exist, and add scopes for new areas.
Never explain anything or print your thoughts. Never include Markdown code blocks.
Only return a single JSON object of the form {"scope": ["path", ...], ...}.
Do not think. Answer immediately.
`
const scopePromptNew = scopeInstructions + `
Repository file tree (one repo-relative path per line):
{filetree}
`
const scopePromptUpdate = scopeInstructions + `
Existing scopes (JSON):
{existing}
The repository structure may have changed (e.g. its .gitignore was edited).
Updated repository file tree (one repo-relative path per line):
{filetree}
`
// buildCommitPrompt fills the commit prompt with an optional scope hint and
// the (already filtered) diff.
func buildCommitPrompt(filteredDiff, scope string) string {
hint := ""
if scope != "" {
hint = "Use this scope: " + scope + "\n"
}
return strings.NewReplacer("{scope_hint}", hint, "{diff}", filteredDiff).Replace(commitPrompt)
}
// buildScopePrompt fills the scope prompt; existing may be nil for first-time
// generation.
func buildScopePrompt(filetree string, existing map[string][]string) string {
if len(existing) == 0 {
return strings.Replace(scopePromptNew, "{filetree}", filetree, 1)
}
blob, _ := json.MarshalIndent(existing, "", " ")
return strings.NewReplacer(
"{existing}", string(blob),
"{filetree}", filetree,
).Replace(scopePromptUpdate)
}