Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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
}