diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-08 10:39:51 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-08 10:39:51 +0200 |
| commit | d5b13224737a9f66c3d5113a885603b32867d740 (patch) | |
| tree | d482cc965a65be22604800fe6772279c52961b99 /internal/appconfig | |
| parent | bd698b257a548d835fbc2675ff5be5e1a69ff229 (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.go | 76 | ||||
| -rw-r--r-- | internal/appconfig/config_test.go | 117 |
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)") + } +} |
