// 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)) } } // 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. func (s *Scanner) RefreshRepo(ctx context.Context, path string) { s.Index.set(s.refreshOne(ctx, path)) } 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 }