6ef1c195e3
git boundary: Pull/Push/Commit/DiscardAll (discard is 1.4-destructive). Scanner.RefreshRepo re-scans one repo after a mutation. Service GitFetch/GitPull/GitPush/GitCommit/GitDiscard record a git-* activity event and refresh on success; service.New takes a refresh hook. HTTP POST /api/repo/git. New <repo-menu> overlay with plain-language commands (Get latest/Publish/Check for updates/Save my work/Set active/Ask Claude to switch/Copy path/Discard all changes), summoned by repo-list's repo:contextmenu. Added service test for commit/discard on a temp repo. Verified the menu live for safe commands. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
222 lines
5.5 KiB
Go
222 lines
5.5 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"`
|
|
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
|
|
}
|