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)
}
+246
View File
@@ -0,0 +1,246 @@
// Package db persists per-repository scopes in a pure-Go SQLite database
// (modernc.org/sqlite, no CGO). The schema mirrors the old DuckDB layout,
// with foreign keys + ON DELETE CASCADE replacing the manual cascade.
package db
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"sort"
_ "modernc.org/sqlite"
)
const schema = `
CREATE TABLE IF NOT EXISTS repositories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
gitignore_hash TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS scopes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(repo_id, name)
);
CREATE TABLE IF NOT EXISTS scope_paths (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope_id INTEGER NOT NULL REFERENCES scopes(id) ON DELETE CASCADE,
path TEXT NOT NULL,
UNIQUE(scope_id, path)
);
CREATE TABLE IF NOT EXISTS github_labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope_id INTEGER NOT NULL UNIQUE REFERENCES scopes(id) ON DELETE CASCADE,
label_name TEXT NOT NULL,
color TEXT,
synced_at TIMESTAMP
);
`
// Dir returns the data directory: ${XDG_DATA_HOME:-~/.local/share}/gh-commit.
func Dir() string {
base := os.Getenv("XDG_DATA_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
base = filepath.Join(home, ".local", "share")
}
return filepath.Join(base, "gh-commit")
}
// Path returns the SQLite database path.
func Path() string { return filepath.Join(Dir(), "gh-commit.sqlite") }
// RepoInfo is one row of the `list` table.
type RepoInfo struct {
Name string
Path string
ScopeCount int
}
// Store wraps the SQLite connection.
type Store struct {
db *sql.DB
// HadLegacyDuckDB is true when this Open created the database for the
// first time while an old DuckDB file was present next to it.
HadLegacyDuckDB bool
}
// Open creates the data directory, opens the database, and applies the schema.
func Open() (*Store, error) {
dir := Dir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("creating %s: %w", dir, err)
}
path := Path()
legacy := false
if _, err := os.Stat(path); os.IsNotExist(err) {
if _, err := os.Stat(filepath.Join(dir, "gh-commit.db")); err == nil {
legacy = true
}
}
dsn := "file:" + path + "?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)"
conn, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("opening %s: %w", path, err)
}
if _, err := conn.Exec(schema); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("initializing schema: %w", err)
}
return &Store{db: conn, HadLegacyDuckDB: legacy}, nil
}
// Close closes the underlying connection.
func (s *Store) Close() error { return s.db.Close() }
// HasScopes reports whether repoPath has any stored scopes.
func (s *Store) HasScopes(repoPath string) (bool, error) {
var n int
err := s.db.QueryRow(`
SELECT COUNT(*) FROM scopes s
JOIN repositories r ON s.repo_id = r.id
WHERE r.path = ?`, repoPath).Scan(&n)
return n > 0, err
}
// Scopes returns repoPath's scopes as name -> ordered path prefixes.
func (s *Store) Scopes(repoPath string) (map[string][]string, error) {
rows, err := s.db.Query(`
SELECT s.name, sp.path
FROM scopes s
JOIN repositories r ON s.repo_id = r.id
JOIN scope_paths sp ON sp.scope_id = s.id
WHERE r.path = ?
ORDER BY s.name, sp.path`, repoPath)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
scopes := map[string][]string{}
for rows.Next() {
var name, path string
if err := rows.Scan(&name, &path); err != nil {
return nil, err
}
scopes[name] = append(scopes[name], path)
}
return scopes, rows.Err()
}
// SaveScopes replaces repoPath's scopes and snapshots gitignoreHash.
func (s *Store) SaveScopes(repoPath, repoName string, scopes map[string][]string, gitignoreHash string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck // no-op after commit
var repoID int64
err = tx.QueryRow(`SELECT id FROM repositories WHERE path = ?`, repoPath).Scan(&repoID)
switch err {
case nil:
// ON DELETE CASCADE removes scope_paths and github_labels.
if _, err := tx.Exec(`DELETE FROM scopes WHERE repo_id = ?`, repoID); err != nil {
return err
}
case sql.ErrNoRows:
res, err := tx.Exec(`INSERT INTO repositories (path, name) VALUES (?, ?)`, repoPath, repoName)
if err != nil {
return err
}
if repoID, err = res.LastInsertId(); err != nil {
return err
}
default:
return err
}
names := make([]string, 0, len(scopes))
for name := range scopes {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
res, err := tx.Exec(`INSERT INTO scopes (repo_id, name) VALUES (?, ?)`, repoID, name)
if err != nil {
return err
}
scopeID, err := res.LastInsertId()
if err != nil {
return err
}
for _, path := range scopes[name] {
if _, err := tx.Exec(
`INSERT OR IGNORE INTO scope_paths (scope_id, path) VALUES (?, ?)`,
scopeID, path,
); err != nil {
return err
}
}
}
if _, err := tx.Exec(
`UPDATE repositories SET updated_at = CURRENT_TIMESTAMP, gitignore_hash = ? WHERE id = ?`,
gitignoreHash, repoID,
); err != nil {
return err
}
return tx.Commit()
}
// StoredGitignoreHash returns the snapshotted hash for repoPath. present is
// false when the repo is unknown or the hash was never recorded.
func (s *Store) StoredGitignoreHash(repoPath string) (hash string, present bool, err error) {
var v sql.NullString
err = s.db.QueryRow(`SELECT gitignore_hash FROM repositories WHERE path = ?`, repoPath).Scan(&v)
if err == sql.ErrNoRows {
return "", false, nil
}
if err != nil {
return "", false, err
}
return v.String, v.Valid, nil
}
// ListRepos returns all repositories, most recently updated first.
func (s *Store) ListRepos() ([]RepoInfo, error) {
rows, err := s.db.Query(`
SELECT r.name, r.path, COUNT(DISTINCT s.id) AS scope_count
FROM repositories r
LEFT JOIN scopes s ON s.repo_id = r.id
GROUP BY r.id, r.name, r.path, r.updated_at
ORDER BY r.updated_at DESC`)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var repos []RepoInfo
for rows.Next() {
var r RepoInfo
if err := rows.Scan(&r.Name, &r.Path, &r.ScopeCount); err != nil {
return nil, err
}
repos = append(repos, r)
}
return repos, rows.Err()
}
// DeleteRepo removes repoPath and, via cascade, all of its children.
func (s *Store) DeleteRepo(repoPath string) error {
_, err := s.db.Exec(`DELETE FROM repositories WHERE path = ?`, repoPath)
return err
}
+103
View File
@@ -0,0 +1,103 @@
package db
import (
"reflect"
"testing"
)
func openTestStore(t *testing.T) *Store {
t.Helper()
t.Setenv("XDG_DATA_HOME", t.TempDir())
s, err := Open()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func TestSaveAndReadScopes(t *testing.T) {
s := openTestStore(t)
scopes := map[string][]string{
"core": {"internal/", "main.go"},
"docs": {"README.md"},
}
if has, _ := s.HasScopes("/repo"); has {
t.Fatal("fresh store should have no scopes")
}
if err := s.SaveScopes("/repo", "repo", scopes, "hash1"); err != nil {
t.Fatal(err)
}
if has, _ := s.HasScopes("/repo"); !has {
t.Fatal("scopes should exist after save")
}
got, err := s.Scopes("/repo")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, scopes) {
t.Errorf("got %#v, want %#v", got, scopes)
}
hash, present, err := s.StoredGitignoreHash("/repo")
if err != nil || !present || hash != "hash1" {
t.Errorf("want (hash1, true), got (%q, %v, %v)", hash, present, err)
}
}
func TestSaveScopesReplacesExisting(t *testing.T) {
s := openTestStore(t)
if err := s.SaveScopes("/repo", "repo", map[string][]string{"old": {"a/"}}, "h1"); err != nil {
t.Fatal(err)
}
if err := s.SaveScopes("/repo", "repo", map[string][]string{"new": {"b/"}}, "h2"); err != nil {
t.Fatal(err)
}
got, _ := s.Scopes("/repo")
want := map[string][]string{"new": {"b/"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("old scopes must be cascade-deleted; got %#v", got)
}
if hash, _, _ := s.StoredGitignoreHash("/repo"); hash != "h2" {
t.Errorf("hash not updated: %q", hash)
}
}
func TestDeleteRepoCascades(t *testing.T) {
s := openTestStore(t)
if err := s.SaveScopes("/repo", "repo", map[string][]string{"core": {"a/"}}, "h"); err != nil {
t.Fatal(err)
}
if err := s.DeleteRepo("/repo"); err != nil {
t.Fatal(err)
}
if has, _ := s.HasScopes("/repo"); has {
t.Error("scopes must be gone after repo delete")
}
var n int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM scope_paths`).Scan(&n); err != nil || n != 0 {
t.Errorf("scope_paths rows must cascade away, got %d (%v)", n, err)
}
}
func TestStoredGitignoreHashUnknownRepo(t *testing.T) {
s := openTestStore(t)
if _, present, err := s.StoredGitignoreHash("/nowhere"); err != nil || present {
t.Errorf("unknown repo: want (false, nil), got (%v, %v)", present, err)
}
}
func TestListRepos(t *testing.T) {
s := openTestStore(t)
if err := s.SaveScopes("/a", "a", map[string][]string{"x": {"x/"}, "y": {"y/"}}, ""); err != nil {
t.Fatal(err)
}
repos, err := s.ListRepos()
if err != nil {
t.Fatal(err)
}
if len(repos) != 1 || repos[0].Name != "a" || repos[0].ScopeCount != 2 {
t.Errorf("got %#v", repos)
}
}
+101
View File
@@ -0,0 +1,101 @@
// Package diff filters git diffs before they reach the model: lock/generated
// files lose their hunk bodies, JSON changes are capped, and every other file
// is truncated. Behavior is a verbatim port of the Python filter_diff.
package diff
import (
"fmt"
"regexp"
"strings"
)
var lockPattern = regexp.MustCompile(
`(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb|` +
`go\.sum|go\.mod|Cargo\.lock|poetry\.lock|composer\.lock|Gemfile\.lock|` +
`.*\.min\.(js|css)|.*\.bundle\.js|.*\.map|` +
`dist/.*|build/.*|\.next/.*|node_modules/.*|vendor/.*|__pycache__/.*|\.pyc$|target/.*)`,
)
var (
fileNameRe = regexp.MustCompile(`b/([^ ]+)`)
headerRe = regexp.MustCompile(`^(index|---|\+\+\+|@@)`)
)
const (
// MaxDiffLines caps the per-file line count for regular files.
MaxDiffLines = 200
// MaxJSONLines caps the +/- line count for JSON files.
MaxJSONLines = 50
)
// Filter rewrites a raw git diff for prompt consumption.
func Filter(diff string) string {
var lines []string
inFiltered, inJSON := false, false
lineCount, jsonCount := 0, 0
for _, line := range strings.Split(diff, "\n") {
if strings.HasPrefix(line, "diff --git") {
lineCount, jsonCount = 0, 0
filename := ""
if m := fileNameRe.FindStringSubmatch(line); m != nil {
filename = m[1]
}
switch {
case lockPattern.MatchString(filename):
inFiltered, inJSON = true, false
lines = append(lines, line)
continue
case strings.HasSuffix(filename, ".json"):
inFiltered, inJSON = false, true
lines = append(lines, line)
continue
default:
inFiltered, inJSON = false, false
}
}
// Lock/generated files: keep headers, replace each hunk body with a
// marker. (The Python original stopped filtering after the first @@,
// leaking hunk bodies; the spec requires bodies replaced, so filtering
// holds until the next "diff --git".)
if inFiltered {
if headerRe.MatchString(line) {
lines = append(lines, line)
if strings.HasPrefix(line, "@@") {
lines = append(lines, "[Generated/lock file - content filtered]")
}
}
continue
}
if inJSON {
if headerRe.MatchString(line) {
lines = append(lines, line)
continue
}
if strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-") {
jsonCount++
if jsonCount <= MaxJSONLines {
lines = append(lines, line)
} else if jsonCount == MaxJSONLines+1 {
lines = append(lines, fmt.Sprintf("[... JSON truncated after %d lines ...]", MaxJSONLines))
}
} else {
lines = append(lines, line)
}
continue
}
if lineCount < MaxDiffLines {
lines = append(lines, line)
lineCount++
} else if lineCount == MaxDiffLines {
lines = append(lines, fmt.Sprintf("[... truncated after %d lines ...]", MaxDiffLines))
lineCount++
}
}
return strings.Join(lines, "\n")
}
+127
View File
@@ -0,0 +1,127 @@
package diff
import (
"fmt"
"strings"
"testing"
)
func diffHeader(file string) string {
return fmt.Sprintf("diff --git a/%s b/%s\nindex 111..222 100644\n--- a/%s\n+++ b/%s\n@@ -1,2 +1,2 @@",
file, file, file, file)
}
func TestFilter(t *testing.T) {
bigBody := make([]string, 0, 300)
for i := range 300 {
bigBody = append(bigBody, fmt.Sprintf("+line %d", i))
}
jsonBody := make([]string, 0, 80)
for i := range 80 {
jsonBody = append(jsonBody, fmt.Sprintf("+ \"key%d\": %d,", i, i))
}
tests := []struct {
name string
input string
wantContain []string
wantAbsent []string
}{
{
name: "small file passes through unchanged",
input: diffHeader("main.go") + "\n-old\n+new",
wantContain: []string{"-old", "+new"},
wantAbsent: []string{"truncated", "filtered"},
},
{
name: "lock file body replaced with marker",
input: diffHeader("package-lock.json") + "\n-\"old\": 1\n+\"new\": 2",
wantContain: []string{
"diff --git a/package-lock.json b/package-lock.json",
"@@ -1,2 +1,2 @@",
"[Generated/lock file - content filtered]",
},
wantAbsent: []string{`"old": 1`, `"new": 2`},
},
{
name: "go.sum matched by lock pattern",
input: diffHeader("go.sum") + "\n+github.com/x v1.0.0 h1:abc",
wantContain: []string{"[Generated/lock file - content filtered]"},
wantAbsent: []string{"h1:abc"},
},
{
name: "path prefix matched anywhere in name",
input: diffHeader("node_modules/foo.js") + "\n+secret",
wantContain: []string{"[Generated/lock file - content filtered]"},
wantAbsent: []string{"+secret"},
},
{
name: "json file capped at 50 +/- lines",
input: diffHeader("config.json") + "\n" + strings.Join(jsonBody, "\n"),
wantContain: []string{
`+ "key49": 49,`,
"[... JSON truncated after 50 lines ...]",
},
wantAbsent: []string{`"key50"`, `"key79"`},
},
{
name: "json context lines kept beyond the cap",
input: diffHeader("config.json") + "\n" + strings.Join(jsonBody, "\n") + "\n context line",
wantContain: []string{" context line"},
},
{
name: "regular file truncated at 200 lines",
input: diffHeader("big.go") + "\n" + strings.Join(bigBody, "\n"),
wantContain: []string{
"[... truncated after 200 lines ...]",
},
wantAbsent: []string{"+line 299"},
},
{
name: "counters reset per file",
input: diffHeader("big.go") + "\n" + strings.Join(bigBody, "\n") + "\n" +
diffHeader("small.go") + "\n+after",
wantContain: []string{"+after"},
},
{
name: "lock file followed by normal file",
input: diffHeader("yarn.lock") + "\n+lockline\n" +
diffHeader("app.ts") + "\n+visible",
wantContain: []string{"[Generated/lock file - content filtered]", "+visible"},
wantAbsent: []string{"+lockline"},
},
{
name: "empty diff stays empty",
input: "",
wantContain: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Filter(tt.input)
for _, want := range tt.wantContain {
if !strings.Contains(got, want) {
t.Errorf("output missing %q\noutput:\n%s", want, got)
}
}
for _, absent := range tt.wantAbsent {
if strings.Contains(got, absent) {
t.Errorf("output should not contain %q\noutput:\n%s", absent, got)
}
}
})
}
}
func TestFilterTruncationMarkerOnlyOnce(t *testing.T) {
var body []string
for i := range 400 {
body = append(body, fmt.Sprintf("+l%d", i))
}
got := Filter(diffHeader("big.go") + "\n" + strings.Join(body, "\n"))
if n := strings.Count(got, "[... truncated after 200 lines ...]"); n != 1 {
t.Errorf("want exactly 1 truncation marker, got %d", n)
}
}
+142
View File
@@ -0,0 +1,142 @@
// Package gitx wraps the git CLI. Read helpers swallow errors and return ""
// (mirroring the Python tool); mutating helpers pass git output through and
// return errors.
package gitx
import (
"crypto/sha256"
"encoding/hex"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
// Git runs git with args and returns trimmed stdout, or "" on any failure.
func Git(args ...string) string {
out, err := exec.Command("git", args...).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// passthrough runs git with args, streaming output to the terminal.
func passthrough(args ...string) error {
cmd := exec.Command("git", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// IsRepo reports whether the working directory is inside a git work tree.
func IsRepo() bool {
return exec.Command("git", "rev-parse", "--is-inside-work-tree").Run() == nil
}
// RepoRoot returns the repository top-level path, or "" outside a repo.
func RepoRoot() string { return Git("rev-parse", "--show-toplevel") }
// Lines splits git output into non-empty lines.
func Lines(out string) []string {
var lines []string
for _, l := range strings.Split(out, "\n") {
if l != "" {
lines = append(lines, l)
}
}
return lines
}
// ChangedFiles returns staged + unstaged + untracked files, deduplicated and sorted.
func ChangedFiles() []string {
seen := map[string]struct{}{}
for _, out := range []string{
Git("diff", "--cached", "--name-only"),
Git("diff", "--name-only"),
Git("ls-files", "--others", "--exclude-standard"),
} {
for _, f := range Lines(out) {
seen[f] = struct{}{}
}
}
files := make([]string, 0, len(seen))
for f := range seen {
files = append(files, f)
}
sort.Strings(files)
return files
}
// FilesInScope returns the files whose path starts with any of the scope prefixes.
func FilesInScope(files, prefixes []string) []string {
var matched []string
for _, f := range files {
for _, p := range prefixes {
if strings.HasPrefix(f, p) {
matched = append(matched, f)
break
}
}
}
return matched
}
// StageFiles stages files with git add.
func StageFiles(files []string) error {
if len(files) == 0 {
return nil
}
return passthrough(append([]string{"add"}, files...)...)
}
// ResetStaging unstages everything (git reset HEAD -- .), ignoring errors.
func ResetStaging() {
_ = exec.Command("git", "reset", "HEAD", "--", ".").Run()
}
// Commit commits the staging area with message.
func Commit(message string) error { return passthrough("commit", "-m", message) }
// UnpushedCommits returns oneline entries for commits not on any remote.
func UnpushedCommits() []string {
return Lines(Git("log", "--branches", "--not", "--remotes", "--oneline"))
}
// CurrentBranch returns the checked-out branch name.
func CurrentBranch() string { return Git("branch", "--show-current") }
// Push pushes branch to origin.
func Push(branch string) error { return passthrough("push", "origin", branch) }
// Filetree returns a sorted, deduplicated repo-relative file listing
// (tracked + non-ignored untracked), one path per line.
func Filetree() string {
seen := map[string]struct{}{}
for _, out := range []string{
Git("ls-files"),
Git("ls-files", "--others", "--exclude-standard"),
} {
for _, f := range Lines(out) {
seen[f] = struct{}{}
}
}
files := make([]string, 0, len(seen))
for f := range seen {
files = append(files, f)
}
sort.Strings(files)
return strings.Join(files, "\n")
}
// GitignoreHash returns the SHA-256 hex digest of the repo's .gitignore,
// or "" when there is none.
func GitignoreHash(repoRoot string) string {
data, err := os.ReadFile(filepath.Join(repoRoot, ".gitignore"))
if err != nil {
return ""
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
+74
View File
@@ -0,0 +1,74 @@
package gitx
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestFilesInScope(t *testing.T) {
files := []string{
"cmd/root.go",
"cmd/list.go",
"internal/db/store.go",
"README.md",
"cmdlets/x.go",
}
tests := []struct {
name string
prefixes []string
want []string
}{
{"directory prefix", []string{"cmd/"}, []string{"cmd/root.go", "cmd/list.go"}},
{"bare prefix matches sibling dirs too", []string{"cmd"}, []string{"cmd/root.go", "cmd/list.go", "cmdlets/x.go"}},
{"exact file", []string{"README.md"}, []string{"README.md"}},
{"multiple prefixes no duplicates", []string{"cmd/", "cmd/root"}, []string{"cmd/root.go", "cmd/list.go"}},
{"no match", []string{"docs/"}, nil},
{"empty prefixes", nil, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := FilesInScope(files, tt.prefixes); !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}
func TestGitignoreHash(t *testing.T) {
dir := t.TempDir()
if got := GitignoreHash(dir); got != "" {
t.Errorf("missing .gitignore should hash to empty string, got %q", got)
}
path := filepath.Join(dir, ".gitignore")
if err := os.WriteFile(path, []byte("node_modules/\n"), 0o644); err != nil {
t.Fatal(err)
}
// sha256 of "node_modules/\n"
first := GitignoreHash(dir)
if len(first) != 64 {
t.Errorf("want 64-char hex digest, got %q", first)
}
if again := GitignoreHash(dir); again != first {
t.Error("hash must be deterministic")
}
if err := os.WriteFile(path, []byte("dist/\n"), 0o644); err != nil {
t.Fatal(err)
}
if changed := GitignoreHash(dir); changed == first {
t.Error("hash must change when content changes")
}
}
func TestLines(t *testing.T) {
if got := Lines("a\n\nb\n"); !reflect.DeepEqual(got, []string{"a", "b"}) {
t.Errorf("got %v", got)
}
if got := Lines(""); got != nil {
t.Errorf("empty output should yield nil, got %v", got)
}
}
+113
View File
@@ -0,0 +1,113 @@
// Package ui centralizes lipgloss styling, huh confirmations, and spinners so
// every command shares the same visual language as the original Python tool.
package ui
import (
"fmt"
"os"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/huh/spinner"
"github.com/charmbracelet/lipgloss"
"golang.org/x/term"
)
// AutoConfirm short-circuits every confirmation prompt (GH_COMMIT_AUTO / --auto).
var AutoConfirm bool
var (
red = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
green = lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
greenBold = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true)
yellow = lipgloss.NewStyle().Foreground(lipgloss.Color("3"))
yellowBold = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Bold(true)
magenta = lipgloss.NewStyle().Foreground(lipgloss.Color("5"))
magentaBold = lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Bold(true)
cyan = lipgloss.NewStyle().Foreground(lipgloss.Color("6"))
dim = lipgloss.NewStyle().Faint(true)
bold = lipgloss.NewStyle().Bold(true)
panelStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("5")).
Padding(0, 1)
)
// Println prints a blank line.
func Println() { fmt.Println() }
// Printf prints unstyled text with a trailing newline.
func Printf(format string, a ...any) { fmt.Printf(format+"\n", a...) }
// Errorf prints red text.
func Errorf(format string, a ...any) { fmt.Println(red.Render(fmt.Sprintf(format, a...))) }
// Successf prints green text.
func Successf(format string, a ...any) { fmt.Println(green.Render(fmt.Sprintf(format, a...))) }
// SuccessBoldf prints bold green text.
func SuccessBoldf(format string, a ...any) {
fmt.Println(greenBold.Render(fmt.Sprintf(format, a...)))
}
// Warnf prints yellow text.
func Warnf(format string, a ...any) { fmt.Println(yellow.Render(fmt.Sprintf(format, a...))) }
// WarnBoldf prints bold yellow text.
func WarnBoldf(format string, a ...any) { fmt.Println(yellowBold.Render(fmt.Sprintf(format, a...))) }
// Infof prints cyan text.
func Infof(format string, a ...any) { fmt.Println(cyan.Render(fmt.Sprintf(format, a...))) }
// Magentaf prints magenta text.
func Magentaf(format string, a ...any) { fmt.Println(magenta.Render(fmt.Sprintf(format, a...))) }
// MagentaBoldf prints bold magenta text.
func MagentaBoldf(format string, a ...any) {
fmt.Println(magentaBold.Render(fmt.Sprintf(format, a...)))
}
// Dimf prints faint text.
func Dimf(format string, a ...any) { fmt.Println(dim.Render(fmt.Sprintf(format, a...))) }
// Bold returns s rendered bold, for inline composition.
func Bold(s string) string { return bold.Render(s) }
// Panel prints text inside a magenta rounded-border panel.
func Panel(text string) { fmt.Println(panelStyle.Render(text)) }
// Cyan returns s rendered cyan, for inline composition.
func Cyan(s string) string { return cyan.Render(s) }
// Green returns s rendered green, for inline composition.
func Green(s string) string { return green.Render(s) }
// Yellow returns s rendered yellow, for inline composition.
func Yellow(s string) string { return yellow.Render(s) }
// Red returns s rendered red, for inline composition.
func Red(s string) string { return red.Render(s) }
// Confirm asks a yes/no question, defaulting to yes. AutoConfirm returns true
// immediately; a prompt failure (e.g. no TTY, Ctrl-C) counts as no.
func Confirm(msg string) bool {
if AutoConfirm {
return true
}
v := true
if err := huh.NewConfirm().Title(msg).Value(&v).Run(); err != nil {
return false
}
return v
}
// Spin runs fn behind a spinner titled title. Without a TTY (or in auto mode)
// it prints the title once and runs fn directly.
func Spin(title string, fn func()) {
if AutoConfirm || !term.IsTerminal(int(os.Stdout.Fd())) || !term.IsTerminal(int(os.Stdin.Fd())) {
Dimf("%s", title)
fn()
return
}
_ = spinner.New().Title(title).Action(fn).Run()
}