mirror of
https://github.com/prdlk/gh-commit.git
synced 2026-09-16 23:16:25 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user