package repository
import (
"errors"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/go-git/go-git/v5"
)
type Info struct {
Name string
Path string
}
type Store struct {
root string
hidden map[string]struct{}
mu sync.RWMutex
repos map[string]Info
cache map[string]*Repository
refreshing bool
}
func Discover(root string, hiddenRepos ...string) (*Store, error) {
hidden := normalizeHiddenRepos(hiddenRepos)
repos, err := discoverRepos(root, hidden)
if err != nil {
return nil, err
}
return &Store{
root: root,
hidden: hidden,
repos: repos,
cache: make(map[string]*Repository),
}, nil
}
func (s *Store) Root() string {
return s.root
}
func (s *Store) List() []Info {
s.mu.RLock()
defer s.mu.RUnlock()
items := make([]Info, 0, len(s.repos))
for _, repo := range s.repos {
items = append(items, repo)
}
sort.Slice(items, func(i, j int) bool {
return items[i].Name < items[j].Name
})
return items
}
func (s *Store) Open(name string) (*Repository, error) {
s.mu.RLock()
info, ok := s.repos[name]
if !ok {
s.mu.RUnlock()
return nil, os.ErrNotExist
}
if repo, ok := s.cache[name]; ok {
s.mu.RUnlock()
return repo, nil
}
s.mu.RUnlock()
repo, err := Open(info.Name, info.Path)
if err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if cached, ok := s.cache[name]; ok {
return cached, nil
}
if _, ok := s.repos[name]; !ok {
return nil, os.ErrNotExist
}
s.cache[name] = repo
return repo, nil
}
func (s *Store) SplitPath(raw string) (string, string, bool) {
clean := strings.Trim(strings.TrimSpace(raw), "/")
if clean == "" {
return "", "", false
}
parts := strings.Split(clean, "/")
s.mu.RLock()
defer s.mu.RUnlock()
for i := len(parts); i >= 1; i-- {
candidate := strings.Join(parts[:i], "/")
if _, ok := s.repos[candidate]; !ok {
continue
}
if i == len(parts) {
return candidate, "", true
}
return candidate, "/" + strings.Join(parts[i:], "/"), true
}
return "", "", false
}
func (s *Store) Refresh() error {
repos, err := discoverRepos(s.root, s.hidden)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
s.repos = repos
s.cache = make(map[string]*Repository)
return nil
}
func (s *Store) RefreshAsync(onError func(error)) bool {
s.mu.Lock()
if s.refreshing {
s.mu.Unlock()
return false
}
s.refreshing = true
s.mu.Unlock()
go func() {
err := s.Refresh()
s.mu.Lock()
s.refreshing = false
s.mu.Unlock()
if err != nil && onError != nil {
onError(err)
}
}()
return true
}
func (s *Store) StartPeriodicRefresh(interval time.Duration, onError func(error)) func() {
if interval <= 0 {
return func() {}
}
done := make(chan struct{})
ticker := time.NewTicker(interval)
go func() {
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.RefreshAsync(onError)
case <-done:
return
}
}
}()
var once sync.Once
return func() {
once.Do(func() {
close(done)
})
}
}
func normalizeHiddenRepos(hiddenRepos []string) map[string]struct{} {
hidden := make(map[string]struct{}, len(hiddenRepos))
for _, repo := range hiddenRepos {
name := strings.Trim(filepath.ToSlash(strings.TrimSpace(repo)), "/")
if name == "" {
continue
}
hidden[name] = struct{}{}
}
return hidden
}
func discoverRepos(root string, hidden map[string]struct{}) (map[string]Info, error) {
entries, err := os.ReadDir(root)
if err != nil {
return nil, err
}
repos := make(map[string]Info)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
found, err := appendRepo(repos, hidden, root, entry.Name())
if err != nil {
return nil, err
}
if found {
continue
}
children, err := os.ReadDir(filepath.Join(root, entry.Name()))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return nil, err
}
for _, child := range children {
if !child.IsDir() {
continue
}
if _, err := appendRepo(repos, hidden, root, entry.Name(), child.Name()); err != nil {
return nil, err
}
}
}
return repos, nil
}
func appendRepo(repos map[string]Info, hidden map[string]struct{}, root string, parts ...string) (bool, error) {
name := filepath.ToSlash(filepath.Join(parts...))
repoPath := filepath.Join(append([]string{root}, parts...)...)
if _, err := git.PlainOpen(repoPath); err != nil {
if errors.Is(err, git.ErrRepositoryNotExists) {
return false, nil
}
return false, err
}
if _, ok := hidden[name]; ok {
return true, nil
}
repos[name] = Info{Name: name, Path: repoPath}
return true, nil
}