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:
2026-09-22 05:52:03 -04:00
parent e30c3b632a
commit 32ae17cc9f
15 changed files with 175 additions and 434 deletions
+17 -83
View File
@@ -6,7 +6,6 @@ package main
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"os"
"os/signal"
@@ -30,36 +29,6 @@ import (
"gitmanager/internal/store"
)
// seedStore migrates config from .env into the store on first run only, so an
// existing deployment keeps working after the switch to the DB (§1.3).
func seedStore(ctx context.Context, st *store.Store, cfg config.Config, log *slog.Logger) {
empty, err := st.IsEmpty(ctx)
if err != nil {
log.Warn("config store check failed", "err", err)
return
}
if !empty {
return
}
log.Info("seeding config store from environment (first run)")
if cfg.GiteaURL != "" && cfg.GiteaToken != "" {
if _, err := st.AddForge(ctx, store.Forge{Name: "Gitea", Kind: "gitea", BaseURL: strings.TrimRight(cfg.GiteaURL, "/"), Token: cfg.GiteaToken}); err != nil {
log.Warn("seed forge failed", "err", err)
}
}
for _, r := range cfg.RepoRoots {
if _, err := st.AddProjectDir(ctx, r); err != nil {
log.Warn("seed project dir failed", "dir", r, "err", err)
}
}
if cfg.GitUserName != "" {
_ = st.SetSetting(ctx, "git_user_name", cfg.GitUserName)
}
if cfg.GitUserEmail != "" {
_ = st.SetSetting(ctx, "git_user_email", cfg.GitUserEmail)
}
}
func main() {
cfg, err := config.Load()
if err != nil {
@@ -81,38 +50,40 @@ func main() {
log.Info("git detected", "version", v)
}
// Config store (forges, project dirs, git identity) on a private volume (§1.3).
// Config store (forges + tokens, git identity) on a private volume (§1.3).
st, err := store.Open(cfg.DBPath)
if err != nil {
log.Error("failed to open config store", "path", cfg.DBPath, "err", err)
os.Exit(1)
}
defer st.Close()
seedStore(context.Background(), st, cfg, log)
// Coordination state: active project + activity feed (§8.2).
feed := activity.New(log, 200)
// The read-only scanner reads its roots fresh from the store each cycle, so
// project-directory changes take effect without a restart (§5).
// The scanner walks the single projects root; each subdirectory is a project.
// Each project's forge is derived from its git remote (svc.ForgeDisplay).
var svc *service.Service
scanCfg := func(ctx context.Context) repos.Config {
roots, err := st.EnabledRoots(ctx)
if err != nil {
log.Warn("could not read project dirs", "err", err)
return repos.Config{
Roots: []string{cfg.ProjectsRoot},
MaxDepth: cfg.ScanMaxDepth,
Ignore: cfg.ScanIgnore,
Fetch: cfg.ScanFetchEnabled,
ForgeFor: func(remoteURL string) string { return svc.ForgeDisplay(ctx, remoteURL) },
}
return repos.Config{Roots: roots, MaxDepth: cfg.ScanMaxDepth, Ignore: cfg.ScanIgnore, Fetch: cfg.ScanFetchEnabled}
}
scanner := repos.NewScanner(g, log, scanCfg, cfg.ScanInterval)
scanCtx, stopScan := context.WithCancel(context.Background())
defer stopScan()
go scanner.Run(scanCtx)
log.Info("scanner started", "interval", cfg.ScanInterval.String())
// The one service layer both the HTTP API and the MCP server call (§1.7).
// scanner.RefreshRepo lets a mutating action re-scan just that repo.
svc := service.New(g, scanner.Index, feed, st, log, scanner.RefreshRepo)
// Configure git (identity + per-forge auth) from the store.
svc.ApplyGitConfig(context.Background())
svc = service.New(g, scanner.Index, feed, st, log, scanner.RefreshRepo)
svc.ApplyGitConfig(context.Background()) // git identity + per-forge auth from the store
scanCtx, stopScan := context.WithCancel(context.Background())
defer stopScan()
go scanner.Run(scanCtx) // started after svc is set, so ForgeFor is ready
log.Info("scanner started", "root", cfg.ProjectsRoot, "interval", cfg.ScanInterval.String())
tmpl, err := render.New("web/templates")
if err != nil {
@@ -258,43 +229,6 @@ func main() {
return c.JSON(http.StatusOK, map[string]bool{"ok": true})
})
e.GET("/api/config/project-dirs", func(c echo.Context) error {
dirs, err := svc.ProjectDirs(c.Request().Context())
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, dirs)
})
e.POST("/api/config/project-dirs", func(c echo.Context) error {
var b struct{ Path string }
if err := c.Bind(&b); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
}
d, err := svc.AddProjectDir(c.Request().Context(), b.Path)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, d)
})
e.PUT("/api/config/project-dirs/:id", func(c echo.Context) error {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
var b struct{ Enabled bool }
if err := c.Bind(&b); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
}
if err := svc.SetProjectDirEnabled(c.Request().Context(), id, b.Enabled); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]bool{"ok": true})
})
e.DELETE("/api/config/project-dirs/:id", func(c echo.Context) error {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if err := svc.DeleteProjectDir(c.Request().Context(), id); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]bool{"ok": true})
})
e.GET("/api/config/identity", func(c echo.Context) error {
name, email := svc.GitIdentity(c.Request().Context())
return c.JSON(http.StatusOK, map[string]string{"name": name, "email": email})