Files
GitManager/internal/repos/repos.go
T
TBNilles 32ae17cc9f 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>
2026-09-22 05:52:03 -04:00

247 lines
6.6 KiB
Go

// 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"`
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
}
// 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
}
// Config is the per-cycle scan configuration. It is fetched fresh on every scan
// (from the store), so changing project directories at runtime takes effect
// without a restart.
type Config struct {
Roots []string
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.
type ConfigFunc func(context.Context) Config
// Scanner discovers repositories and refreshes the index on an interval.
type Scanner struct {
git *git.CLI
log *slog.Logger
cfgFn ConfigFunc
interval time.Duration
Index *Index
}
// NewScanner builds a scanner. cfgFn supplies the roots/depth/ignore/fetch fresh
// each cycle; interval is fixed for the life of the process.
func NewScanner(g *git.CLI, log *slog.Logger, cfgFn ConfigFunc, interval time.Duration) *Scanner {
return &Scanner{
git: g,
log: log,
cfgFn: cfgFn,
interval: interval,
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) {
cfg := s.cfgFn(ctx)
paths := discover(cfg)
s.log.Debug("scan discovered repositories", "roots", cfg.Roots, "count", len(paths))
for _, p := range paths {
select {
case <-ctx.Done():
return
default:
}
s.Index.set(s.refreshOne(ctx, p, cfg.Fetch, cfg.ForgeFor))
}
}
// RefreshRepo re-scans a single repository and updates the index. Used after a
// 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) {
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, forgeFor func(string) string) State {
rctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
st := State{Path: path, Name: filepath.Base(path), UpdatedAt: time.Now()}
if fetch {
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)
// 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
}
// 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 discover(cfg Config) []string {
ignore := make(map[string]struct{}, len(cfg.Ignore))
for _, name := range cfg.Ignore {
ignore[name] = struct{}{}
}
seen := make(map[string]struct{})
var out []string
for _, root := range cfg.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 := ignore[name]; skip {
return filepath.SkipDir
}
}
if depth(root, path) > cfg.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
}