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
+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)
}
}