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

321 lines
11 KiB
Go

package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
"github.com/zzet/gortex/internal/excludes"
)
// ConfigManager merges GlobalConfig + per-repo WorkspaceConfig.
// It loads the GlobalConfig once at construction and caches workspace
// configs (per-repo .gortex.yaml) on demand with a sync.RWMutex.
type ConfigManager struct {
global *GlobalConfig
workspace map[string]*Config // repoPrefix → workspace config
// workspacePaths tracks the absolute filesystem root for each
// repoPrefix. Needed by EffectiveExclude to locate the repo's own
// `.gitignore` file. Populated by LoadWorkspaceConfig regardless of
// whether `.gortex.yaml` exists, so a repo without workspace config
// still gets gitignore-respecting behaviour.
workspacePaths map[string]string
mu sync.RWMutex
logger *zap.Logger
// excludeCache memoizes the per-repo `.gitignore` parse and the layered
// exclude list so EffectiveExclude — called on every indexer walk and
// per-file reconcile — does not re-read and re-merge on every call.
excludeCache *excludeCache
}
// NewConfigManager creates a ConfigManager by loading the GlobalConfig
// from the given path. If globalPath is empty, the default path is used.
// A missing GlobalConfig file is not an error (returns empty config).
func NewConfigManager(globalPath string) (*ConfigManager, error) {
var gc *GlobalConfig
var err error
if globalPath != "" {
gc, err = LoadGlobal(globalPath)
} else {
gc, err = LoadGlobal()
}
if err != nil {
return nil, fmt.Errorf("loading global config: %w", err)
}
return &ConfigManager{
global: gc,
workspace: make(map[string]*Config),
workspacePaths: make(map[string]string),
logger: zap.NewNop(),
excludeCache: newExcludeCache(),
}, nil
}
// SetLogger sets the logger for the ConfigManager.
func (cm *ConfigManager) SetLogger(logger *zap.Logger) {
if logger != nil {
cm.logger = logger
}
}
// Global returns the underlying GlobalConfig.
func (cm *ConfigManager) Global() *GlobalConfig {
return cm.global
}
// Reload re-reads the GlobalConfig from disk, keeping the same config
// path. Workspace caches are preserved — individual `.gortex.yaml`
// files are re-read lazily on demand. Used by the daemon's `reload`
// control RPC to pick up manual edits to the global config without a
// full process restart.
func (cm *ConfigManager) Reload() error {
cm.mu.Lock()
path := cm.global.ConfigPath()
cm.mu.Unlock()
var fresh *GlobalConfig
var err error
if path != "" {
fresh, err = LoadGlobal(path)
} else {
fresh, err = LoadGlobal()
}
if err != nil {
return fmt.Errorf("reload global config: %w", err)
}
cm.mu.Lock()
cm.global = fresh
// Drop workspace cache so stale per-repo overrides don't linger;
// they'll be reloaded on the next LoadWorkspaceConfig call.
cm.workspace = make(map[string]*Config)
cm.workspacePaths = make(map[string]string)
cm.mu.Unlock()
return nil
}
// LoadWorkspaceConfig loads a .gortex.yaml from the given repo root
// and caches it under the given repoPrefix. If the file is missing,
// no entry is cached (global defaults will apply). If the file is
// malformed, a warning is logged and no entry is cached.
func (cm *ConfigManager) LoadWorkspaceConfig(repoPrefix, repoPath string) {
// Remember the path even when `.gortex.yaml` is absent so the
// effective-exclude layer can still find the repo's `.gitignore`.
if repoPath != "" {
cm.mu.Lock()
cm.workspacePaths[repoPrefix] = repoPath
cm.mu.Unlock()
}
configPath := filepath.Join(repoPath, ".gortex.yaml")
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
// No workspace config — global defaults will apply.
return
}
cm.logger.Warn("failed to read workspace config",
zap.String("repo", repoPrefix),
zap.String("path", configPath),
zap.Error(err))
return
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
// Malformed workspace config — log warning, return global defaults.
cm.logger.Warn("malformed workspace config, using global defaults",
zap.String("repo", repoPrefix),
zap.String("path", configPath),
zap.Error(err))
return
}
cm.mu.Lock()
defer cm.mu.Unlock()
cm.workspace[repoPrefix] = &cfg
}
// getWorkspaceConfig returns the cached workspace config for a repo, or nil.
func (cm *ConfigManager) getWorkspaceConfig(repoPrefix string) *Config {
cm.mu.RLock()
defer cm.mu.RUnlock()
return cm.workspace[repoPrefix]
}
// GetRepoConfig returns the merged config for a repository. The returned
// Config has Index.Exclude and Watch.Exclude populated with the full
// layered exclude list from EffectiveExclude, so callers passing
// cfg.Index into indexer.New automatically receive the effective patterns.
func (cm *ConfigManager) GetRepoConfig(repoPrefix string) *Config {
var out *Config
if ws := cm.getWorkspaceConfig(repoPrefix); ws != nil {
dup := *ws
out = &dup
} else {
out = Default()
}
effective := cm.EffectiveExclude(repoPrefix)
out.Exclude = effective
out.Index.Exclude = effective
out.Watch.Exclude = effective
// Plumb semantic.skip_embed through to the indexer's config so the
// embedder can filter without a new setter. Workspace > compiled
// defaults.
if len(out.Semantic.SkipEmbed) > 0 {
out.Index.SkipEmbed = out.Semantic.SkipEmbed
} else {
out.Index.SkipEmbed = DefaultSkipEmbed()
}
// Same plumbing for semantic.skip_search — controls what goes into
// the BM25/Bleve text index. Separate from SkipEmbed so users can
// tune the two filters independently (e.g. a tiny-repo user who
// doesn't care about text-index memory can clear SkipSearch while
// keeping SkipEmbed's embedding-cost savings).
if len(out.Semantic.SkipSearch) > 0 {
out.Index.SkipSearch = out.Semantic.SkipSearch
} else {
out.Index.SkipSearch = DefaultSkipSearch()
}
// Prose indexing toggle -- propagated from search.index_prose so
// the indexer (which only sees IndexConfig) can honour it.
// Defaults to enabled when the key is unset.
out.Index.IndexProse = out.Search.IndexProseEnabled()
return out
}
// EffectiveExclude returns the effective ignore patterns for a repo,
// layered in precedence order (later layers can re-include via !pattern):
//
// 1. Builtin baseline (excludes.Builtin)
// 2. Repo's own `.gitignore` (read from disk; opt out with
// `respect_gitignore: false` in `.gortex.yaml`)
// 3. Global Exclude from ~/.gortex/config.yaml
// 4. Matching RepoEntry.Exclude (first match in Repos, then Projects)
// 5. Workspace .gortex.yaml top-level Exclude
// 6. Legacy workspace Index.Exclude / Watch.Exclude (deprecated)
//
// This runs on a firehose of calls (every indexer walk and per-file
// reconcile), so the layered result is memoized per repo and returned
// shared: a steady-state call does one os.Stat of the repo's `.gitignore`
// and returns the cached slice without re-reading or re-merging. The cache
// invalidates on config changes (the global and workspace configs are
// swapped, never mutated in place, so pointer identity is the version) and
// on a `.gitignore` mtime/size change.
//
// The returned slice is SHARED and IMMUTABLE: callers MUST NOT mutate its
// elements. It is clipped (len == cap), so appending to it is safe —
// append reallocates rather than writing through the shared backing array.
func (cm *ConfigManager) EffectiveExclude(repoPrefix string) []string {
cm.mu.RLock()
gc := cm.global
ws := cm.workspace[repoPrefix]
repoPath := cm.workspacePaths[repoPrefix]
cm.mu.RUnlock()
respect := shouldRespectGitignore(ws)
var st gitignoreStat
if respect && repoPath != "" {
st = statGitignore(repoPath)
}
if m, ok := cm.excludeCache.lookupMerged(repoPrefix, gc, ws, repoPath, respect, st); ok {
return m
}
out := make([]string, 0, 32)
out = append(out, excludes.Builtin...)
// Layer 2: repo `.gitignore`, unless the workspace config explicitly
// opts out. The parse is cached per repo path and refreshed only when
// the file's mtime/size changes (see excludeCache), so a mid-session
// edit is still picked up on the next call.
if respect && repoPath != "" {
out = append(out, cm.excludeCache.patterns(repoPath, st)...)
}
if gc != nil {
out = append(out, gc.Exclude...)
if entry := gc.FindRepoByPrefix(repoPrefix); entry != nil {
out = append(out, entry.Exclude...)
}
}
if ws != nil {
out = append(out, ws.Exclude...)
// Legacy fallback: older configs put patterns under index.exclude
// or watch.exclude. Fold them in so nothing silently breaks.
if len(ws.Exclude) == 0 {
out = append(out, ws.Index.Exclude...)
out = append(out, ws.Watch.Exclude...)
}
}
// Force-include last so it wins: each Include entry becomes a gitignore
// `!pattern` re-include over every exclude layer above (builtin,
// .gitignore, global, repo). This is the readable form of hand-writing
// negations, for a vendored/generated tree you want indexed anyway.
if ws != nil {
for _, inc := range ws.Include {
inc = strings.TrimSpace(inc)
if inc == "" {
continue
}
if !strings.HasPrefix(inc, "!") {
inc = "!" + inc
}
out = append(out, inc)
}
}
// Clip to len == cap so a caller that appends to the returned slice is
// forced to reallocate and can never write through the shared backing
// array the cache hands to every reader.
out = out[:len(out):len(out)]
cm.excludeCache.storeMerged(repoPrefix, gc, ws, repoPath, respect, st, out)
return out
}
// shouldRespectGitignore returns true when the repo's `.gitignore`
// should be folded into the effective exclude list. Absence of a
// workspace config or absence of an explicit `respect_gitignore` setting
// both default to true; only an explicit `respect_gitignore: false`
// disables the layer.
func shouldRespectGitignore(ws *Config) bool {
if ws == nil || ws.RespectGitignore == nil {
return true
}
return *ws.RespectGitignore
}
// EffectiveGuardRules returns the effective guard rules for a repo.
// Workspace config wins when present; otherwise global defaults apply.
func (cm *ConfigManager) EffectiveGuardRules(repoPrefix string) []GuardRule {
ws := cm.getWorkspaceConfig(repoPrefix)
if ws != nil && len(ws.Guards.Rules) > 0 {
return ws.Guards.Rules
}
return Default().Guards.Rules
}
// ActiveRepos returns the repos for the active project, or the top-level
// repos if no active project is set.
func (cm *ConfigManager) ActiveRepos() []RepoEntry {
if cm.global.ActiveProject != "" {
repos, err := cm.global.ResolveRepos(cm.global.ActiveProject)
if err == nil {
return repos
}
// If the active project is invalid, fall through to top-level repos.
cm.logger.Warn("active project not found, falling back to top-level repos",
zap.String("project", cm.global.ActiveProject),
zap.Error(err))
}
return cm.global.Repos
}