package core import ( "fmt" "log/slog" "os" "path/filepath" "sync" "time" "github.com/fsnotify/fsnotify" "gsg/internal/gitx" ) // discoverConcurrency bounds how many repositories are opened at once during // the initial scan. Each one is a handful of git forks plus, for // keyring-bearing repos, a gpg import -- unbounded parallelism would fork // thousands of processes at once on a large install. const discoverConcurrency = 16 // watcher owns every repo directly under root: which ones are currently // open, and the fsnotify watch that tells it when to look again. reconcile // is the single entry point for loading, reloading, or dropping a repo -- // used identically by the initial scan and by every later filesystem event. type watcher struct { site *Site root string fsw *fsnotify.Watcher mu sync.Mutex repos map[string]*gitx.Repo // open repos, by directory name -- reused across reconciles dirName map[string]string // git-dir path -> directory name, for event dispatch debMu sync.Mutex debounce map[string]*time.Timer } // Start discovers every repository under site.Root, then watches root and // each repo's git-dir for changes for the life of the process. It returns // once the initial scan completes, the same as the old discover() did. func Start(site *Site) error { fsw, err := fsnotify.NewWatcher() if err != nil { return fmt.Errorf("config watch: %w", err) } if err := fsw.Add(site.Root); err != nil { fsw.Close() return fmt.Errorf("config watch: %w", err) } w := &watcher{ site: site, root: site.Root, fsw: fsw, repos: map[string]*gitx.Repo{}, dirName: map[string]string{}, debounce: map[string]*time.Timer{}, } entries, err := os.ReadDir(site.Root) if err != nil { fsw.Close() return err } sem := make(chan struct{}, discoverConcurrency) var wg sync.WaitGroup for _, e := range entries { if !e.IsDir() { continue } wg.Add(1) go func(name string) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() w.reconcile(name) }(e.Name()) } wg.Wait() if len(site.RepoList()) == 0 { fsw.Close() return fmt.Errorf("no repositories found under %s", site.Root) } go w.run() return nil } // reconcile loads, reloads, or drops exactly one repo, named by its // directory under root. It is existence-driven rather than event-driven -- // it doesn't ask what kind of filesystem event caused this call, only // whether root/name currently exists and is a valid repo -- which is what // lets the same function serve both the initial concurrent scan and the // one-at-a-time runtime event loop with no special-casing between them. func (w *watcher) reconcile(name string) { dir := filepath.Join(w.root, name) w.mu.Lock() r, known := w.repos[name] w.mu.Unlock() if _, err := os.Stat(dir); err != nil { if known { w.mu.Lock() delete(w.repos, name) w.fsw.Remove(r.Dir) delete(w.dirName, r.Dir) w.mu.Unlock() w.site.remove(r.Slug) } return } if !known { var err error if r, err = gitx.Open(dir); err != nil { return // not a repo (or a bare clone still being populated) } w.mu.Lock() w.repos[name] = r w.fsw.Add(r.Dir) // catches this repo's own config/description writes from here on w.dirName[r.Dir] = name w.mu.Unlock() } info := LoadRepoInfo(r) // Converge the keyring to whatever's configured now, on this call just // like any other -- no "only import the first time" special case. if info.GPGKeyring != r.KeyringPath() { if info.GPGKeyring == "" { r.ClearKeyring() } else if err := r.UseKeyring(info.GPGKeyring); err != nil { slog.Warn("gpg keyring: verification disabled", "repo", info.Slug, "err", err) info.GPGKeyring = "" } } w.site.upsert(Handle{Repo: r, Info: info}) } // run consumes filesystem events for the life of the process. A repo dir // appearing, disappearing, or being renamed under root is dispatched by its // (new) name; a write to a known repo's config or description file is // dispatched by that repo's name. func (w *watcher) run() { for { select { case ev, ok := <-w.fsw.Events: if !ok { return } if filepath.Dir(ev.Name) == w.root { w.scheduleReconcile(filepath.Base(ev.Name)) continue } if base := filepath.Base(ev.Name); base == "config" || base == "description" { w.mu.Lock() name := w.dirName[filepath.Dir(ev.Name)] w.mu.Unlock() if name != "" { w.scheduleReconcile(name) } } case err, ok := <-w.fsw.Errors: if !ok { return } slog.Warn("config watch error", "err", err) } } } // scheduleReconcile coalesces bursts of events for the same repo -- a single // `git config` write is a lockfile create plus a rename, i.e. at least two // raw events for one logical change -- into a single reconcile call. func (w *watcher) scheduleReconcile(name string) { const debounceDelay = 200 * time.Millisecond w.debMu.Lock() defer w.debMu.Unlock() if t, ok := w.debounce[name]; ok { t.Reset(debounceDelay) return } w.debounce[name] = time.AfterFunc(debounceDelay, func() { w.debMu.Lock() delete(w.debounce, name) w.debMu.Unlock() w.reconcile(name) }) }