Respect .gitignore, add .cbmignore support, skip symlinks, expand ignore list

- Parse full .gitignore hierarchy (nested files + .git/info/exclude) via
  boyter/gocodewalker go-gitignore library for git repos
- Add .cbmignore as repo-root gitignore-style file that stacks on top of
  .gitignore for indexer-specific exclusions (e.g. generated code in git)
- Skip symlinked files and directories to prevent duplicate indexing
- Move bin/, build/, out/ from always-on IGNORE_PATTERNS to fast-mode-only
  (prevents false exclusions in Go, CMake, Maven projects)
- Add 28 ecosystem-specific cache/artifact dirs to IGNORE_PATTERNS:
  .next, .nuxt, .svelte-kit, .angular, .turbo, .terraform, bazel-bin,
  zig-cache, .cargo, .stack-work, elm-stuff, .metals, .bloop, etc.
- Keep .cgrignore for backward compatibility
- 17 new tests: gitignore patterns, negation, globstar, nested, exclude file,
  non-git repos, dir-only, cbmignore stacking, symlinks, pattern changes
This commit is contained in:
Martin Vogel committed 2026-03-12 12:57:55 +01:00
1 parent a208b34c8b
commit d8df2be5dc
5 files changed
+516 -13

No files matched your search

+5
View File
@@ -3,17 +3,22 @@ module github.com/DeusData/codebase-memory-mcp
go 1.26
require (
github.com/boyter/gocodewalker v1.5.1
github.com/fsnotify/fsnotify v1.9.0
github.com/mattn/go-sqlite3 v1.14.34
github.com/modelcontextprotocol/go-sdk v1.4.0
github.com/zeebo/xxh3 v1.1.0
go.uber.org/goleak v1.3.0
golang.org/x/sync v0.19.0
golang.org/x/sys v0.40.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.3 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
+21 -1
View File
@@ -1,3 +1,12 @@
github.com/boyter/gocodewalker v1.5.1 h1:0YeK2QAkd+ymW5MsagMZapIXD3v9/vrZl0HkFSLpKsw=
github.com/boyter/gocodewalker v1.5.1/go.mod h1:9k+yM6+fIx61F0xI9ChXEGE5DYoLhggw8AxSOtW+kKo=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -6,20 +15,30 @@ github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbc
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8=
github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
@@ -28,7 +47,8 @@ golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+49 -12
View File
@@ -15,18 +15,34 @@ import (
// IGNORE_PATTERNS are directory names to skip during discovery.
var IGNORE_PATTERNS = map[string]bool{
".cache": true, ".claude": true, ".eclipse": true, ".eggs": true,
".env": true, ".git": true, ".gradle": true, ".hg": true,
".idea": true, ".maven": true, ".mypy_cache": true, ".nox": true,
".npm": true, ".nyc_output": true, ".pnpm-store": true,
".pytest_cache": true, ".qdrant_code_embeddings": true,
".ruff_cache": true, ".svn": true, ".tmp": true, ".tox": true,
".venv": true, ".vs": true, ".vscode": true, ".yarn": true,
"__pycache__": true, "bin": true, "bower_components": true,
"build": true, "coverage": true, "dist": true, "env": true,
"htmlcov": true, "node_modules": true, "obj": true, "out": true,
"Pods": true, "site-packages": true, "target": true, "temp": true,
"tmp": true, "vendor": true, "venv": true,
// VCS / IDE
".git": true, ".hg": true, ".svn": true,
".idea": true, ".vs": true, ".vscode": true, ".eclipse": true, ".claude": true,
// Python
".cache": true, ".eggs": true, ".env": true, ".mypy_cache": true, ".nox": true,
".pytest_cache": true, ".ruff_cache": true, ".tox": true, ".venv": true,
"__pycache__": true, "env": true, "htmlcov": true, "site-packages": true, "venv": true,
// JS/TS
".npm": true, ".nyc_output": true, ".pnpm-store": true, ".yarn": true,
"bower_components": true, "coverage": true, "node_modules": true,
// JS/TS framework caches
".next": true, ".nuxt": true, ".svelte-kit": true, ".angular": true,
".turbo": true, ".parcel-cache": true, ".docusaurus": true, ".expo": true,
// Build artifacts
"dist": true, "obj": true, "Pods": true, "target": true, "temp": true, "tmp": true,
// Build system caches
".terraform": true, ".serverless": true,
"bazel-bin": true, "bazel-out": true, "bazel-testlogs": true,
// Language-specific caches
".cargo": true, ".stack-work": true, ".dart_tool": true,
"zig-cache": true, "zig-out": true,
".metals": true, ".bloop": true, ".bsp": true,
".ccls-cache": true, ".clangd": true,
"elm-stuff": true, "_opam": true, ".cpcache": true, ".shadow-cljs": true,
// Deploy/hosting
".vercel": true, ".netlify": true,
// Misc
".qdrant_code_embeddings": true, ".tmp": true, "vendor": true,
}
// IGNORE_SUFFIXES are file suffixes that are never source files.
@@ -82,6 +98,8 @@ var fastIgnoreDirs = map[string]bool{
"e2e": true, "integration": true,
"locale": true, "locales": true, "i18n": true, "l10n": true,
"scripts": true, "tools": true, "hack": true,
// Generic dirs moved from IGNORE_PATTERNS (cause false exclusions in Go, CMake, Maven)
"bin": true, "build": true, "out": true,
}
// fastIgnoreSuffixes are additional file extensions skipped in fast mode.
@@ -323,6 +341,9 @@ func Discover(ctx context.Context, repoPath string, opts *Options) ([]FileInfo,
mode = opts.Mode
}
// Load gitignore + cbmignore matchers (nil-safe for non-git repos)
matchers := loadIgnoreMatchers(repoPath)
err = filepath.Walk(repoPath, func(path string, info os.FileInfo, walkErr error) error {
if err := ctx.Err(); err != nil {
return err
@@ -331,12 +352,28 @@ func Discover(ctx context.Context, repoPath string, opts *Options) ([]FileInfo,
return filepath.SkipDir
}
// Skip symlinks — prevents duplicate indexing
if info.Mode()&os.ModeSymlink != 0 {
return nil
}
rel, _ := filepath.Rel(repoPath, path)
if info.IsDir() {
// Hardcoded patterns first (fast O(1) map lookup)
if shouldSkipDir(info.Name(), rel, extraIgnore, mode) {
return filepath.SkipDir
}
// Then gitignore + cbmignore (skip root — library panics on base==path)
if rel != "." && matchers.shouldIgnore(path, true) {
slog.Debug("discover.gitignore_skip", "dir", rel)
return filepath.SkipDir
}
return nil
}
// Gitignore + cbmignore check for files
if matchers.shouldIgnore(path, false) {
return nil
}
+63
View File
@@ -0,0 +1,63 @@
package discover
import (
"log/slog"
"os"
"path/filepath"
gitignore "github.com/boyter/gocodewalker/go-gitignore"
)
// ignoreMatchers holds the layered ignore matchers for a repository.
// Evaluation order: hardcoded patterns (fastest) → gitignore → cbmignore.
type ignoreMatchers struct {
gitignore gitignore.GitIgnore // .gitignore hierarchy + .git/info/exclude (nil for non-git repos)
cbmignore gitignore.GitIgnore // .cbmignore at repo root (nil if absent)
}
// loadIgnoreMatchers loads all gitignore-style matchers for the repository.
// .gitignore is loaded only for git repos (presence of .git dir).
// .cbmignore stacks on top — patterns there additionally exclude from indexing.
func loadIgnoreMatchers(repoPath string) ignoreMatchers {
var m ignoreMatchers
// .gitignore — only for git repos
gitDir := filepath.Join(repoPath, ".git")
if info, err := os.Stat(gitDir); err == nil && info.IsDir() {
gi, err := gitignore.NewRepository(repoPath)
if err != nil {
slog.Warn("discover.gitignore_load_err", "err", err)
} else {
m.gitignore = gi
}
}
// .cbmignore — repo-root file with gitignore-style patterns
cbmPath := filepath.Join(repoPath, ".cbmignore")
if _, err := os.Stat(cbmPath); err == nil {
ci, err := gitignore.NewFromFile(cbmPath)
if err != nil {
slog.Warn("discover.cbmignore_load_err", "err", err)
} else {
m.cbmignore = ci
}
}
return m
}
// shouldIgnore checks if a path should be ignored by gitignore or cbmignore.
// absPath must be absolute; isDir indicates whether the path is a directory.
func (m *ignoreMatchers) shouldIgnore(absPath string, isDir bool) bool {
if m.gitignore != nil {
if match := m.gitignore.Absolute(absPath, isDir); match != nil && match.Ignore() {
return true
}
}
if m.cbmignore != nil {
if match := m.cbmignore.Absolute(absPath, isDir); match != nil && match.Ignore() {
return true
}
}
return false
}
+378
View File
@@ -0,0 +1,378 @@
package discover
import (
"context"
"os"
"path/filepath"
"runtime"
"testing"
)
// initGitRepo creates a minimal .git directory to make the temp dir look like a git repo.
func initGitRepo(t *testing.T, dir string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(dir, ".git", "info"), 0o755); err != nil {
t.Fatal(err)
}
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
func discoverRelPaths(t *testing.T, dir string) map[string]bool {
t.Helper()
files, err := Discover(context.Background(), dir, nil)
if err != nil {
t.Fatal(err)
}
m := make(map[string]bool, len(files))
for _, f := range files {
m[f.RelPath] = true
}
return m
}
func TestGitignoreBasicPatterns(t *testing.T) {
dir := t.TempDir()
initGitRepo(t, dir)
writeFile(t, filepath.Join(dir, ".gitignore"), "*.log\nbuild/\n")
writeFile(t, filepath.Join(dir, "main.go"), "package main\n")
writeFile(t, filepath.Join(dir, "app.log"), "log data\n")
writeFile(t, filepath.Join(dir, "build", "out.go"), "package build\n")
found := discoverRelPaths(t, dir)
if !found["main.go"] {
t.Error("main.go should be discovered")
}
if found["app.log"] {
t.Error("app.log should be ignored by *.log pattern")
}
if found["build/out.go"] {
t.Error("build/out.go should be ignored by build/ pattern")
}
}
func TestGitignoreNegation(t *testing.T) {
dir := t.TempDir()
initGitRepo(t, dir)
writeFile(t, filepath.Join(dir, ".gitignore"), "*.log\n!important.log\n")
writeFile(t, filepath.Join(dir, "debug.log"), "debug\n")
writeFile(t, filepath.Join(dir, "important.log"), "keep me\n")
// .log isn't a supported language, so add a Go file to prove discovery works
writeFile(t, filepath.Join(dir, "main.go"), "package main\n")
files, err := Discover(context.Background(), dir, nil)
if err != nil {
t.Fatal(err)
}
found := make(map[string]bool)
for _, f := range files {
found[f.RelPath] = true
}
if !found["main.go"] {
t.Error("main.go should be discovered")
}
// Note: .log files aren't a supported language, so they won't appear in Discover
// results regardless. The gitignore negation is still effective — we test it
// via the matcher directly.
m := loadIgnoreMatchers(dir)
if !m.shouldIgnore(filepath.Join(dir, "debug.log"), false) {
t.Error("debug.log should be ignored")
}
if m.shouldIgnore(filepath.Join(dir, "important.log"), false) {
t.Error("important.log should NOT be ignored (negated)")
}
}
func TestGitignoreGlobstar(t *testing.T) {
dir := t.TempDir()
initGitRepo(t, dir)
writeFile(t, filepath.Join(dir, ".gitignore"), "**/vendor/\n")
writeFile(t, filepath.Join(dir, "main.go"), "package main\n")
writeFile(t, filepath.Join(dir, "vendor", "lib.go"), "package vendor\n")
writeFile(t, filepath.Join(dir, "src", "vendor", "dep.go"), "package vendor\n")
found := discoverRelPaths(t, dir)
if !found["main.go"] {
t.Error("main.go should be discovered")
}
// vendor/ is also in IGNORE_PATTERNS, but the gitignore globstar should catch nested ones
if found["src/vendor/dep.go"] {
t.Error("src/vendor/dep.go should be ignored by **/vendor/ pattern")
}
}
func TestGitignoreNested(t *testing.T) {
dir := t.TempDir()
initGitRepo(t, dir)
writeFile(t, filepath.Join(dir, ".gitignore"), "*.txt\n")
writeFile(t, filepath.Join(dir, "subdir", ".gitignore"), "!readme.txt\n")
writeFile(t, filepath.Join(dir, "top.txt"), "top\n")
writeFile(t, filepath.Join(dir, "subdir", "readme.txt"), "readme\n")
writeFile(t, filepath.Join(dir, "subdir", "notes.txt"), "notes\n")
// Test via matchers since .txt isn't a supported language
m := loadIgnoreMatchers(dir)
if !m.shouldIgnore(filepath.Join(dir, "top.txt"), false) {
t.Error("top.txt should be ignored by root *.txt")
}
if m.shouldIgnore(filepath.Join(dir, "subdir", "readme.txt"), false) {
t.Error("subdir/readme.txt should NOT be ignored (negated by subdir/.gitignore)")
}
if !m.shouldIgnore(filepath.Join(dir, "subdir", "notes.txt"), false) {
t.Error("subdir/notes.txt should be ignored by root *.txt")
}
}
func TestGitignoreExcludeFile(t *testing.T) {
dir := t.TempDir()
initGitRepo(t, dir)
// Pattern only in .git/info/exclude
writeFile(t, filepath.Join(dir, ".git", "info", "exclude"), "*.secret\n")
writeFile(t, filepath.Join(dir, "main.go"), "package main\n")
m := loadIgnoreMatchers(dir)
if !m.shouldIgnore(filepath.Join(dir, "config.secret"), false) {
t.Error("config.secret should be ignored by .git/info/exclude")
}
if m.shouldIgnore(filepath.Join(dir, "main.go"), false) {
t.Error("main.go should not be ignored")
}
}
func TestGitignoreNonGitRepo(t *testing.T) {
dir := t.TempDir()
// No .git directory
writeFile(t, filepath.Join(dir, "main.go"), "package main\n")
m := loadIgnoreMatchers(dir)
if m.gitignore != nil {
t.Error("gitignore should be nil for non-git repo")
}
// Discovery should still work with hardcoded patterns
found := discoverRelPaths(t, dir)
if !found["main.go"] {
t.Error("main.go should be discovered in non-git repo")
}
}
func TestGitignoreDirOnly(t *testing.T) {
dir := t.TempDir()
initGitRepo(t, dir)
writeFile(t, filepath.Join(dir, ".gitignore"), "logs/\n")
writeFile(t, filepath.Join(dir, "logs", "app.go"), "package logs\n")
m := loadIgnoreMatchers(dir)
// Directory should be ignored
if !m.shouldIgnore(filepath.Join(dir, "logs"), true) {
t.Error("logs/ directory should be ignored")
}
// File named "logs" should NOT be ignored (trailing slash = dir-only)
if m.shouldIgnore(filepath.Join(dir, "logs"), false) {
t.Error("file named 'logs' should NOT be ignored by 'logs/' pattern")
}
}
func TestCBMIgnoreBasic(t *testing.T) {
dir := t.TempDir()
initGitRepo(t, dir)
// .cbmignore with patterns that stack on top of .gitignore
writeFile(t, filepath.Join(dir, ".cbmignore"), "generated/\n*.pb.go\n")
writeFile(t, filepath.Join(dir, "main.go"), "package main\n")
writeFile(t, filepath.Join(dir, "generated", "types.go"), "package generated\n")
writeFile(t, filepath.Join(dir, "api.pb.go"), "package api\n")
found := discoverRelPaths(t, dir)
if !found["main.go"] {
t.Error("main.go should be discovered")
}
if found["generated/types.go"] {
t.Error("generated/types.go should be ignored by .cbmignore")
}
if found["api.pb.go"] {
t.Error("api.pb.go should be ignored by .cbmignore")
}
}
func TestCBMIgnoreStacksOnGitignore(t *testing.T) {
dir := t.TempDir()
initGitRepo(t, dir)
// .gitignore ignores *.log
writeFile(t, filepath.Join(dir, ".gitignore"), "*.log\n")
// .cbmignore additionally ignores docs/
writeFile(t, filepath.Join(dir, ".cbmignore"), "docs/\n")
writeFile(t, filepath.Join(dir, "main.go"), "package main\n")
writeFile(t, filepath.Join(dir, "docs", "api.go"), "package docs\n")
m := loadIgnoreMatchers(dir)
// Both matchers should work
if !m.shouldIgnore(filepath.Join(dir, "app.log"), false) {
t.Error("app.log should be ignored by .gitignore")
}
if !m.shouldIgnore(filepath.Join(dir, "docs"), true) {
t.Error("docs/ should be ignored by .cbmignore")
}
if m.shouldIgnore(filepath.Join(dir, "main.go"), false) {
t.Error("main.go should not be ignored")
}
// End-to-end via Discover
found := discoverRelPaths(t, dir)
if !found["main.go"] {
t.Error("main.go should be discovered")
}
if found["docs/api.go"] {
t.Error("docs/api.go should be ignored by .cbmignore")
}
}
func TestCBMIgnoreWithoutGitRepo(t *testing.T) {
dir := t.TempDir()
// No .git — but .cbmignore should still work
writeFile(t, filepath.Join(dir, ".cbmignore"), "scratch/\n")
writeFile(t, filepath.Join(dir, "main.go"), "package main\n")
writeFile(t, filepath.Join(dir, "scratch", "tmp.go"), "package scratch\n")
found := discoverRelPaths(t, dir)
if !found["main.go"] {
t.Error("main.go should be discovered")
}
if found["scratch/tmp.go"] {
t.Error("scratch/tmp.go should be ignored by .cbmignore")
}
}
func TestSymlinkedFilesSkipped(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require elevated privileges on Windows")
}
dir := t.TempDir()
initGitRepo(t, dir)
writeFile(t, filepath.Join(dir, "real.go"), "package main\n")
if err := os.Symlink(filepath.Join(dir, "real.go"), filepath.Join(dir, "link.go")); err != nil {
t.Fatal(err)
}
found := discoverRelPaths(t, dir)
if !found["real.go"] {
t.Error("real.go should be discovered")
}
// filepath.Walk uses Lstat, so symlinked files show ModeSymlink
// Our check should skip them
if found["link.go"] {
t.Error("link.go (symlink) should be skipped")
}
}
func TestSymlinkedDirsSkipped(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require elevated privileges on Windows")
}
dir := t.TempDir()
initGitRepo(t, dir)
writeFile(t, filepath.Join(dir, "src", "main.go"), "package main\n")
if err := os.Symlink(filepath.Join(dir, "src"), filepath.Join(dir, "src-link")); err != nil {
t.Fatal(err)
}
found := discoverRelPaths(t, dir)
if !found["src/main.go"] {
t.Error("src/main.go should be discovered")
}
// filepath.Walk does NOT follow symlinked dirs, so src-link/ contents won't appear.
// The symlinked dir entry itself should be skipped by our ModeSymlink check.
if found["src-link/main.go"] {
t.Error("src-link/main.go should not appear")
}
}
func TestNewIgnorePatterns(t *testing.T) {
dir := t.TempDir()
initGitRepo(t, dir)
// Create directories that should now be in IGNORE_PATTERNS
dirs := []string{".next", ".terraform", "zig-cache", ".cargo", "elm-stuff", "bazel-out"}
for _, d := range dirs {
writeFile(t, filepath.Join(dir, d, "file.go"), "package x\n")
}
writeFile(t, filepath.Join(dir, "main.go"), "package main\n")
found := discoverRelPaths(t, dir)
if !found["main.go"] {
t.Error("main.go should be discovered")
}
for _, d := range dirs {
if found[d+"/file.go"] {
t.Errorf("%s/file.go should be ignored by IGNORE_PATTERNS", d)
}
}
}
func TestGenericDirsNotIgnoredInFullMode(t *testing.T) {
dir := t.TempDir()
initGitRepo(t, dir)
// bin, build, out were moved to fastIgnoreDirs — should be discovered in full mode
dirs := []string{"bin", "build", "out"}
for _, d := range dirs {
writeFile(t, filepath.Join(dir, d, "main.go"), "package "+d+"\n")
}
found := discoverRelPaths(t, dir)
for _, d := range dirs {
if !found[d+"/main.go"] {
t.Errorf("%s/main.go should be discovered in full mode", d)
}
}
}
func TestGenericDirsIgnoredInFastMode(t *testing.T) {
dir := t.TempDir()
initGitRepo(t, dir)
dirs := []string{"bin", "build", "out"}
for _, d := range dirs {
writeFile(t, filepath.Join(dir, d, "main.go"), "package "+d+"\n")
}
files, err := Discover(context.Background(), dir, &Options{Mode: ModeFast})
if err != nil {
t.Fatal(err)
}
found := make(map[string]bool, len(files))
for _, f := range files {
found[f.RelPath] = true
}
for _, d := range dirs {
if found[d+"/main.go"] {
t.Errorf("%s/main.go should be ignored in fast mode", d)
}
}
}