mirror of
https://github.com/prdlk/gh-commit.git
synced 2026-09-17 15:36: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,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
|
||||
}
|
||||
Reference in New Issue
Block a user