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,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