a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
466 lines
14 KiB
Go
466 lines
14 KiB
Go
package indexer
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/zzet/gortex/internal/config"
|
|
"github.com/zzet/gortex/internal/resolver"
|
|
)
|
|
|
|
// MultiWatcher manages file watchers across multiple repositories.
|
|
type MultiWatcher struct {
|
|
watchers map[string]*Watcher // repoPrefix → file watcher
|
|
gitWatchers map[string]*GitWatcher // repoPrefix → .git ref watcher
|
|
started map[string]bool // tracks which watchers have been started
|
|
multi *MultiIndexer
|
|
resolver *resolver.CrossRepoResolver
|
|
logger *zap.Logger
|
|
events chan GraphChangeEvent
|
|
done chan struct{}
|
|
mu sync.Mutex
|
|
|
|
// symbolChangeCb is the OnSymbolChange callback registered by the
|
|
// MCP server (or any other consumer). It's fanned out to every
|
|
// per-repo Watcher and re-applied at AddRepo time so newly-tracked
|
|
// repos pick it up without a second registration call. Guarded by
|
|
// callbackMu so registration and per-repo apply don't race.
|
|
callbackMu sync.Mutex
|
|
symbolChangeCb SymbolChangeCallback
|
|
// degradedCb is fanned out to every per-repo Watcher (and re-applied at
|
|
// AddRepo) so a watcher entering a degraded state — inotify / FD
|
|
// exhaustion — pushes a single health notice through the daemon.
|
|
degradedCb func(reason string)
|
|
}
|
|
|
|
// NewMultiWatcher creates a MultiWatcher that watches all configured repos.
|
|
// Each repo gets its own Watcher with repo-specific exclude patterns.
|
|
func NewMultiWatcher(
|
|
mi *MultiIndexer,
|
|
configs map[string]config.WatchConfig,
|
|
logger *zap.Logger,
|
|
) (*MultiWatcher, error) {
|
|
mw := &MultiWatcher{
|
|
watchers: make(map[string]*Watcher),
|
|
gitWatchers: make(map[string]*GitWatcher),
|
|
started: make(map[string]bool),
|
|
multi: mi,
|
|
resolver: resolver.NewCrossRepo(mi.Graph()),
|
|
logger: logger,
|
|
events: make(chan GraphChangeEvent, 128),
|
|
done: make(chan struct{}),
|
|
}
|
|
// Wire the cross-workspace boundary check into the resolver so
|
|
// cross-repo edges are only resolved when the source workspace
|
|
// declared the target via `cross_workspace_deps`.
|
|
mw.resolver.SetCrossWorkspaceDepLookup(mi.crossWorkspaceLookup())
|
|
// Resolve JS/TS imports declared through an npm alias to their
|
|
// locally-vendored real package.
|
|
mw.resolver.SetNpmAliasResolver(mi.npmAliasResolver())
|
|
mw.resolver.SetPathAliasResolver(mi.pathAliasResolver())
|
|
// Break same-named import collisions in favour of the importer's
|
|
// own package-manager workspace member.
|
|
mw.resolver.SetWorkspaceMembership(mi.workspaceMembershipResolver())
|
|
// Cross-daemon proxy-edge minting: mint proxy edges on incremental
|
|
// re-resolution too, when the daemon installed a prober (flag on).
|
|
mi.applyRemoteStitch(mw.resolver)
|
|
|
|
for prefix, cfg := range configs {
|
|
if err := mw.createWatcher(prefix, cfg); err != nil {
|
|
// Log warning and continue if a repo root is inaccessible.
|
|
logger.Warn("failed to create watcher for repo",
|
|
zap.String("prefix", prefix),
|
|
zap.Error(err),
|
|
)
|
|
continue
|
|
}
|
|
}
|
|
|
|
return mw, nil
|
|
}
|
|
|
|
// createWatcher creates a per-repo Watcher for the given prefix.
|
|
func (mw *MultiWatcher) createWatcher(prefix string, cfg config.WatchConfig) error {
|
|
meta := mw.multi.GetMetadata(prefix)
|
|
if meta == nil {
|
|
return fmt.Errorf("repository not found: %s", prefix)
|
|
}
|
|
|
|
// Verify the repo root is accessible.
|
|
if _, err := os.Stat(meta.RootPath); err != nil {
|
|
return fmt.Errorf("repo root inaccessible: %s: %w", meta.RootPath, err)
|
|
}
|
|
|
|
idx := mw.multi.GetIndexer(prefix)
|
|
if idx == nil {
|
|
return fmt.Errorf("no indexer for repo: %s", prefix)
|
|
}
|
|
|
|
w, err := NewWatcher(idx, cfg, mw.logger.With(zap.String("repo", prefix)))
|
|
if err != nil {
|
|
return fmt.Errorf("creating watcher for %s: %w", prefix, err)
|
|
}
|
|
|
|
mw.watchers[prefix] = w
|
|
return nil
|
|
}
|
|
|
|
// Start begins watching all configured repos. Events from per-repo watchers
|
|
// are merged into the single Events() channel.
|
|
func (mw *MultiWatcher) Start() error {
|
|
mw.mu.Lock()
|
|
defer mw.mu.Unlock()
|
|
|
|
// Per-repo watcher startup is independent, and each w.Start blocks
|
|
// ~150ms on macOS draining the FSEvents initial-replay storm (plus
|
|
// OS stream setup and a .git/HEAD watcher). Run them concurrently
|
|
// so an N-repo daemon pays one drain window instead of N serialised
|
|
// ones — on a 20-repo install this cuts ~3.4s of warmup to ~0.4s.
|
|
//
|
|
// Each goroutine writes only its own slot in results[] (no shared
|
|
// write), and mw's started/gitWatchers maps plus the forwardEvents
|
|
// goroutines are folded in serially after the wait. mw.mu stays
|
|
// held for the whole call so Start/Stop can't interleave; the
|
|
// concurrency here is purely within one Start.
|
|
type startResult struct {
|
|
prefix string
|
|
w *Watcher
|
|
gw *GitWatcher
|
|
ok bool
|
|
}
|
|
prefixes := make([]string, 0, len(mw.watchers))
|
|
for prefix := range mw.watchers {
|
|
prefixes = append(prefixes, prefix)
|
|
}
|
|
results := make([]startResult, len(prefixes))
|
|
var wg sync.WaitGroup
|
|
for i, prefix := range prefixes {
|
|
w := mw.watchers[prefix]
|
|
meta := mw.multi.GetMetadata(prefix)
|
|
if meta == nil {
|
|
mw.logger.Warn("skipping watcher start: repo metadata not found",
|
|
zap.String("prefix", prefix))
|
|
continue
|
|
}
|
|
|
|
// Verify root is still accessible before starting.
|
|
if _, err := os.Stat(meta.RootPath); err != nil {
|
|
mw.logger.Warn("repo root inaccessible, skipping watcher",
|
|
zap.String("prefix", prefix),
|
|
zap.String("root", meta.RootPath),
|
|
zap.Error(err),
|
|
)
|
|
continue
|
|
}
|
|
|
|
wg.Add(1)
|
|
go func(slot int, prefix string, w *Watcher, rootPath string) {
|
|
defer wg.Done()
|
|
if err := w.Start([]string{rootPath}); err != nil {
|
|
mw.logger.Warn("failed to start watcher for repo",
|
|
zap.String("prefix", prefix),
|
|
zap.Error(err),
|
|
)
|
|
return
|
|
}
|
|
res := startResult{prefix: prefix, w: w, ok: true}
|
|
|
|
// Start the .git/HEAD watcher alongside the file watcher.
|
|
// It's best-effort — repos without a .git dir (uninitialised
|
|
// worktrees, tarball checkouts) simply skip it.
|
|
if idx := mw.multi.GetIndexer(prefix); idx != nil {
|
|
gw, err := NewGitWatcher(rootPath, idx, mw.logger.With(zap.String("repo", prefix)))
|
|
if err != nil {
|
|
mw.logger.Debug("git-watcher: init failed",
|
|
zap.String("prefix", prefix), zap.Error(err))
|
|
} else if err := gw.Start(); err != nil {
|
|
mw.logger.Debug("git-watcher: start failed",
|
|
zap.String("prefix", prefix), zap.Error(err))
|
|
_ = gw.Stop()
|
|
} else {
|
|
res.gw = gw
|
|
}
|
|
}
|
|
results[slot] = res
|
|
}(i, prefix, w, meta.RootPath)
|
|
}
|
|
wg.Wait()
|
|
|
|
for _, res := range results {
|
|
if !res.ok {
|
|
continue
|
|
}
|
|
mw.started[res.prefix] = true
|
|
if res.gw != nil {
|
|
mw.gitWatchers[res.prefix] = res.gw
|
|
}
|
|
// Forward events from this watcher and trigger cross-repo resolution.
|
|
go mw.forwardEvents(res.prefix, res.w)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// forwardEvents reads events from a per-repo watcher and forwards them
|
|
// to the merged events channel. After each event, it triggers cross-repo
|
|
// resolution for the owning repo.
|
|
func (mw *MultiWatcher) forwardEvents(prefix string, w *Watcher) {
|
|
for {
|
|
select {
|
|
case <-mw.done:
|
|
return
|
|
case ev, ok := <-w.Events():
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// After re-indexing, trigger cross-repo resolution — scoped
|
|
// to the file that changed, not the whole repo. ResolveForRepo
|
|
// materialised the repo's entire edge set on every save (the
|
|
// per-edit allocation flood); ResolveForFile only re-resolves
|
|
// the changed file's out-edges. The watcher path is absolute,
|
|
// so convert it to the repo-relative graph key first.
|
|
if mw.multi.IsMultiRepo() {
|
|
relPath := ev.FilePath
|
|
if w.indexer != nil {
|
|
relPath = w.indexer.RelKey(ev.FilePath)
|
|
}
|
|
stats := mw.resolver.ResolveForFile(prefix, relPath)
|
|
if stats.CrossRepoEdges > 0 {
|
|
mw.logger.Debug("cross-repo edges updated after file change",
|
|
zap.String("repo", prefix),
|
|
zap.String("file", ev.FilePath),
|
|
zap.Int("cross_repo_edges", stats.CrossRepoEdges),
|
|
)
|
|
}
|
|
}
|
|
|
|
// Non-blocking send to merged channel.
|
|
select {
|
|
case mw.events <- ev:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stop halts all per-repo watchers and cleans up resources.
|
|
func (mw *MultiWatcher) Stop() error {
|
|
close(mw.done)
|
|
|
|
mw.mu.Lock()
|
|
defer mw.mu.Unlock()
|
|
|
|
var firstErr error
|
|
for prefix, w := range mw.watchers {
|
|
// Only stop watchers that were actually started.
|
|
if !mw.started[prefix] {
|
|
continue
|
|
}
|
|
if err := w.Stop(); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
mw.logger.Warn("error stopping watcher",
|
|
zap.String("prefix", prefix),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
if gw, ok := mw.gitWatchers[prefix]; ok {
|
|
_ = gw.Stop()
|
|
}
|
|
}
|
|
|
|
return firstErr
|
|
}
|
|
|
|
// Events returns a read-only channel of merged graph change events from all repos.
|
|
func (mw *MultiWatcher) Events() <-chan GraphChangeEvent {
|
|
return mw.events
|
|
}
|
|
|
|
// History returns the union of per-repo histories, sorted newest-first.
|
|
// Implements the same surface as Watcher.History so the MCP server can
|
|
// consume either a single Watcher or a MultiWatcher through the same
|
|
// interface and `get_recent_changes` lights up under the daemon.
|
|
func (mw *MultiWatcher) History() []GraphChangeEvent {
|
|
mw.mu.Lock()
|
|
watchers := make([]*Watcher, 0, len(mw.watchers))
|
|
for _, w := range mw.watchers {
|
|
watchers = append(watchers, w)
|
|
}
|
|
mw.mu.Unlock()
|
|
|
|
var out []GraphChangeEvent
|
|
for _, w := range watchers {
|
|
out = append(out, w.History()...)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Timestamp.After(out[j].Timestamp) })
|
|
return out
|
|
}
|
|
|
|
// HistorySince returns the union of per-repo events strictly after the
|
|
// given timestamp, sorted newest-first.
|
|
func (mw *MultiWatcher) HistorySince(since time.Time) []GraphChangeEvent {
|
|
mw.mu.Lock()
|
|
watchers := make([]*Watcher, 0, len(mw.watchers))
|
|
for _, w := range mw.watchers {
|
|
watchers = append(watchers, w)
|
|
}
|
|
mw.mu.Unlock()
|
|
|
|
var out []GraphChangeEvent
|
|
for _, w := range watchers {
|
|
out = append(out, w.HistorySince(since)...)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Timestamp.After(out[j].Timestamp) })
|
|
return out
|
|
}
|
|
|
|
// OnSymbolChange registers the callback against every current per-repo
|
|
// Watcher and stores it so AddRepo applies it to future watchers too.
|
|
// Replaces any previously registered callback (matches Watcher.OnSymbolChange
|
|
// semantics).
|
|
func (mw *MultiWatcher) OnSymbolChange(cb SymbolChangeCallback) {
|
|
mw.callbackMu.Lock()
|
|
mw.symbolChangeCb = cb
|
|
mw.callbackMu.Unlock()
|
|
|
|
mw.mu.Lock()
|
|
watchers := make([]*Watcher, 0, len(mw.watchers))
|
|
for _, w := range mw.watchers {
|
|
watchers = append(watchers, w)
|
|
}
|
|
mw.mu.Unlock()
|
|
|
|
for _, w := range watchers {
|
|
w.OnSymbolChange(cb)
|
|
}
|
|
}
|
|
|
|
// OnDegraded registers a callback fired (once per repo) when a per-repo watcher
|
|
// first degrades — inotify / FD exhaustion. Fanned out to current watchers and
|
|
// re-applied at AddRepo, mirroring OnSymbolChange.
|
|
func (mw *MultiWatcher) OnDegraded(cb func(reason string)) {
|
|
mw.callbackMu.Lock()
|
|
mw.degradedCb = cb
|
|
mw.callbackMu.Unlock()
|
|
|
|
mw.mu.Lock()
|
|
watchers := make([]*Watcher, 0, len(mw.watchers))
|
|
for _, w := range mw.watchers {
|
|
watchers = append(watchers, w)
|
|
}
|
|
mw.mu.Unlock()
|
|
|
|
for _, w := range watchers {
|
|
w.OnDegraded(cb)
|
|
}
|
|
}
|
|
|
|
// DegradedReason returns the first non-empty per-repo degraded reason, prefixed
|
|
// with the repo it came from, or "" when every watcher is healthy. Lets the
|
|
// daemon-mode freshness rider surface a whole-index "frozen" banner the same
|
|
// way the single-repo embedded watcher does.
|
|
func (mw *MultiWatcher) DegradedReason() string {
|
|
mw.mu.Lock()
|
|
defer mw.mu.Unlock()
|
|
prefixes := make([]string, 0, len(mw.watchers))
|
|
for prefix := range mw.watchers {
|
|
prefixes = append(prefixes, prefix)
|
|
}
|
|
sort.Strings(prefixes)
|
|
for _, prefix := range prefixes {
|
|
if r := mw.watchers[prefix].DegradedReason(); r != "" {
|
|
if prefix != "" {
|
|
return prefix + ": " + r
|
|
}
|
|
return r
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// AddRepo creates and starts a watcher for a newly tracked repo.
|
|
func (mw *MultiWatcher) AddRepo(repoPrefix string, cfg config.WatchConfig) error {
|
|
mw.mu.Lock()
|
|
defer mw.mu.Unlock()
|
|
|
|
if _, exists := mw.watchers[repoPrefix]; exists {
|
|
return fmt.Errorf("watcher already exists for repo: %s", repoPrefix)
|
|
}
|
|
|
|
if err := mw.createWatcher(repoPrefix, cfg); err != nil {
|
|
mw.logger.Warn("failed to add watcher for repo",
|
|
zap.String("prefix", repoPrefix),
|
|
zap.Error(err),
|
|
)
|
|
return err
|
|
}
|
|
|
|
w := mw.watchers[repoPrefix]
|
|
meta := mw.multi.GetMetadata(repoPrefix)
|
|
if meta == nil {
|
|
return fmt.Errorf("repository metadata not found: %s", repoPrefix)
|
|
}
|
|
|
|
if err := w.Start([]string{meta.RootPath}); err != nil {
|
|
delete(mw.watchers, repoPrefix)
|
|
return fmt.Errorf("starting watcher for %s: %w", repoPrefix, err)
|
|
}
|
|
|
|
mw.started[repoPrefix] = true
|
|
if idx := mw.multi.GetIndexer(repoPrefix); idx != nil {
|
|
if gw, err := NewGitWatcher(meta.RootPath, idx, mw.logger.With(zap.String("repo", repoPrefix))); err == nil {
|
|
if err := gw.Start(); err == nil {
|
|
mw.gitWatchers[repoPrefix] = gw
|
|
} else {
|
|
_ = gw.Stop()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply any previously-registered symbol-change callback so a repo
|
|
// added at runtime contributes to get_symbol_history just like the
|
|
// repos created at MultiWatcher construction time.
|
|
mw.callbackMu.Lock()
|
|
cb := mw.symbolChangeCb
|
|
degradedCb := mw.degradedCb
|
|
mw.callbackMu.Unlock()
|
|
if cb != nil {
|
|
w.OnSymbolChange(cb)
|
|
}
|
|
if degradedCb != nil {
|
|
w.OnDegraded(degradedCb)
|
|
}
|
|
|
|
go mw.forwardEvents(repoPrefix, w)
|
|
return nil
|
|
}
|
|
|
|
// RemoveRepo stops and removes the watcher for a repo.
|
|
func (mw *MultiWatcher) RemoveRepo(repoPrefix string) error {
|
|
mw.mu.Lock()
|
|
defer mw.mu.Unlock()
|
|
|
|
w, exists := mw.watchers[repoPrefix]
|
|
if !exists {
|
|
return fmt.Errorf("no watcher for repo: %s", repoPrefix)
|
|
}
|
|
|
|
var err error
|
|
if mw.started[repoPrefix] {
|
|
err = w.Stop()
|
|
}
|
|
if gw, ok := mw.gitWatchers[repoPrefix]; ok {
|
|
_ = gw.Stop()
|
|
delete(mw.gitWatchers, repoPrefix)
|
|
}
|
|
delete(mw.watchers, repoPrefix)
|
|
delete(mw.started, repoPrefix)
|
|
return err
|
|
}
|