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
321 lines
11 KiB
Go
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
|
|
}
|