From d8df2be5dc1f3d6ea7e34bc477c6af9f450e0d7b Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Thu, 12 Mar 2026 12:57:55 +0100 Subject: [PATCH] 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 --- go.mod | 5 + go.sum | 22 +- internal/discover/discover.go | 61 ++++- internal/discover/gitignore.go | 63 +++++ internal/discover/gitignore_test.go | 378 ++++++++++++++++++++++++++++ 5 files changed, 516 insertions(+), 13 deletions(-) create mode 100644 internal/discover/gitignore.go create mode 100644 internal/discover/gitignore_test.go diff --git a/go.mod b/go.mod index d5c9002e8..b941b2b1c 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 233fde6dd..69c66d37c 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/discover/discover.go b/internal/discover/discover.go index 894acde4f..92c59be22 100644 --- a/internal/discover/discover.go +++ b/internal/discover/discover.go @@ -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 } diff --git a/internal/discover/gitignore.go b/internal/discover/gitignore.go new file mode 100644 index 000000000..15f55aae9 --- /dev/null +++ b/internal/discover/gitignore.go @@ -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 +} diff --git a/internal/discover/gitignore_test.go b/internal/discover/gitignore_test.go new file mode 100644 index 000000000..c6b856c7d --- /dev/null +++ b/internal/discover/gitignore_test.go @@ -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) + } + } +}