Config: .env = projects root only; derive each project's forge from git
Reduce .env to just REPOS_HOST_PATH (the projects root); runtime bootstrap moves to compose/defaults. Projects are the subdirectories of the single root — removed the project-directories feature (store table, service methods, /api/config/project-dirs, Settings section). Each project's forge is derived from its git remote (matched to a configured forge, else the bare host) and shown as a pill next to its name (State.Forge via scanner ForgeFor + svc.ForgeDisplay). Removed first-run .env seeding; forges + identity are managed in Settings. Added forge.HostOf. AGENT.md updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+11
-20
@@ -23,28 +23,25 @@ type Config struct {
|
||||
TLSCertFile string // PEM cert (e.g. an mkcert leaf trusted by the OS store)
|
||||
TLSKeyFile string // PEM private key
|
||||
|
||||
// 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 is the SQLite config store (forges + tokens, 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
|
||||
// ProjectsRoot is the single directory (inside the container) under which each
|
||||
// project lives in its own subdirectory. This is the one thing set in .env
|
||||
// (as REPOS_HOST_PATH on the host, mounted here); everything else is either a
|
||||
// compose/env default or lives in the config store.
|
||||
ProjectsRoot string
|
||||
|
||||
GitBin string // path to the git binary
|
||||
|
||||
ScanInterval time.Duration // scanner refresh interval
|
||||
ScanMaxDepth int // max discovery depth under each root
|
||||
ScanMaxDepth int // max discovery depth under the root
|
||||
ScanIgnore []string // directory names to skip during discovery
|
||||
ScanFetchEnabled bool // allow the scanner to run `git fetch`
|
||||
|
||||
Dev bool // readable console logging vs structured JSON
|
||||
LogFile string // optional file to also append logs to
|
||||
|
||||
GitUserName string // commit identity for git actions run by the app
|
||||
GitUserEmail string // commit identity for git actions run by the app
|
||||
|
||||
GiteaURL string // Gitea/Forgejo base URL (e.g. https://git.nilles.net)
|
||||
GiteaToken string // Gitea token (read + PR write + branch delete) — §8.4
|
||||
GitHubToken string // optional forge token (later provider)
|
||||
GitLabToken string // optional forge token (later provider)
|
||||
}
|
||||
|
||||
// Load reads .env (if present) then the environment, applying defaults.
|
||||
@@ -55,22 +52,16 @@ func Load() (Config, error) {
|
||||
c := Config{
|
||||
ListenAddr: env("LISTEN_ADDR", "127.0.0.1:8080"),
|
||||
DBPath: env("GITMANAGER_DB", "/data/gitmanager.db"),
|
||||
ProjectsRoot: env("PROJECTS_ROOT", "/repos"),
|
||||
HTTPSAddr: env("HTTPS_ADDR", ""),
|
||||
TLSCertFile: env("TLS_CERT_FILE", ""),
|
||||
TLSKeyFile: env("TLS_KEY_FILE", ""),
|
||||
RepoRoots: splitList(env("GIT_REPO_ROOTS", "")),
|
||||
GitBin: env("GIT_BIN", "git"),
|
||||
ScanMaxDepth: envInt("SCAN_MAX_DEPTH", 4),
|
||||
ScanIgnore: splitList(env("SCAN_IGNORE", "node_modules,vendor,.cache")),
|
||||
ScanFetchEnabled: envBool("SCAN_FETCH_ENABLED", false),
|
||||
Dev: strings.EqualFold(env("APP_ENV", "dev"), "dev"),
|
||||
LogFile: env("LOG_FILE", ""),
|
||||
GitUserName: env("GIT_USER_NAME", ""),
|
||||
GitUserEmail: env("GIT_USER_EMAIL", ""),
|
||||
GiteaURL: env("GITEA_URL", ""),
|
||||
GiteaToken: env("GITEA_TOKEN", ""),
|
||||
GitHubToken: env("GITHUB_TOKEN", ""),
|
||||
GitLabToken: env("GITLAB_TOKEN", ""),
|
||||
}
|
||||
|
||||
interval, err := time.ParseDuration(env("SCAN_INTERVAL", "30s"))
|
||||
|
||||
@@ -66,6 +66,16 @@ type Provider interface {
|
||||
MergeAndCleanup(ctx context.Context, owner, repo string, number int64, method MergeMethod) (MergeResult, error)
|
||||
}
|
||||
|
||||
// HostOf returns the hostname of a base URL (e.g. "https://git.example.com:3000"
|
||||
// → "git.example.com"), or "" if it can't be parsed.
|
||||
func HostOf(rawURL string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.Hostname()
|
||||
}
|
||||
|
||||
// ParseRemote extracts (host, owner, repo) from a git remote URL, handling both
|
||||
// https ("https://host/owner/repo.git") and scp-like ssh ("git@host:owner/repo.git").
|
||||
func ParseRemote(remote string) (host, owner, repo string, ok bool) {
|
||||
|
||||
+21
-5
@@ -26,6 +26,7 @@ type State struct {
|
||||
Ahead int `json:"ahead"`
|
||||
Behind int `json:"behind"`
|
||||
Remotes []string `json:"remotes"`
|
||||
Forge string `json:"forge,omitempty"` // forge this project belongs to (derived from its remote)
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Error string `json:"error,omitempty"` // set if refreshing this repo failed
|
||||
}
|
||||
@@ -69,6 +70,9 @@ type Config struct {
|
||||
MaxDepth int
|
||||
Ignore []string
|
||||
Fetch bool
|
||||
// ForgeFor maps a repo's origin remote URL to a display label for the forge it
|
||||
// belongs to (a configured forge's name, else the bare host, else ""). May be nil.
|
||||
ForgeFor func(remoteURL string) string
|
||||
}
|
||||
|
||||
// ConfigFunc supplies the current scan configuration.
|
||||
@@ -123,7 +127,7 @@ func (s *Scanner) Refresh(ctx context.Context) {
|
||||
return
|
||||
default:
|
||||
}
|
||||
s.Index.set(s.refreshOne(ctx, p, cfg.Fetch))
|
||||
s.Index.set(s.refreshOne(ctx, p, cfg.Fetch, cfg.ForgeFor))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,10 +135,11 @@ func (s *Scanner) Refresh(ctx context.Context) {
|
||||
// mutating action so the UI reflects the new state without waiting for the next
|
||||
// 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, false))
|
||||
cfg := s.cfgFn(ctx)
|
||||
s.Index.set(s.refreshOne(ctx, path, false, cfg.ForgeFor))
|
||||
}
|
||||
|
||||
func (s *Scanner) refreshOne(ctx context.Context, path string, fetch bool) State {
|
||||
func (s *Scanner) refreshOne(ctx context.Context, path string, fetch bool, forgeFor func(string) string) State {
|
||||
rctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -161,8 +166,19 @@ func (s *Scanner) refreshOne(ctx context.Context, path string, fetch bool) State
|
||||
|
||||
st.Ahead, st.Behind, _ = s.git.AheadBehind(rctx, path)
|
||||
|
||||
if remotes, err := s.git.Remotes(rctx, path); err == nil {
|
||||
st.Remotes = remotes
|
||||
// Remote names, plus the origin URL used to derive which forge this project
|
||||
// belongs to (§ "get it from git").
|
||||
if details, err := s.git.RemoteDetails(rctx, path); err == nil {
|
||||
var originURL string
|
||||
for _, rm := range details {
|
||||
st.Remotes = append(st.Remotes, rm.Name)
|
||||
if rm.Name == "origin" || originURL == "" {
|
||||
originURL = rm.URL
|
||||
}
|
||||
}
|
||||
if forgeFor != nil && originURL != "" {
|
||||
st.Forge = forgeFor(originURL)
|
||||
}
|
||||
}
|
||||
|
||||
return st
|
||||
|
||||
+20
-47
@@ -9,7 +9,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -391,46 +390,6 @@ func (s *Service) DeleteForge(ctx context.Context, id int64) error {
|
||||
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", "")
|
||||
@@ -450,13 +409,27 @@ func (s *Service) SetGitIdentity(ctx context.Context, name, email string) error
|
||||
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)
|
||||
// ForgeDisplay maps a repo's origin remote URL to a label for the forge it
|
||||
// belongs to: a configured forge's name (or its host) when the remote host
|
||||
// matches one, otherwise the bare remote host, otherwise "". Used by the scanner
|
||||
// to show each project's forge next to its name.
|
||||
func (s *Service) ForgeDisplay(ctx context.Context, remoteURL string) string {
|
||||
host, _, _, ok := forge.ParseRemote(remoteURL)
|
||||
if !ok || host == "" {
|
||||
return ""
|
||||
}
|
||||
return roots
|
||||
forges, err := s.store.ListForges(ctx)
|
||||
if err == nil {
|
||||
for _, f := range forges {
|
||||
if strings.EqualFold(forge.HostOf(f.BaseURL), host) {
|
||||
if f.Name != "" {
|
||||
return f.Name
|
||||
}
|
||||
return host
|
||||
}
|
||||
}
|
||||
}
|
||||
return host // not a configured forge, but still informative
|
||||
}
|
||||
|
||||
// ApplyGitConfig writes the container's git global config from the store: a
|
||||
|
||||
@@ -28,14 +28,6 @@ type Forge struct {
|
||||
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
|
||||
@@ -69,12 +61,6 @@ CREATE TABLE IF NOT EXISTS forges (
|
||||
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
|
||||
@@ -142,73 +128,6 @@ func (s *Store) DeleteForge(ctx context.Context, id int64) error {
|
||||
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) {
|
||||
@@ -229,14 +148,3 @@ func (s *Store) SetSetting(ctx context.Context, key, value string) error {
|
||||
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