summaryrefslogtreecommitdiff
path: root/internal/appconfig
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-08 10:39:51 +0200
committerPaul Buetow <paul@buetow.org>2026-02-08 10:39:51 +0200
commitd5b13224737a9f66c3d5113a885603b32867d740 (patch)
treed482cc965a65be22604800fe6772279c52961b99 /internal/appconfig
parentbd698b257a548d835fbc2675ff5be5e1a69ff229 (diff)
add gitignore-aware file filtering for LSP completions and code actionsv0.18.0
Files matching .gitignore patterns or user-configured extra patterns are now skipped for completions and code actions. Configurable via [ignore] section in config.toml with gitignore, extra_patterns, and lsp_notify_ignored options. Includes hot-reload support and env var overrides (HEXAI_IGNORE_*). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/appconfig')
-rw-r--r--internal/appconfig/config.go76
-rw-r--r--internal/appconfig/config_test.go117
2 files changed, 182 insertions, 11 deletions
diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go
index 87ad9ff..8ec29ae 100644
--- a/internal/appconfig/config.go
+++ b/internal/appconfig/config.go
@@ -113,6 +113,11 @@ type App struct {
TmuxCustomMenuHotkey string `json:"-" toml:"-"`
// Stats
StatsWindowMinutes int `json:"-" toml:"-"`
+
+ // Ignore: gitignore-aware file filtering for LSP
+ IgnoreGitignore *bool `json:"-" toml:"-"`
+ IgnoreExtraPatterns []string `json:"-" toml:"-"`
+ IgnoreLSPNotify *bool `json:"-" toml:"-"`
}
// CustomAction describes a user-defined code action.
@@ -180,9 +185,15 @@ func newDefaultConfig() App {
// Stats
StatsWindowMinutes: 60,
+
+ // Ignore: respect .gitignore by default, notify in LSP by default
+ IgnoreGitignore: boolPtr(true),
+ IgnoreLSPNotify: boolPtr(true),
}
}
+func boolPtr(b bool) *bool { return &b }
+
// Load reads configuration from a file and merges with defaults.
// It respects the XDG Base Directory Specification.
func Load(logger *log.Logger) App { return LoadWithOptions(logger, LoadOptions{}) }
@@ -236,11 +247,11 @@ func LoadWithOptions(logger *log.Logger, opts LoadOptions) App {
}
// loadProjectConfig attempts to load .hexaiconfig.toml from the project root and
-// merges it into cfg. Uses opts.ProjectRoot if set, otherwise auto-detects via findGitRoot().
+// merges it into cfg. Uses opts.ProjectRoot if set, otherwise auto-detects via FindGitRoot().
func loadProjectConfig(logger *log.Logger, opts LoadOptions, cfg *App) {
projectRoot := strings.TrimSpace(opts.ProjectRoot)
if projectRoot == "" {
- projectRoot = findGitRoot()
+ projectRoot = FindGitRoot()
}
if projectRoot == "" {
return
@@ -269,6 +280,7 @@ type fileConfig struct {
Prompts sectionPrompts `toml:"prompts"`
Tmux sectionTmux `toml:"tmux"`
Stats sectionStats `toml:"stats"`
+ Ignore sectionIgnore `toml:"ignore"`
}
type sectionGeneral struct {
@@ -313,6 +325,14 @@ type sectionStats struct {
WindowMinutes int `toml:"window_minutes"`
}
+// sectionIgnore controls gitignore-aware file filtering. Files matching
+// these patterns are skipped for completions and code actions.
+type sectionIgnore struct {
+ Gitignore *bool `toml:"gitignore"`
+ ExtraPatterns []string `toml:"extra_patterns"`
+ LSPNotifyIgnored *bool `toml:"lsp_notify_ignored"`
+}
+
type sectionOpenAI struct {
Model string `toml:"model"`
BaseURL string `toml:"base_url"`
@@ -629,6 +649,16 @@ func (fc *fileConfig) toApp() App {
out.StatsWindowMinutes = fc.Stats.WindowMinutes
}
+ // ignore
+ if fc.Ignore.Gitignore != nil || len(fc.Ignore.ExtraPatterns) > 0 || fc.Ignore.LSPNotifyIgnored != nil {
+ tmp := App{
+ IgnoreGitignore: fc.Ignore.Gitignore,
+ IgnoreExtraPatterns: fc.Ignore.ExtraPatterns,
+ IgnoreLSPNotify: fc.Ignore.LSPNotifyIgnored,
+ }
+ out.mergeBasics(&tmp)
+ }
+
return out
}
@@ -925,6 +955,16 @@ func (a *App) mergeBasics(other *App) {
if s := strings.TrimSpace(other.Provider); s != "" {
a.Provider = s
}
+ // Ignore settings
+ if other.IgnoreGitignore != nil {
+ a.IgnoreGitignore = other.IgnoreGitignore
+ }
+ if len(other.IgnoreExtraPatterns) > 0 {
+ a.IgnoreExtraPatterns = slices.Clone(other.IgnoreExtraPatterns)
+ }
+ if other.IgnoreLSPNotify != nil {
+ a.IgnoreLSPNotify = other.IgnoreLSPNotify
+ }
}
// mergeSurfaceModels copies per-surface model and temperature overrides.
@@ -1141,17 +1181,17 @@ const ProjectConfigFilename = ".hexaiconfig.toml"
// ProjectConfigPath returns the path to the per-project config file if a git repository
// root is detected from the current working directory. Returns empty string otherwise.
func ProjectConfigPath() string {
- root := findGitRoot()
+ root := FindGitRoot()
if root == "" {
return ""
}
return filepath.Join(root, ProjectConfigFilename)
}
-// findGitRoot walks up from the current working directory looking for a .git
-// directory or file (worktrees use a .git file). Returns the directory
-// containing .git, or empty string if none is found.
-func findGitRoot() string {
+// FindGitRoot walks up from the current working directory to find the nearest
+// .git directory or file (worktrees use a .git file), returning its parent
+// path or "" if none is found.
+func FindGitRoot() string {
dir, err := os.Getwd()
if err != nil {
return ""
@@ -1402,6 +1442,28 @@ func loadFromEnv(logger *log.Logger) *App {
any = true
}
+ // Ignore settings (bool: "true"/"1" or "false"/"0")
+ if s := getenv("HEXAI_IGNORE_GITIGNORE"); s != "" {
+ b := s == "true" || s == "1"
+ out.IgnoreGitignore = &b
+ any = true
+ }
+ if s := getenv("HEXAI_IGNORE_EXTRA_PATTERNS"); s != "" {
+ parts := strings.Split(s, ",")
+ out.IgnoreExtraPatterns = nil
+ for _, p := range parts {
+ if t := strings.TrimSpace(p); t != "" {
+ out.IgnoreExtraPatterns = append(out.IgnoreExtraPatterns, t)
+ }
+ }
+ any = true
+ }
+ if s := getenv("HEXAI_IGNORE_LSP_NOTIFY"); s != "" {
+ b := s == "true" || s == "1"
+ out.IgnoreLSPNotify = &b
+ any = true
+ }
+
if !any {
return nil
}
diff --git a/internal/appconfig/config_test.go b/internal/appconfig/config_test.go
index 75cc7ee..b9dfe3a 100644
--- a/internal/appconfig/config_test.go
+++ b/internal/appconfig/config_test.go
@@ -625,9 +625,9 @@ func TestFindGitRoot(t *testing.T) {
if err := os.Chdir(nested); err != nil {
t.Fatalf("chdir: %v", err)
}
- root := findGitRoot()
+ root := FindGitRoot()
if root != dir {
- t.Fatalf("findGitRoot() = %q, want %q", root, dir)
+ t.Fatalf("FindGitRoot() = %q, want %q", root, dir)
}
// Test from a dir with no .git ancestor
@@ -635,9 +635,9 @@ func TestFindGitRoot(t *testing.T) {
if err := os.Chdir(noGit); err != nil {
t.Fatalf("chdir: %v", err)
}
- root = findGitRoot()
+ root = FindGitRoot()
if root != "" {
- t.Fatalf("findGitRoot() = %q, want empty", root)
+ t.Fatalf("FindGitRoot() = %q, want empty", root)
}
}
@@ -784,3 +784,112 @@ func TestProjectConfigPath(t *testing.T) {
t.Fatalf("ProjectConfigPath() = %q, want empty", path)
}
}
+
+func TestIgnoreConfig_Defaults(t *testing.T) {
+ clearHexaiEnv(t)
+ cfg := Load(nil)
+ if cfg.IgnoreGitignore == nil || !*cfg.IgnoreGitignore {
+ t.Error("expected IgnoreGitignore default true")
+ }
+ if cfg.IgnoreLSPNotify == nil || !*cfg.IgnoreLSPNotify {
+ t.Error("expected IgnoreLSPNotify default true")
+ }
+ if len(cfg.IgnoreExtraPatterns) != 0 {
+ t.Errorf("expected empty IgnoreExtraPatterns, got %v", cfg.IgnoreExtraPatterns)
+ }
+}
+
+func TestIgnoreConfig_FromFile(t *testing.T) {
+ clearHexaiEnv(t)
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "config.toml")
+ writeFile(t, cfgPath, `
+[ignore]
+gitignore = false
+extra_patterns = ["*.min.js", "dist/**"]
+lsp_notify_ignored = false
+`)
+ cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath})
+ if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore {
+ t.Error("expected IgnoreGitignore false from file")
+ }
+ if cfg.IgnoreLSPNotify == nil || *cfg.IgnoreLSPNotify {
+ t.Error("expected IgnoreLSPNotify false from file")
+ }
+ want := []string{"*.min.js", "dist/**"}
+ if !reflect.DeepEqual(cfg.IgnoreExtraPatterns, want) {
+ t.Errorf("IgnoreExtraPatterns = %v, want %v", cfg.IgnoreExtraPatterns, want)
+ }
+}
+
+func TestIgnoreConfig_EnvOverrides(t *testing.T) {
+ clearHexaiEnv(t)
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "config.toml")
+ writeFile(t, cfgPath, `
+[ignore]
+gitignore = true
+lsp_notify_ignored = true
+`)
+ withEnv(t, "HEXAI_IGNORE_GITIGNORE", "false")
+ withEnv(t, "HEXAI_IGNORE_LSP_NOTIFY", "0")
+ withEnv(t, "HEXAI_IGNORE_EXTRA_PATTERNS", "*.bak,*.tmp")
+ cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath})
+ if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore {
+ t.Error("expected IgnoreGitignore false from env override")
+ }
+ if cfg.IgnoreLSPNotify == nil || *cfg.IgnoreLSPNotify {
+ t.Error("expected IgnoreLSPNotify false from env override")
+ }
+ want := []string{"*.bak", "*.tmp"}
+ if !reflect.DeepEqual(cfg.IgnoreExtraPatterns, want) {
+ t.Errorf("IgnoreExtraPatterns = %v, want %v", cfg.IgnoreExtraPatterns, want)
+ }
+}
+
+func TestIgnoreConfig_ProjectOverride(t *testing.T) {
+ clearHexaiEnv(t)
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "config.toml")
+ writeFile(t, cfgPath, `
+[ignore]
+gitignore = true
+`)
+ // Set up a fake git repo with project override
+ projectDir := t.TempDir()
+ if err := os.Mkdir(filepath.Join(projectDir, ".git"), 0o755); err != nil {
+ t.Fatalf("mkdir .git: %v", err)
+ }
+ projectCfg := filepath.Join(projectDir, ProjectConfigFilename)
+ writeFile(t, projectCfg, `
+[ignore]
+gitignore = false
+extra_patterns = ["build/**"]
+`)
+ cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath, ProjectRoot: projectDir})
+ if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore {
+ t.Error("expected project override to set IgnoreGitignore false")
+ }
+ want := []string{"build/**"}
+ if !reflect.DeepEqual(cfg.IgnoreExtraPatterns, want) {
+ t.Errorf("IgnoreExtraPatterns = %v, want %v", cfg.IgnoreExtraPatterns, want)
+ }
+}
+
+func TestIgnoreConfig_DisableGitignore(t *testing.T) {
+ clearHexaiEnv(t)
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "config.toml")
+ writeFile(t, cfgPath, `
+[ignore]
+gitignore = false
+`)
+ cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath})
+ if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore {
+ t.Error("expected IgnoreGitignore false")
+ }
+ // LSP notify should still be true (default, not overridden)
+ if cfg.IgnoreLSPNotify == nil || !*cfg.IgnoreLSPNotify {
+ t.Error("expected IgnoreLSPNotify to remain true (default)")
+ }
+}