Move domain config from .env to a private SQLite store
Forges (multi-host) + tokens, project directories, and git identity now live in a private SQLite config store (internal/store, modernc.org/sqlite) on a /data named volume that is not bind-mounted or exposed, so credentials aren't reachable outside the container. New Settings page (/settings) + <settings-panel> with /api/config CRUD. Scanner reads roots fresh from the store each cycle; service resolves forges per-repo from the store and reapplies per-forge git auth on change. First run seeds the store from .env. Overturns the old no-datastore/.env-config laws (AGENT.md updated). Verified live end-to-end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,11 @@ type Config struct {
|
||||
TLSCertFile string // PEM cert (e.g. an mkcert leaf trusted by the OS store)
|
||||
TLSKeyFile string // PEM private key
|
||||
|
||||
RepoRoots []string // roots to scan for git repositories
|
||||
// DBPath is the SQLite config store (forges, project dirs, git identity).
|
||||
// It lives on a private volume, not accessible outside the container (§1.3).
|
||||
DBPath string
|
||||
|
||||
RepoRoots []string // SEED ONLY: roots to scan, used to seed the store on first run
|
||||
GitBin string // path to the git binary
|
||||
|
||||
ScanInterval time.Duration // scanner refresh interval
|
||||
@@ -50,6 +54,7 @@ func Load() (Config, error) {
|
||||
|
||||
c := Config{
|
||||
ListenAddr: env("LISTEN_ADDR", "127.0.0.1:8080"),
|
||||
DBPath: env("GITMANAGER_DB", "/data/gitmanager.db"),
|
||||
HTTPSAddr: env("HTTPS_ADDR", ""),
|
||||
TLSCertFile: env("TLS_CERT_FILE", ""),
|
||||
TLSKeyFile: env("TLS_KEY_FILE", ""),
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"gitmanager/internal/git"
|
||||
"gitmanager/internal/repos"
|
||||
"gitmanager/internal/service"
|
||||
"gitmanager/internal/store"
|
||||
)
|
||||
|
||||
// TestMCPRoundTrip exercises the full path: a real temp git repo -> scanner ->
|
||||
@@ -39,10 +40,17 @@ func TestMCPRoundTrip(t *testing.T) {
|
||||
// Populate the index via the real scanner, then build service + MCP server.
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
g := git.New("git")
|
||||
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false)
|
||||
scanner := repos.NewScanner(g, log, func(context.Context) repos.Config {
|
||||
return repos.Config{Roots: []string{root}, MaxDepth: 3, Fetch: false}
|
||||
}, time.Minute)
|
||||
scanner.Refresh(context.Background())
|
||||
|
||||
svc := service.New(g, scanner.Index, activity.New(log, 200), nil, scanner.RefreshRepo)
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "config.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
svc := service.New(g, scanner.Index, activity.New(log, 200), st, log, scanner.RefreshRepo)
|
||||
srv := NewServer(svc, "test")
|
||||
|
||||
// Wire an in-memory client<->server session.
|
||||
|
||||
+41
-32
@@ -61,34 +61,38 @@ func (i *Index) List() []State {
|
||||
return out
|
||||
}
|
||||
|
||||
// Config is the per-cycle scan configuration. It is fetched fresh on every scan
|
||||
// (from the store), so changing project directories at runtime takes effect
|
||||
// without a restart.
|
||||
type Config struct {
|
||||
Roots []string
|
||||
MaxDepth int
|
||||
Ignore []string
|
||||
Fetch bool
|
||||
}
|
||||
|
||||
// ConfigFunc supplies the current scan configuration.
|
||||
type ConfigFunc func(context.Context) Config
|
||||
|
||||
// Scanner discovers repositories and refreshes the index on an interval.
|
||||
type Scanner struct {
|
||||
git *git.CLI
|
||||
log *slog.Logger
|
||||
roots []string
|
||||
maxDepth int
|
||||
ignore map[string]struct{}
|
||||
interval time.Duration
|
||||
fetchEnabled bool
|
||||
git *git.CLI
|
||||
log *slog.Logger
|
||||
cfgFn ConfigFunc
|
||||
interval time.Duration
|
||||
|
||||
Index *Index
|
||||
}
|
||||
|
||||
// NewScanner builds a scanner. ignore is a set of directory names to skip.
|
||||
func NewScanner(g *git.CLI, log *slog.Logger, roots []string, maxDepth int, ignore []string, interval time.Duration, fetchEnabled bool) *Scanner {
|
||||
ig := make(map[string]struct{}, len(ignore))
|
||||
for _, name := range ignore {
|
||||
ig[name] = struct{}{}
|
||||
}
|
||||
// NewScanner builds a scanner. cfgFn supplies the roots/depth/ignore/fetch fresh
|
||||
// each cycle; interval is fixed for the life of the process.
|
||||
func NewScanner(g *git.CLI, log *slog.Logger, cfgFn ConfigFunc, interval time.Duration) *Scanner {
|
||||
return &Scanner{
|
||||
git: g,
|
||||
log: log,
|
||||
roots: roots,
|
||||
maxDepth: maxDepth,
|
||||
ignore: ig,
|
||||
interval: interval,
|
||||
fetchEnabled: fetchEnabled,
|
||||
Index: newIndex(),
|
||||
git: g,
|
||||
log: log,
|
||||
cfgFn: cfgFn,
|
||||
interval: interval,
|
||||
Index: newIndex(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,32 +114,33 @@ func (s *Scanner) Run(ctx context.Context) {
|
||||
// Refresh discovers repos and updates the index. Each repo is refreshed under
|
||||
// its own timeout so a single slow/unreachable repo cannot stall the rest.
|
||||
func (s *Scanner) Refresh(ctx context.Context) {
|
||||
paths := s.discover()
|
||||
s.log.Debug("scan discovered repositories", "count", len(paths))
|
||||
cfg := s.cfgFn(ctx)
|
||||
paths := discover(cfg)
|
||||
s.log.Debug("scan discovered repositories", "roots", cfg.Roots, "count", len(paths))
|
||||
for _, p := range paths {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
s.Index.set(s.refreshOne(ctx, p))
|
||||
s.Index.set(s.refreshOne(ctx, p, cfg.Fetch))
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshRepo re-scans a single repository and updates the index. Used after a
|
||||
// mutating action so the UI reflects the new state without waiting for the next
|
||||
// full scan.
|
||||
// full scan. It never fetches (network) — it only re-reads local state.
|
||||
func (s *Scanner) RefreshRepo(ctx context.Context, path string) {
|
||||
s.Index.set(s.refreshOne(ctx, path))
|
||||
s.Index.set(s.refreshOne(ctx, path, false))
|
||||
}
|
||||
|
||||
func (s *Scanner) refreshOne(ctx context.Context, path string) State {
|
||||
func (s *Scanner) refreshOne(ctx context.Context, path string, fetch bool) State {
|
||||
rctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
st := State{Path: path, Name: filepath.Base(path), UpdatedAt: time.Now()}
|
||||
|
||||
if s.fetchEnabled {
|
||||
if fetch {
|
||||
if err := s.git.Fetch(rctx, path); err != nil {
|
||||
s.log.Warn("scan fetch failed", "repo", path, "err", err)
|
||||
}
|
||||
@@ -166,11 +171,15 @@ func (s *Scanner) refreshOne(ctx context.Context, path string) State {
|
||||
// discover walks each root looking for directories that contain a .git entry,
|
||||
// recording the parent as a repo and not descending into it. Depth is measured
|
||||
// relative to each root; ignored directory names are skipped.
|
||||
func (s *Scanner) discover() []string {
|
||||
func discover(cfg Config) []string {
|
||||
ignore := make(map[string]struct{}, len(cfg.Ignore))
|
||||
for _, name := range cfg.Ignore {
|
||||
ignore[name] = struct{}{}
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
var out []string
|
||||
|
||||
for _, root := range s.roots {
|
||||
for _, root := range cfg.Roots {
|
||||
root = filepath.Clean(root)
|
||||
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
@@ -182,12 +191,12 @@ func (s *Scanner) discover() []string {
|
||||
|
||||
name := d.Name()
|
||||
if path != root {
|
||||
if _, skip := s.ignore[name]; skip {
|
||||
if _, skip := ignore[name]; skip {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
|
||||
if depth(root, path) > s.maxDepth {
|
||||
if depth(root, path) > cfg.MaxDepth {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
|
||||
+233
-34
@@ -8,13 +8,17 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitmanager/internal/activity"
|
||||
"gitmanager/internal/forge"
|
||||
"gitmanager/internal/git"
|
||||
"gitmanager/internal/repos"
|
||||
"gitmanager/internal/store"
|
||||
)
|
||||
|
||||
// Service holds the shared dependencies the capabilities need.
|
||||
@@ -22,15 +26,22 @@ type Service struct {
|
||||
git *git.CLI
|
||||
index *repos.Index
|
||||
feed *activity.Feed
|
||||
forge *forge.Gitea // nil when no forge is configured
|
||||
refresh func(context.Context, string) // re-scan one repo after a mutation (may be nil)
|
||||
store *store.Store
|
||||
log *slog.Logger
|
||||
refresh func(context.Context, string) // re-scan one repo after a mutation (may be nil)
|
||||
|
||||
mu sync.Mutex // guards forgeCache
|
||||
forgeCache map[string]*forge.Gitea // base_url -> provider
|
||||
}
|
||||
|
||||
// New builds a Service over the git boundary, the scanner's repo index, the
|
||||
// activity feed, (optionally) a forge provider, and a single-repo refresh hook
|
||||
// (may be nil) used to re-scan a repo after a mutating action.
|
||||
func New(g *git.CLI, index *repos.Index, feed *activity.Feed, fg *forge.Gitea, refresh func(context.Context, string)) *Service {
|
||||
return &Service{git: g, index: index, feed: feed, forge: fg, refresh: refresh}
|
||||
// activity feed, the config store, and a single-repo refresh hook (may be nil)
|
||||
// used to re-scan a repo after a mutating action.
|
||||
func New(g *git.CLI, index *repos.Index, feed *activity.Feed, st *store.Store, log *slog.Logger, refresh func(context.Context, string)) *Service {
|
||||
return &Service{
|
||||
git: g, index: index, feed: feed, store: st, log: log, refresh: refresh,
|
||||
forgeCache: make(map[string]*forge.Gitea),
|
||||
}
|
||||
}
|
||||
|
||||
// ListRepos returns a snapshot of every discovered repository.
|
||||
@@ -123,29 +134,32 @@ func (s *Service) CancelSwitch(actor activity.Actor) (activity.PendingSwitch, bo
|
||||
|
||||
// --- Forge (Gitea) — PRs and "Merge & clean up" (§8.4) ---------------------
|
||||
|
||||
// ForgeConfigured reports whether any forge provider is set up.
|
||||
func (s *Service) ForgeConfigured() bool { return s.forge != nil }
|
||||
// ForgeConfigured reports whether any forge is configured in the store.
|
||||
func (s *Service) ForgeConfigured(ctx context.Context) bool {
|
||||
forges, err := s.store.ListForges(ctx)
|
||||
return err == nil && len(forges) > 0
|
||||
}
|
||||
|
||||
// ForgePRs lists open pull requests for a repo. Returns forge.ErrNotConfigured
|
||||
// when no provider is set, or forge.ErrNotSupported when the repo's remote is not
|
||||
// on the configured host.
|
||||
// when no forge is configured, or forge.ErrNotSupported when the repo's remote is
|
||||
// not on a configured host.
|
||||
func (s *Service) ForgePRs(ctx context.Context, repoPath string) ([]forge.PullRequest, error) {
|
||||
owner, repo, err := s.resolveForge(ctx, repoPath)
|
||||
prov, owner, repo, err := s.resolveForge(ctx, repoPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.forge.ListPullRequests(ctx, owner, repo)
|
||||
return prov.ListPullRequests(ctx, owner, repo)
|
||||
}
|
||||
|
||||
// CreatePR opens a pull request from head into base (empty base = the repo's
|
||||
// default branch) and records the action. The head branch must already exist on
|
||||
// the remote (push it first).
|
||||
func (s *Service) CreatePR(ctx context.Context, actor activity.Actor, repoPath, head, base, title, body string) (forge.PullRequest, error) {
|
||||
owner, repo, err := s.resolveForge(ctx, repoPath)
|
||||
prov, owner, repo, err := s.resolveForge(ctx, repoPath)
|
||||
if err != nil {
|
||||
return forge.PullRequest{}, err
|
||||
}
|
||||
pr, err := s.forge.CreatePullRequest(ctx, owner, repo, forge.NewPR{Head: head, Base: base, Title: title, Body: body})
|
||||
pr, err := prov.CreatePullRequest(ctx, owner, repo, forge.NewPR{Head: head, Base: base, Title: title, Body: body})
|
||||
if err != nil {
|
||||
return forge.PullRequest{}, err
|
||||
}
|
||||
@@ -157,11 +171,11 @@ func (s *Service) CreatePR(ctx context.Context, actor activity.Actor, repoPath,
|
||||
// The caller is responsible for confirming with the user first (§1.4); actor
|
||||
// distinguishes a UI action (user) from an MCP one (claude).
|
||||
func (s *Service) MergeAndCleanup(ctx context.Context, actor activity.Actor, repoPath string, number int64) (forge.MergeResult, error) {
|
||||
owner, repo, err := s.resolveForge(ctx, repoPath)
|
||||
prov, owner, repo, err := s.resolveForge(ctx, repoPath)
|
||||
if err != nil {
|
||||
return forge.MergeResult{}, err
|
||||
}
|
||||
res, err := s.forge.MergeAndCleanup(ctx, owner, repo, number, forge.MergeSquash)
|
||||
res, err := prov.MergeAndCleanup(ctx, owner, repo, number, forge.MergeSquash)
|
||||
if err != nil {
|
||||
return forge.MergeResult{}, err
|
||||
}
|
||||
@@ -254,38 +268,223 @@ func (s *Service) GitCreateBranch(ctx context.Context, actor activity.Actor, rep
|
||||
})
|
||||
}
|
||||
|
||||
// resolveForge maps a repo path to (owner, repo) on the configured forge host via
|
||||
// its git remotes, preferring "origin".
|
||||
func (s *Service) resolveForge(ctx context.Context, repoPath string) (owner, repo string, err error) {
|
||||
if s.forge == nil {
|
||||
return "", "", forge.ErrNotConfigured
|
||||
// resolveForge maps a repo path to (provider, owner, repo) by matching its git
|
||||
// remotes (preferring "origin") against the forges configured in the store.
|
||||
func (s *Service) resolveForge(ctx context.Context, repoPath string) (*forge.Gitea, string, string, error) {
|
||||
forges, err := s.store.ListForges(ctx)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
if len(forges) == 0 {
|
||||
return nil, "", "", forge.ErrNotConfigured
|
||||
}
|
||||
base, ok := s.index.Get(filepath.Clean(repoPath))
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("unknown repository %q", repoPath)
|
||||
return nil, "", "", fmt.Errorf("unknown repository %q", repoPath)
|
||||
}
|
||||
remotes, err := s.git.RemoteDetails(ctx, base.Path)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return nil, "", "", err
|
||||
}
|
||||
|
||||
match := func(remoteURL string) (*forge.Gitea, string, string, bool) {
|
||||
host, o, r, ok := forge.ParseRemote(remoteURL)
|
||||
if !ok {
|
||||
return nil, "", "", false
|
||||
}
|
||||
for _, f := range forges {
|
||||
prov, perr := s.forgeFor(f)
|
||||
if perr != nil {
|
||||
continue
|
||||
}
|
||||
if prov.Handles(host) {
|
||||
return prov, o, r, true
|
||||
}
|
||||
}
|
||||
return nil, "", "", false
|
||||
}
|
||||
|
||||
// Prefer origin, then any matching remote.
|
||||
var fallback [2]string
|
||||
haveFallback := false
|
||||
var fb *forge.Gitea
|
||||
var fo, fr string
|
||||
for _, rm := range remotes {
|
||||
host, o, r, ok := forge.ParseRemote(rm.URL)
|
||||
if !ok || !s.forge.Handles(host) {
|
||||
prov, o, r, ok := match(rm.URL)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if rm.Name == "origin" {
|
||||
return o, r, nil
|
||||
return prov, o, r, nil
|
||||
}
|
||||
if !haveFallback {
|
||||
fallback = [2]string{o, r}
|
||||
haveFallback = true
|
||||
if fb == nil {
|
||||
fb, fo, fr = prov, o, r
|
||||
}
|
||||
}
|
||||
if haveFallback {
|
||||
return fallback[0], fallback[1], nil
|
||||
if fb != nil {
|
||||
return fb, fo, fr, nil
|
||||
}
|
||||
return nil, "", "", forge.ErrNotSupported
|
||||
}
|
||||
|
||||
// forgeFor returns a cached *forge.Gitea for a configured forge, building it on
|
||||
// first use. The cache is cleared whenever forges change (invalidateForges).
|
||||
func (s *Service) forgeFor(f store.Forge) (*forge.Gitea, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if p, ok := s.forgeCache[f.BaseURL]; ok {
|
||||
return p, nil
|
||||
}
|
||||
p, err := forge.NewGitea(f.BaseURL, f.Token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p == nil {
|
||||
return nil, forge.ErrNotConfigured
|
||||
}
|
||||
s.forgeCache[f.BaseURL] = p
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Service) invalidateForges() {
|
||||
s.mu.Lock()
|
||||
s.forgeCache = make(map[string]*forge.Gitea)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// --- Configuration store (forges, project dirs, git identity) — §1.3 --------
|
||||
|
||||
// Forges lists configured forges (tokens are never included).
|
||||
func (s *Service) Forges(ctx context.Context) ([]store.Forge, error) {
|
||||
return s.store.ListForges(ctx)
|
||||
}
|
||||
|
||||
// AddForge adds a forge, then reapplies git auth so pushes to it work.
|
||||
func (s *Service) AddForge(ctx context.Context, name, kind, baseURL, token string) (store.Forge, error) {
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
return store.Forge{}, fmt.Errorf("a base URL is required")
|
||||
}
|
||||
f, err := s.store.AddForge(ctx, store.Forge{Name: name, Kind: kind, BaseURL: strings.TrimRight(baseURL, "/"), Token: token})
|
||||
if err != nil {
|
||||
return store.Forge{}, err
|
||||
}
|
||||
s.invalidateForges()
|
||||
s.ApplyGitConfig(ctx)
|
||||
s.feed.Record(activity.ActorUser, "forge-added", "", baseURL)
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// UpdateForge edits a forge (empty token keeps the existing one).
|
||||
func (s *Service) UpdateForge(ctx context.Context, id int64, name, kind, baseURL, token string) error {
|
||||
if err := s.store.UpdateForge(ctx, id, name, kind, strings.TrimRight(baseURL, "/"), token); err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidateForges()
|
||||
s.ApplyGitConfig(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteForge removes a forge.
|
||||
func (s *Service) DeleteForge(ctx context.Context, id int64) error {
|
||||
if err := s.store.DeleteForge(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidateForges()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProjectDirs lists configured project directories.
|
||||
func (s *Service) ProjectDirs(ctx context.Context) ([]store.ProjectDir, error) {
|
||||
return s.store.ListProjectDirs(ctx)
|
||||
}
|
||||
|
||||
// AddProjectDir adds a directory to scan. The path must exist inside the
|
||||
// container (i.e. be under a mounted base) and be a directory.
|
||||
func (s *Service) AddProjectDir(ctx context.Context, path string) (store.ProjectDir, error) {
|
||||
path = filepath.Clean(strings.TrimSpace(path))
|
||||
if path == "" {
|
||||
return store.ProjectDir{}, fmt.Errorf("a path is required")
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return store.ProjectDir{}, fmt.Errorf("path not found in the container: %s (is it under a mounted directory?)", path)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return store.ProjectDir{}, fmt.Errorf("not a directory: %s", path)
|
||||
}
|
||||
d, err := s.store.AddProjectDir(ctx, path)
|
||||
if err != nil {
|
||||
return store.ProjectDir{}, err
|
||||
}
|
||||
s.feed.Record(activity.ActorUser, "project-dir-added", path, "")
|
||||
if s.refresh == nil {
|
||||
return d, nil
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// SetProjectDirEnabled toggles a project directory.
|
||||
func (s *Service) SetProjectDirEnabled(ctx context.Context, id int64, enabled bool) error {
|
||||
return s.store.SetProjectDirEnabled(ctx, id, enabled)
|
||||
}
|
||||
|
||||
// DeleteProjectDir removes a project directory.
|
||||
func (s *Service) DeleteProjectDir(ctx context.Context, id int64) error {
|
||||
return s.store.DeleteProjectDir(ctx, id)
|
||||
}
|
||||
|
||||
// GitIdentity returns the configured commit identity.
|
||||
func (s *Service) GitIdentity(ctx context.Context) (name, email string) {
|
||||
name, _ = s.store.GetSetting(ctx, "git_user_name", "")
|
||||
email, _ = s.store.GetSetting(ctx, "git_user_email", "")
|
||||
return name, email
|
||||
}
|
||||
|
||||
// SetGitIdentity stores the commit identity and reapplies it to git config.
|
||||
func (s *Service) SetGitIdentity(ctx context.Context, name, email string) error {
|
||||
if err := s.store.SetSetting(ctx, "git_user_name", name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.SetSetting(ctx, "git_user_email", email); err != nil {
|
||||
return err
|
||||
}
|
||||
s.ApplyGitConfig(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ScanRoots returns the enabled project directories (for the scanner).
|
||||
func (s *Service) ScanRoots(ctx context.Context) []string {
|
||||
roots, err := s.store.EnabledRoots(ctx)
|
||||
if err != nil && s.log != nil {
|
||||
s.log.Warn("could not read project dirs", "err", err)
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
// ApplyGitConfig writes the container's git global config from the store: a
|
||||
// commit identity, safe.directory for host-owned mounts, and an auth header per
|
||||
// forge so push/fetch/pull over HTTPS work. Called at startup and after changes.
|
||||
func (s *Service) ApplyGitConfig(ctx context.Context) {
|
||||
set := func(key, value string) {
|
||||
if err := s.git.SetGlobalConfig(ctx, key, value); err != nil && s.log != nil {
|
||||
s.log.Warn("git config failed", "key", key, "err", err)
|
||||
}
|
||||
}
|
||||
set("safe.directory", "*")
|
||||
if name, email := s.GitIdentity(ctx); name != "" || email != "" {
|
||||
if name != "" {
|
||||
set("user.name", name)
|
||||
}
|
||||
if email != "" {
|
||||
set("user.email", email)
|
||||
}
|
||||
}
|
||||
forges, err := s.store.ListForges(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, f := range forges {
|
||||
if f.Token == "" {
|
||||
continue
|
||||
}
|
||||
set("http."+f.BaseURL+".extraheader", "Authorization: token "+f.Token)
|
||||
}
|
||||
return "", "", forge.ErrNotSupported
|
||||
}
|
||||
|
||||
@@ -14,8 +14,27 @@ import (
|
||||
"gitmanager/internal/activity"
|
||||
"gitmanager/internal/git"
|
||||
"gitmanager/internal/repos"
|
||||
"gitmanager/internal/store"
|
||||
)
|
||||
|
||||
// testScanner builds a scanner whose roots are fixed to the given paths.
|
||||
func testScanner(g *git.CLI, log *slog.Logger, roots ...string) *repos.Scanner {
|
||||
return repos.NewScanner(g, log, func(context.Context) repos.Config {
|
||||
return repos.Config{Roots: roots, MaxDepth: 3, Fetch: false}
|
||||
}, time.Minute)
|
||||
}
|
||||
|
||||
// testStore opens a throwaway SQLite store.
|
||||
func testStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "config.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
// TestGitActions exercises the mutating commands (commit, discard) on a throwaway
|
||||
// temp repo — never a real one.
|
||||
func TestGitActions(t *testing.T) {
|
||||
@@ -31,10 +50,10 @@ func TestGitActions(t *testing.T) {
|
||||
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
g := git.New("git")
|
||||
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false)
|
||||
scanner := testScanner(g, log, root)
|
||||
scanner.Refresh(context.Background())
|
||||
feed := activity.New(log, 200)
|
||||
svc := New(g, scanner.Index, feed, nil, scanner.RefreshRepo)
|
||||
svc := New(g, scanner.Index, feed, testStore(t), log, scanner.RefreshRepo)
|
||||
ctx := context.Background()
|
||||
|
||||
// Commit a new file, then the repo should be clean in the index.
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
// Package store is GitManager's small SQLite-backed configuration store: forge
|
||||
// hosts + credentials, the project directories to scan, and a few settings
|
||||
// (git identity). It replaces the domain config that used to live in .env.
|
||||
//
|
||||
// The DB file lives on a PRIVATE named Docker volume (not bind-mounted into the
|
||||
// project, no network port), so it is not accessible outside the container
|
||||
// (AGENT.md §0/§1.3). Tokens are stored as-is, relying on that isolation. Pure-Go
|
||||
// driver (modernc.org/sqlite) so the static CGO_ENABLED=0 build keeps working.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Forge is a configured hosting provider (Gitea/Forgejo, later GitHub/GitLab).
|
||||
type Forge struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // "gitea" (only kind for now)
|
||||
BaseURL string `json:"baseUrl"` // e.g. https://git.nilles.net
|
||||
Token string `json:"-"` // never serialized to the client
|
||||
HasToken bool `json:"hasToken"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ProjectDir is a directory (inside the container) to scan for repositories.
|
||||
type ProjectDir struct {
|
||||
ID int64 `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Store wraps the SQLite connection.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Open opens (creating if needed) the SQLite DB at path and runs migrations.
|
||||
func Open(path string) (*Store, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)", path)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1) // SQLite: serialize writers to avoid "database is locked"
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS forges (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
kind TEXT NOT NULL DEFAULT 'gitea',
|
||||
base_url TEXT NOT NULL UNIQUE,
|
||||
token TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS project_dirs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);`)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- forges ----------------------------------------------------------------
|
||||
|
||||
func (s *Store) ListForges(ctx context.Context) ([]Forge, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, name, kind, base_url, token, created_at FROM forges ORDER BY base_url`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Forge
|
||||
for rows.Next() {
|
||||
var f Forge
|
||||
var created string
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.Kind, &f.BaseURL, &f.Token, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
f.HasToken = f.Token != ""
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) AddForge(ctx context.Context, f Forge) (Forge, error) {
|
||||
if f.Kind == "" {
|
||||
f.Kind = "gitea"
|
||||
}
|
||||
f.CreatedAt = time.Now()
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO forges (name, kind, base_url, token, created_at) VALUES (?,?,?,?,?)`,
|
||||
f.Name, f.Kind, f.BaseURL, f.Token, f.CreatedAt.Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return Forge{}, err
|
||||
}
|
||||
f.ID, _ = res.LastInsertId()
|
||||
f.HasToken = f.Token != ""
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// UpdateForge updates name/base_url/kind, and the token only when newToken != ""
|
||||
// (empty means "keep the existing token").
|
||||
func (s *Store) UpdateForge(ctx context.Context, id int64, name, kind, baseURL, newToken string) error {
|
||||
if kind == "" {
|
||||
kind = "gitea"
|
||||
}
|
||||
if newToken != "" {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE forges SET name=?, kind=?, base_url=?, token=? WHERE id=?`,
|
||||
name, kind, baseURL, newToken, id)
|
||||
return err
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE forges SET name=?, kind=?, base_url=? WHERE id=?`, name, kind, baseURL, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteForge(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM forges WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- project dirs ----------------------------------------------------------
|
||||
|
||||
func (s *Store) ListProjectDirs(ctx context.Context) ([]ProjectDir, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, path, enabled, created_at FROM project_dirs ORDER BY path`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ProjectDir
|
||||
for rows.Next() {
|
||||
var d ProjectDir
|
||||
var created string
|
||||
var enabled int
|
||||
if err := rows.Scan(&d.ID, &d.Path, &enabled, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Enabled = enabled != 0
|
||||
d.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// EnabledRoots returns the paths of enabled project directories.
|
||||
func (s *Store) EnabledRoots(ctx context.Context) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT path FROM project_dirs WHERE enabled=1 ORDER BY path`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var p string
|
||||
if err := rows.Scan(&p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) AddProjectDir(ctx context.Context, path string) (ProjectDir, error) {
|
||||
d := ProjectDir{Path: path, Enabled: true, CreatedAt: time.Now()}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO project_dirs (path, enabled, created_at) VALUES (?,1,?)`,
|
||||
path, d.CreatedAt.Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return ProjectDir{}, err
|
||||
}
|
||||
d.ID, _ = res.LastInsertId()
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetProjectDirEnabled(ctx context.Context, id int64, enabled bool) error {
|
||||
v := 0
|
||||
if enabled {
|
||||
v = 1
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE project_dirs SET enabled=? WHERE id=?`, v, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteProjectDir(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM project_dirs WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- settings (kv) ---------------------------------------------------------
|
||||
|
||||
func (s *Store) GetSetting(ctx context.Context, key, def string) (string, error) {
|
||||
var v string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key=?`, key).Scan(&v)
|
||||
if err == sql.ErrNoRows {
|
||||
return def, nil
|
||||
}
|
||||
if err != nil {
|
||||
return def, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO settings (key, value) VALUES (?,?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// IsEmpty reports whether the store has no forges and no project dirs (used to
|
||||
// decide whether to seed from .env on first run).
|
||||
func (s *Store) IsEmpty(ctx context.Context) (bool, error) {
|
||||
var n int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT (SELECT COUNT(*) FROM forges) + (SELECT COUNT(*) FROM project_dirs)`).Scan(&n); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n == 0, nil
|
||||
}
|
||||
Reference in New Issue
Block a user