summaryrefslogtreecommitdiff
path: root/internal/ignore
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/ignore
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/ignore')
-rw-r--r--internal/ignore/checker.go90
-rw-r--r--internal/ignore/checker_test.go282
2 files changed, 372 insertions, 0 deletions
diff --git a/internal/ignore/checker.go b/internal/ignore/checker.go
new file mode 100644
index 0000000..92129b8
--- /dev/null
+++ b/internal/ignore/checker.go
@@ -0,0 +1,90 @@
+// Summary: Thread-safe gitignore-aware file checker that combines .gitignore
+// patterns with user-configured extra patterns. Used by the LSP server to
+// skip completions and code actions for ignored files.
+package ignore
+
+import (
+ "path/filepath"
+ "strings"
+ "sync"
+
+ gitignore "github.com/sabhiram/go-gitignore"
+)
+
+// Checker evaluates whether an absolute file path should be ignored based on
+// .gitignore patterns and/or user-configured extra patterns. It is safe for
+// concurrent use.
+type Checker struct {
+ mu sync.RWMutex
+ gitRoot string
+ giMatcher *gitignore.GitIgnore // compiled .gitignore (nil when disabled or missing)
+ exMatcher *gitignore.GitIgnore // compiled extra patterns (nil when empty)
+}
+
+// New creates a Checker. If useGitignore is true and gitRoot is non-empty, it
+// loads .gitignore from gitRoot. extraPatterns are always compiled (gitignore
+// syntax).
+func New(gitRoot string, useGitignore bool, extraPatterns []string) *Checker {
+ c := &Checker{gitRoot: gitRoot}
+ c.compile(useGitignore, extraPatterns)
+ return c
+}
+
+// IsIgnored returns whether absPath should be ignored and a human-readable
+// reason string. When the checker is nil, nothing is ignored.
+func (c *Checker) IsIgnored(absPath string) (ignored bool, reason string) {
+ if c == nil {
+ return false, ""
+ }
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+
+ rel, inside := c.relPath(absPath)
+
+ // Only check gitignore when the path is inside the git root
+ if inside && c.giMatcher != nil && c.giMatcher.MatchesPath(rel) {
+ return true, "matched .gitignore pattern"
+ }
+ if c.exMatcher != nil && c.exMatcher.MatchesPath(rel) {
+ return true, "matched extra ignore pattern"
+ }
+ return false, ""
+}
+
+// Update recompiles matchers for hot-reload. Thread-safe.
+func (c *Checker) Update(useGitignore bool, extraPatterns []string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.compile(useGitignore, extraPatterns)
+}
+
+// compile builds the gitignore and extra-pattern matchers. Must be called
+// under c.mu write lock (or during construction).
+func (c *Checker) compile(useGitignore bool, extraPatterns []string) {
+ c.giMatcher = nil
+ c.exMatcher = nil
+
+ if useGitignore && c.gitRoot != "" {
+ giPath := filepath.Join(c.gitRoot, ".gitignore")
+ if gi, err := gitignore.CompileIgnoreFile(giPath); err == nil {
+ c.giMatcher = gi
+ }
+ }
+ if len(extraPatterns) > 0 {
+ c.exMatcher = gitignore.CompileIgnoreLines(extraPatterns...)
+ }
+}
+
+// relPath converts absPath to a path relative to gitRoot. Returns the
+// relative path and true if the path is inside the git root; otherwise
+// returns the original path and false.
+func (c *Checker) relPath(absPath string) (string, bool) {
+ if c.gitRoot == "" {
+ return absPath, false
+ }
+ rel, err := filepath.Rel(c.gitRoot, absPath)
+ if err != nil || strings.HasPrefix(rel, "..") {
+ return absPath, false
+ }
+ return rel, true
+}
diff --git a/internal/ignore/checker_test.go b/internal/ignore/checker_test.go
new file mode 100644
index 0000000..3e3384c
--- /dev/null
+++ b/internal/ignore/checker_test.go
@@ -0,0 +1,282 @@
+package ignore
+
+import (
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+)
+
+// writeGitignore creates a .gitignore in dir with the given lines.
+func writeGitignore(t *testing.T, dir string, lines ...string) {
+ t.Helper()
+ content := ""
+ for _, l := range lines {
+ content += l + "\n"
+ }
+ if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(content), 0o644); err != nil {
+ t.Fatalf("write .gitignore: %v", err)
+ }
+}
+
+func TestSimpleWildcard(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "*.log")
+ c := New(dir, true, nil)
+
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "app.log")); !ign {
+ t.Error("expected app.log to be ignored")
+ }
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "debug.log")); !ign {
+ t.Error("expected debug.log to be ignored")
+ }
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "app.go")); ign {
+ t.Error("app.go should not be ignored")
+ }
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "log.txt")); ign {
+ t.Error("log.txt should not be ignored")
+ }
+}
+
+func TestDirectoryPattern(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "build/")
+ c := New(dir, true, nil)
+
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "build", "output.js")); !ign {
+ t.Error("expected build/output.js to be ignored")
+ }
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "rebuild", "x")); ign {
+ t.Error("rebuild/x should not be ignored")
+ }
+}
+
+func TestDoubleStarPattern(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "**/temp")
+ c := New(dir, true, nil)
+
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "a", "b", "temp")); !ign {
+ t.Error("expected a/b/temp to be ignored")
+ }
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "temp")); !ign {
+ t.Error("expected temp to be ignored")
+ }
+}
+
+func TestNegation(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "*.log", "!important.log")
+ c := New(dir, true, nil)
+
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "debug.log")); !ign {
+ t.Error("expected debug.log to be ignored")
+ }
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "important.log")); ign {
+ t.Error("important.log should not be ignored (negated)")
+ }
+}
+
+func TestComments(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "# comment", "*.tmp")
+ c := New(dir, true, nil)
+
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "x.tmp")); !ign {
+ t.Error("expected x.tmp to be ignored")
+ }
+ // A file literally named "# comment" should not be ignored
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "# comment")); ign {
+ t.Error("file named '# comment' should not be ignored")
+ }
+}
+
+func TestExtensionGroups(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "*.out", "*.html")
+ c := New(dir, true, nil)
+
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "coverage.out")); !ign {
+ t.Error("expected coverage.out to be ignored")
+ }
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "main.go")); ign {
+ t.Error("main.go should not be ignored")
+ }
+}
+
+func TestNestedDirs(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "vendor/**")
+ c := New(dir, true, nil)
+
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "vendor", "lib", "x.go")); !ign {
+ t.Error("expected vendor/lib/x.go to be ignored")
+ }
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "myvendor", "x")); ign {
+ t.Error("myvendor/x should not be ignored")
+ }
+}
+
+func TestExtraPatternsOnly(t *testing.T) {
+ // No gitignore, only extra patterns
+ c := New("", false, []string{"*.min.js", "dist/**"})
+
+ if ign, reason := c.IsIgnored("/project/app.min.js"); !ign {
+ t.Error("expected app.min.js to be ignored")
+ } else if reason != "matched extra ignore pattern" {
+ t.Errorf("unexpected reason: %s", reason)
+ }
+ if ign, _ := c.IsIgnored("/project/dist/bundle.js"); !ign {
+ t.Error("expected dist/bundle.js to be ignored")
+ }
+ if ign, _ := c.IsIgnored("/project/app.js"); ign {
+ t.Error("app.js should not be ignored")
+ }
+}
+
+func TestCombinedGitignoreAndExtra(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "*.log")
+ c := New(dir, true, []string{"*.min.js"})
+
+ // gitignore match
+ if ign, reason := c.IsIgnored(filepath.Join(dir, "app.log")); !ign {
+ t.Error("expected app.log to be ignored")
+ } else if reason != "matched .gitignore pattern" {
+ t.Errorf("unexpected reason: %s", reason)
+ }
+ // extra pattern match
+ if ign, reason := c.IsIgnored(filepath.Join(dir, "app.min.js")); !ign {
+ t.Error("expected app.min.js to be ignored")
+ } else if reason != "matched extra ignore pattern" {
+ t.Errorf("unexpected reason: %s", reason)
+ }
+ // neither match
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "main.go")); ign {
+ t.Error("main.go should not be ignored")
+ }
+}
+
+func TestNilChecker(t *testing.T) {
+ var c *Checker
+ if ign, _ := c.IsIgnored("/some/file.go"); ign {
+ t.Error("nil checker should never ignore")
+ }
+}
+
+func TestEmptyChecker(t *testing.T) {
+ c := New("", false, nil)
+ if ign, _ := c.IsIgnored("/some/file.go"); ign {
+ t.Error("empty checker should never ignore")
+ }
+}
+
+func TestUpdatePatterns(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "*.log")
+ c := New(dir, true, nil)
+
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "app.log")); !ign {
+ t.Error("expected app.log ignored initially")
+ }
+
+ // Update: disable gitignore, add extra pattern
+ c.Update(false, []string{"*.tmp"})
+
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "app.log")); ign {
+ t.Error("app.log should not be ignored after disabling gitignore")
+ }
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "x.tmp")); !ign {
+ t.Error("expected x.tmp ignored after update")
+ }
+}
+
+func TestThreadSafety(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "*.log")
+ c := New(dir, true, nil)
+
+ var wg sync.WaitGroup
+ // Concurrent reads
+ for i := 0; i < 50; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ c.IsIgnored(filepath.Join(dir, "app.log"))
+ c.IsIgnored(filepath.Join(dir, "main.go"))
+ }()
+ }
+ // Concurrent updates
+ for i := 0; i < 10; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ c.Update(true, []string{"*.tmp"})
+ }()
+ }
+ wg.Wait()
+}
+
+func TestNoGitRoot(t *testing.T) {
+ // gitRoot empty but gitignore enabled — should not crash, gitignore has no effect
+ c := New("", true, []string{"*.bak"})
+
+ if ign, _ := c.IsIgnored("/any/file.go"); ign {
+ t.Error("should not ignore .go files")
+ }
+ if ign, _ := c.IsIgnored("/any/file.bak"); !ign {
+ t.Error("extra patterns should still work without git root")
+ }
+}
+
+func TestPathOutsideGitRoot(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "*.log")
+ c := New(dir, true, nil)
+
+ // Path outside the git root — relPath returns absolute, gitignore won't match
+ if ign, _ := c.IsIgnored("/completely/elsewhere/app.log"); ign {
+ t.Error("files outside git root should not be matched by gitignore")
+ }
+}
+
+func TestMixedRealGitignore(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir,
+ "# Build outputs",
+ "bin/",
+ "*.exe",
+ "*.dll",
+ "",
+ "# Dependencies",
+ "vendor/**",
+ "",
+ "# IDE",
+ ".idea/",
+ ".vscode/",
+ )
+ c := New(dir, true, nil)
+
+ ignored := []string{
+ filepath.Join(dir, "bin", "app"),
+ filepath.Join(dir, "main.exe"),
+ filepath.Join(dir, "vendor", "lib", "x.go"),
+ filepath.Join(dir, ".idea", "workspace.xml"),
+ }
+ for _, p := range ignored {
+ if ign, _ := c.IsIgnored(p); !ign {
+ t.Errorf("expected %s to be ignored", p)
+ }
+ }
+
+ allowed := []string{
+ filepath.Join(dir, "main.go"),
+ filepath.Join(dir, "internal", "app.go"),
+ filepath.Join(dir, "README.md"),
+ }
+ for _, p := range allowed {
+ if ign, _ := c.IsIgnored(p); ign {
+ t.Errorf("%s should not be ignored", p)
+ }
+ }
+}