// Command server is the GitManager entrypoint. It wires config, logging, the // Git boundary, the repo scanner, and the Echo HTTP server, then serves the // dashboard shell and the JSON endpoints the web components fetch from. package main import ( "context" "encoding/json" "log/slog" "net/http" "os" "os/signal" "strconv" "strings" "syscall" "time" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "gitmanager/internal/activity" "gitmanager/internal/config" "gitmanager/internal/forge" "gitmanager/internal/git" "gitmanager/internal/logging" mcpserver "gitmanager/internal/mcp" "gitmanager/internal/render" "gitmanager/internal/repos" "gitmanager/internal/service" "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 { panic(err) } log, closer, err := logging.Setup(cfg.Dev, cfg.LogFile) if err != nil { panic(err) } if closer != nil { defer closer.Close() } g := git.New(cfg.GitBin) if v, err := g.Version(context.Background()); err != nil { log.Warn("git binary not usable — repo operations will fail", "bin", cfg.GitBin, "err", err) } else { log.Info("git detected", "version", v) } // Config store (forges, project dirs, 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). 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: 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()) tmpl, err := render.New("web/templates") if err != nil { log.Error("failed to parse templates", "err", err) os.Exit(1) } e := echo.New() e.HideBanner = true e.Renderer = tmpl e.Use(middleware.Recover()) e.Use(middleware.RequestID()) // Ask browsers to revalidate component/static assets so edits show up on // reload (the dev server hot-reloads; cached ES modules would defeat that). e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { p := c.Request().URL.Path if strings.HasPrefix(p, "/components/") || strings.HasPrefix(p, "/static/") { c.Response().Header().Set("Cache-Control", "no-cache") } return next(c) } }) // Static assets and component sources. e.Static("/static", "web/static") e.Static("/components", "components") // Page shells. e.GET("/", func(c echo.Context) error { return c.Render(http.StatusOK, "index.html", nil) }) e.GET("/help", func(c echo.Context) error { return c.Render(http.StatusOK, "help.html", nil) }) e.GET("/settings", func(c echo.Context) error { return c.Render(http.StatusOK, "settings.html", nil) }) // JSON API — components self-fetch from here. e.GET("/healthz", func(c echo.Context) error { return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) }) e.GET("/api/repos", func(c echo.Context) error { return c.JSON(http.StatusOK, svc.ListRepos()) }) e.GET("/api/repo", func(c echo.Context) error { // The service only serves details for an already-discovered repo — it // never runs git against an arbitrary caller-supplied path (§1.3). detail, ok := svc.RepoDetail(c.Request().Context(), c.QueryParam("path")) if !ok { return c.JSON(http.StatusNotFound, map[string]string{"error": "unknown repository"}) } return c.JSON(http.StatusOK, detail) }) // Active project + activity (§8.2). e.GET("/api/active-project", func(c echo.Context) error { return c.JSON(http.StatusOK, map[string]string{"path": svc.ActiveProject()}) }) e.POST("/api/active-project", func(c echo.Context) error { var body struct { Path string `json:"path"` } if err := c.Bind(&body); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"}) } // A user action in the UI (actor=user) — distinct from Claude's own switches. if _, _, err := svc.SetActiveProject(activity.ActorUser, body.Path); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]string{"path": svc.ActiveProject()}) }) e.GET("/api/activity", func(c echo.Context) error { return c.JSON(http.StatusOK, svc.Activity(0)) }) // Graceful handoff (§8.3): the user requests a switch; Claude completes it. e.GET("/api/switch", func(c echo.Context) error { p, ok := svc.PendingSwitch() if !ok { return c.JSON(http.StatusOK, map[string]any{"pending": false}) } return c.JSON(http.StatusOK, map[string]any{ "pending": true, "target": p.Target, "note": p.Note, "requestedAt": p.RequestedAt, }) }) e.POST("/api/switch", func(c echo.Context) error { var body struct { Target string `json:"target"` Note string `json:"note"` } if err := c.Bind(&body); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"}) } p, err := svc.RequestSwitch(activity.ActorUser, body.Target, body.Note) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]any{"pending": true, "target": p.Target, "note": p.Note}) }) e.DELETE("/api/switch", func(c echo.Context) error { svc.CancelSwitch(activity.ActorUser) return c.JSON(http.StatusOK, map[string]any{"pending": false}) }) // Configuration store: forges, project directories, git identity (§1.3). e.GET("/api/config/forges", func(c echo.Context) error { forges, err := svc.Forges(c.Request().Context()) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, forges) }) e.POST("/api/config/forges", func(c echo.Context) error { var b struct{ Name, Kind, BaseURL, Token string } if err := c.Bind(&b); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"}) } f, err := svc.AddForge(c.Request().Context(), b.Name, b.Kind, b.BaseURL, b.Token) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, f) }) e.PUT("/api/config/forges/:id", func(c echo.Context) error { id, _ := strconv.ParseInt(c.Param("id"), 10, 64) var b struct{ Name, Kind, BaseURL, Token string } if err := c.Bind(&b); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"}) } if err := svc.UpdateForge(c.Request().Context(), id, b.Name, b.Kind, b.BaseURL, b.Token); 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/forges/:id", func(c echo.Context) error { id, _ := strconv.ParseInt(c.Param("id"), 10, 64) if err := svc.DeleteForge(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/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}) }) e.PUT("/api/config/identity", func(c echo.Context) error { var b struct{ Name, Email string } if err := c.Bind(&b); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"}) } if err := svc.SetGitIdentity(c.Request().Context(), b.Name, b.Email); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]bool{"ok": true}) }) // Plain-language git commands (the right-click menu, §6). One endpoint, // op-switched. Destructive ops (discard) are confirmed UI-side per §1.4. e.POST("/api/repo/git", func(c echo.Context) error { var body struct { Path string `json:"path"` Op string `json:"op"` Message string `json:"message"` Branch string `json:"branch"` } if err := c.Bind(&body); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"}) } ctx := c.Request().Context() var out string var err error switch body.Op { case "fetch": out, err = svc.GitFetch(ctx, activity.ActorUser, body.Path) case "pull": out, err = svc.GitPull(ctx, activity.ActorUser, body.Path) case "push": out, err = svc.GitPush(ctx, activity.ActorUser, body.Path) case "commit": out, err = svc.GitCommit(ctx, activity.ActorUser, body.Path, body.Message) case "discard": out, err = svc.GitDiscard(ctx, activity.ActorUser, body.Path) case "checkout": out, err = svc.GitCheckout(ctx, activity.ActorUser, body.Path, body.Branch) case "create-branch": out, err = svc.GitCreateBranch(ctx, activity.ActorUser, body.Path, body.Branch) default: return c.JSON(http.StatusBadRequest, map[string]string{"error": "unknown op: " + body.Op}) } if err != nil { return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]any{"ok": true, "output": out}) }) // Forge PRs + "Merge & clean up" (§8.4). e.GET("/api/repo/prs", func(c echo.Context) error { prs, err := svc.ForgePRs(c.Request().Context(), c.QueryParam("path")) if err != nil { // Not configured / not on the forge host is a normal "no PRs here" // state — tell the UI to hide the section rather than error. if err == forge.ErrNotConfigured || err == forge.ErrNotSupported { return c.JSON(http.StatusOK, map[string]any{"supported": false}) } return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]any{"supported": true, "prs": prs}) }) e.POST("/api/repo/pr/create", func(c echo.Context) error { var body struct { Path string `json:"path"` Head string `json:"head"` Base string `json:"base"` Title string `json:"title"` Body string `json:"body"` } if err := c.Bind(&body); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"}) } pr, err := svc.CreatePR(c.Request().Context(), activity.ActorUser, body.Path, body.Head, body.Base, body.Title, body.Body) if err != nil { return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, pr) }) e.POST("/api/repo/pr/merge", func(c echo.Context) error { var body struct { Path string `json:"path"` Number int64 `json:"number"` } if err := c.Bind(&body); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"}) } res, err := svc.MergeAndCleanup(c.Request().Context(), activity.ActorUser, body.Path, body.Number) if err != nil { return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, res) }) // SSE stream of activity events for live UI (§8.2). e.GET("/events", func(c echo.Context) error { w := c.Response() w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.WriteHeader(http.StatusOK) w.Flush() ch, unsub := svc.SubscribeActivity() defer unsub() keepalive := time.NewTicker(25 * time.Second) defer keepalive.Stop() for { select { case <-c.Request().Context().Done(): return nil case <-keepalive.C: if _, err := w.Write([]byte(": ping\n\n")); err != nil { return nil } w.Flush() case ev := <-ch: data, err := json.Marshal(ev) if err != nil { continue } if _, err := w.Write([]byte("event: activity\ndata: " + string(data) + "\n\n")); err != nil { return nil } w.Flush() } } }) // MCP server — Claude connects via the local stdio bridge (§8.1). Same // service layer as the HTTP API (§1.7); localhost-bound like everything else. mcpSrv := mcpserver.NewServer(svc, "0.1.0") e.Any("/mcp", echo.WrapHandler(mcpserver.Handler(mcpSrv))) log.Info("mcp server mounted", "path", "/mcp") // Serve with graceful shutdown. go func() { if err := e.Start(cfg.ListenAddr); err != nil && err != http.ErrServerClosed { log.Error("server error", "err", err) os.Exit(1) } }() log.Info("listening", "addr", cfg.ListenAddr) // Optional HTTPS listener (same Echo app). Required for the MCP connector, // which only accepts https:// URLs (§8.1). Best-effort: a missing/unreadable // cert logs a warning and leaves the app running over HTTP. if cfg.HTTPSAddr != "" && cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" { if _, err := os.Stat(cfg.TLSCertFile); err != nil { log.Warn("HTTPS requested but cert not readable — serving HTTP only", "cert", cfg.TLSCertFile, "err", err) } else { go func() { if err := e.StartTLS(cfg.HTTPSAddr, cfg.TLSCertFile, cfg.TLSKeyFile); err != nil && err != http.ErrServerClosed { log.Error("TLS server error", "err", err) } }() log.Info("listening (https)", "addr", cfg.HTTPSAddr) } } quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt, syscall.SIGTERM) <-quit log.Info("shutting down") stopScan() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := e.Shutdown(ctx); err != nil { log.Error("graceful shutdown failed", "err", err) } }