Scaffold GitManager multi-repo dashboard
Runnable skeleton per AGENT.md: Echo server (/, /help, /healthz, /api/repos), read-only repo scanner with in-memory index, the internal/git boundary, the <repo-list> web component with design tokens, and dev tooling (Dockerfile, docker-compose, air, .env.example). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
// Package config loads GitManager's runtime configuration from the environment
|
||||
// (and an optional .env file). See AGENT.md §1.5 — every setting is env-driven;
|
||||
// nothing is hardcoded.
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Config is the typed application configuration.
|
||||
type Config struct {
|
||||
ListenAddr string // address the HTTP server binds to
|
||||
|
||||
RepoRoots []string // roots to scan for git repositories
|
||||
GitBin string // path to the git binary
|
||||
|
||||
ScanInterval time.Duration // scanner refresh interval
|
||||
ScanMaxDepth int // max discovery depth under each 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
|
||||
|
||||
GitHubToken string // optional forge token (AGENT.md §8)
|
||||
GitLabToken string // optional forge token (AGENT.md §8)
|
||||
}
|
||||
|
||||
// Load reads .env (if present) then the environment, applying defaults.
|
||||
// A missing .env is not an error — the environment may be set another way.
|
||||
func Load() (Config, error) {
|
||||
_ = godotenv.Load()
|
||||
|
||||
c := Config{
|
||||
ListenAddr: env("LISTEN_ADDR", "127.0.0.1:8080"),
|
||||
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", ""),
|
||||
GitHubToken: env("GITHUB_TOKEN", ""),
|
||||
GitLabToken: env("GITLAB_TOKEN", ""),
|
||||
}
|
||||
|
||||
interval, err := time.ParseDuration(env("SCAN_INTERVAL", "30s"))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
c.ScanInterval = interval
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func env(key, def string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envInt(key string, def int) int {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envBool(key string, def bool) bool {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
if b, err := strconv.ParseBool(strings.TrimSpace(v)); err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// splitList splits a comma-separated value, trimming spaces and dropping empties.
|
||||
func splitList(v string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(v, ",") {
|
||||
if p := strings.TrimSpace(part); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Package git is THE boundary for every Git operation (AGENT.md §1.3). All Git
|
||||
// access — read and, later, write — goes through here by shelling out to the
|
||||
// system `git` binary via os/exec, so the user's credential helpers, SSH keys,
|
||||
// hooks, and config apply exactly.
|
||||
//
|
||||
// The methods below are all READ-ONLY. Mutating operations (checkout, commit,
|
||||
// push, …) will be added here as they are built; destructive ones must obey
|
||||
// AGENT.md §1.4 (explicit, confirmed, never automatic, never a default).
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CLI runs Git commands via the system binary.
|
||||
type CLI struct {
|
||||
Bin string // path to git; "git" resolves from PATH
|
||||
}
|
||||
|
||||
// New returns a CLI using the given binary (defaults to "git").
|
||||
func New(bin string) *CLI {
|
||||
if bin == "" {
|
||||
bin = "git"
|
||||
}
|
||||
return &CLI{Bin: bin}
|
||||
}
|
||||
|
||||
// run executes `git <args...>` in dir and returns trimmed stdout. On failure it
|
||||
// returns an error whose message includes stderr.
|
||||
func (c *CLI) run(ctx context.Context, dir string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, c.Bin, args...)
|
||||
cmd.Dir = dir
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if msg != "" {
|
||||
return "", &Error{Args: args, Stderr: msg, Err: err}
|
||||
}
|
||||
return "", &Error{Args: args, Err: err}
|
||||
}
|
||||
return strings.TrimSpace(stdout.String()), nil
|
||||
}
|
||||
|
||||
// Error describes a failed git invocation.
|
||||
type Error struct {
|
||||
Args []string
|
||||
Stderr string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
s := "git " + strings.Join(e.Args, " ") + ": " + e.Err.Error()
|
||||
if e.Stderr != "" {
|
||||
s += ": " + e.Stderr
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (e *Error) Unwrap() error { return e.Err }
|
||||
|
||||
// Version returns the installed git version string.
|
||||
func (c *CLI) Version(ctx context.Context) (string, error) {
|
||||
return c.run(ctx, "", "version")
|
||||
}
|
||||
|
||||
// CurrentBranch returns the checked-out branch, or "HEAD" when detached.
|
||||
func (c *CLI) CurrentBranch(ctx context.Context, dir string) (string, error) {
|
||||
return c.run(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
}
|
||||
|
||||
// IsDirty reports whether the working tree has staged or unstaged changes.
|
||||
func (c *CLI) IsDirty(ctx context.Context, dir string) (bool, error) {
|
||||
out, err := c.run(ctx, dir, "status", "--porcelain")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return out != "", nil
|
||||
}
|
||||
|
||||
// AheadBehind returns how many commits HEAD is ahead of and behind its upstream.
|
||||
// Both are 0 with no error when there is no configured upstream.
|
||||
func (c *CLI) AheadBehind(ctx context.Context, dir string) (ahead, behind int, err error) {
|
||||
out, err := c.run(ctx, dir, "rev-list", "--left-right", "--count", "@{u}...HEAD")
|
||||
if err != nil {
|
||||
// No upstream is a normal state, not a failure to report.
|
||||
return 0, 0, nil
|
||||
}
|
||||
fields := strings.Fields(out)
|
||||
if len(fields) != 2 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
behind, _ = strconv.Atoi(fields[0])
|
||||
ahead, _ = strconv.Atoi(fields[1])
|
||||
return ahead, behind, nil
|
||||
}
|
||||
|
||||
// Remotes returns the configured remote names.
|
||||
func (c *CLI) Remotes(ctx context.Context, dir string) ([]string, error) {
|
||||
out, err := c.run(ctx, dir, "remote")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return strings.Split(out, "\n"), nil
|
||||
}
|
||||
|
||||
// Fetch updates remote-tracking refs. It does not modify the working tree, but
|
||||
// it does touch the network, so the scanner only calls it when explicitly
|
||||
// enabled (AGENT.md §5).
|
||||
func (c *CLI) Fetch(ctx context.Context, dir string) error {
|
||||
_, err := c.run(ctx, dir, "fetch", "--quiet", "--all")
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Package logging builds the application's slog logger. Logs go to stderr
|
||||
// (readable console in dev, structured JSON otherwise) and, optionally, to a
|
||||
// file. There is no database sink — see AGENT.md §7.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Setup returns a configured *slog.Logger and, if a file sink was opened, an
|
||||
// io.Closer to flush/close it on shutdown (nil when no file sink is used).
|
||||
func Setup(dev bool, logFile string) (*slog.Logger, io.Closer, error) {
|
||||
var w io.Writer = os.Stderr
|
||||
var closer io.Closer
|
||||
|
||||
if logFile != "" {
|
||||
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
w = io.MultiWriter(os.Stderr, f)
|
||||
closer = f
|
||||
}
|
||||
|
||||
var handler slog.Handler
|
||||
if dev {
|
||||
handler = slog.NewTextHandler(w, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
} else {
|
||||
handler = slog.NewJSONHandler(w, &slog.HandlerOptions{Level: slog.LevelInfo})
|
||||
}
|
||||
|
||||
return slog.New(handler), closer, nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Package render wires Go html/template page shells into Echo. The server emits
|
||||
// the page shell and declares web components; the components fetch their own
|
||||
// data (AGENT.md §1.1, §2).
|
||||
package render
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// Templates implements echo.Renderer over the templates in a directory.
|
||||
type Templates struct {
|
||||
tmpl *template.Template
|
||||
}
|
||||
|
||||
// New parses every *.html file in dir.
|
||||
func New(dir string) (*Templates, error) {
|
||||
t, err := template.ParseGlob(filepath.Join(dir, "*.html"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Templates{tmpl: t}, nil
|
||||
}
|
||||
|
||||
// Render satisfies echo.Renderer.
|
||||
func (t *Templates) Render(w io.Writer, name string, data any, _ echo.Context) error {
|
||||
return t.tmpl.ExecuteTemplate(w, name, data)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// Package repos discovers Git repositories under the configured roots and keeps
|
||||
// a read-optimized in-memory index of their state. The repositories are the
|
||||
// system of record; this index is a derived cache that can be rebuilt at any
|
||||
// time. The scanner is strictly READ-ONLY (AGENT.md §1.3, §5).
|
||||
package repos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitmanager/internal/git"
|
||||
)
|
||||
|
||||
// State is the cached snapshot of one repository.
|
||||
type State struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
Branch string `json:"branch"`
|
||||
Dirty bool `json:"dirty"`
|
||||
Ahead int `json:"ahead"`
|
||||
Behind int `json:"behind"`
|
||||
Remotes []string `json:"remotes"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Error string `json:"error,omitempty"` // set if refreshing this repo failed
|
||||
}
|
||||
|
||||
// Index is a concurrency-safe map of repo path -> State.
|
||||
type Index struct {
|
||||
mu sync.RWMutex
|
||||
byKey map[string]State
|
||||
}
|
||||
|
||||
func newIndex() *Index { return &Index{byKey: make(map[string]State)} }
|
||||
|
||||
func (i *Index) set(s State) {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
i.byKey[s.Path] = s
|
||||
}
|
||||
|
||||
// List returns a snapshot of all known repos, sorted by name then path.
|
||||
func (i *Index) List() []State {
|
||||
i.mu.RLock()
|
||||
defer i.mu.RUnlock()
|
||||
out := make([]State, 0, len(i.byKey))
|
||||
for _, s := range i.byKey {
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Slice(out, func(a, b int) bool {
|
||||
if out[a].Name != out[b].Name {
|
||||
return out[a].Name < out[b].Name
|
||||
}
|
||||
return out[a].Path < out[b].Path
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
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{}{}
|
||||
}
|
||||
return &Scanner{
|
||||
git: g,
|
||||
log: log,
|
||||
roots: roots,
|
||||
maxDepth: maxDepth,
|
||||
ignore: ig,
|
||||
interval: interval,
|
||||
fetchEnabled: fetchEnabled,
|
||||
Index: newIndex(),
|
||||
}
|
||||
}
|
||||
|
||||
// Run does an immediate refresh, then refreshes every interval until ctx is done.
|
||||
func (s *Scanner) Run(ctx context.Context) {
|
||||
s.Refresh(ctx)
|
||||
ticker := time.NewTicker(s.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.Refresh(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
for _, p := range paths {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
s.Index.set(s.refreshOne(ctx, p))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scanner) refreshOne(ctx context.Context, path string) 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 err := s.git.Fetch(rctx, path); err != nil {
|
||||
s.log.Warn("scan fetch failed", "repo", path, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
branch, err := s.git.CurrentBranch(rctx, path)
|
||||
if err != nil {
|
||||
st.Error = err.Error()
|
||||
return st
|
||||
}
|
||||
st.Branch = branch
|
||||
|
||||
if dirty, err := s.git.IsDirty(rctx, path); err == nil {
|
||||
st.Dirty = dirty
|
||||
} else {
|
||||
st.Error = err.Error()
|
||||
}
|
||||
|
||||
st.Ahead, st.Behind, _ = s.git.AheadBehind(rctx, path)
|
||||
|
||||
if remotes, err := s.git.Remotes(rctx, path); err == nil {
|
||||
st.Remotes = remotes
|
||||
}
|
||||
|
||||
return st
|
||||
}
|
||||
|
||||
// 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 {
|
||||
seen := make(map[string]struct{})
|
||||
var out []string
|
||||
|
||||
for _, root := range s.roots {
|
||||
root = filepath.Clean(root)
|
||||
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil // unreadable entry: skip, don't abort the walk
|
||||
}
|
||||
if !d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
name := d.Name()
|
||||
if path != root {
|
||||
if _, skip := s.ignore[name]; skip {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
|
||||
if depth(root, path) > s.maxDepth {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
if hasGit(path) {
|
||||
if _, ok := seen[path]; !ok {
|
||||
seen[path] = struct{}{}
|
||||
out = append(out, path)
|
||||
}
|
||||
return filepath.SkipDir // don't descend into a repo
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hasGit reports whether dir is a git repository (a .git directory, or a .git
|
||||
// file for worktrees/submodules).
|
||||
func hasGit(dir string) bool {
|
||||
_, err := os.Stat(filepath.Join(dir, ".git"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// depth returns how many path segments below root path is (root itself is 0).
|
||||
func depth(root, path string) int {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil || rel == "." {
|
||||
return 0
|
||||
}
|
||||
return strings.Count(rel, string(filepath.Separator)) + 1
|
||||
}
|
||||
Reference in New Issue
Block a user