mirror of
https://github.com/prdlk/gh-commit.git
synced 2026-09-16 23:16:25 +00:00
247 lines
6.7 KiB
Go
247 lines
6.7 KiB
Go
// 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
|
||
|
|
}
|