chore: import upstream snapshot with attribution
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:42 +08:00
commit a06f331eb8
3186 changed files with 689843 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
// Package excludes provides a unified path-exclusion matcher used by
// both the indexer's initial walk and the watcher's live event filter.
// Patterns follow .gitignore semantics (via go-gitignore): leading '/'
// anchors at the root, trailing '/' restricts to directories, '!' negates,
// '**' matches any number of path segments.
package excludes
// Builtin is the superset of directory/file patterns that Gortex always
// excludes, regardless of user config. It merges what the indexer and
// watcher used to maintain as two divergent hardcoded lists.
//
// Users can re-include an entry by listing "!pattern" in global,
// RepoEntry, or workspace config.
var Builtin = []string{
".git/",
".hg/",
".svn/",
".terraform/",
".gortex-cache/",
".gortex/", // Gortex's per-repo state dir (quarantine, merkle tree)
".claude/",
".kiro/",
"node_modules/",
"vendor/",
".venv/",
"venv/",
"__pycache__/",
".mypy_cache/",
".tox/",
".next/",
"target/",
"build/",
"dist/",
// Package-manager + build dirs for non-JS/non-Go ecosystems. These
// are indexed-by-default without these entries, which pollutes the
// graph with upstream code (e.g. CocoaPods' sqlite3.c — 150k+ lines)
// that users can't act on. Names are unambiguous — no first-party
// project uses `Pods/` or `.dart_tool/` for its own source.
"Pods/", // CocoaPods (iOS/macOS)
".gradle/", // Gradle build cache (Android/JVM)
".bundle/", // Ruby Bundler cache
".dart_tool/", // Dart/Flutter build cache
".pub-cache/", // Dart global pub cache, occasionally vendored
"*.tmp",
// Editor scratch/backup files. Vim cycles swap suffixes backward
// through the alphabet (.swp → .swo → .swn → ...); neovim writes a
// .swpx auxiliary alongside .swp on some platforms.
"*.swp",
"*.swo",
"*.swn",
"*.swm",
"*.swpx",
"*~",
}
+170
View File
@@ -0,0 +1,170 @@
package excludes
import (
"path"
"strings"
)
// generatedSuffixes lists file-name suffixes that mark generated code
// across the languages Gortex indexes. Kept here (a leaf package) so
// both the MCP response-envelope notes and the search rerank pipeline
// share one source of truth without an import cycle.
var generatedSuffixes = []string{
// Protobuf / gRPC stubs.
".pb.go", ".pb.cc", ".pb.h", ".pb.swift",
"_pb2.py", "_pb2_grpc.py", "_pb2.pyi",
"_pb.ts", "_pb.js", "_grpc_pb.ts", "_grpc_pb.js",
// Go generators.
"_gen.go", ".gen.go", "_generated.go", ".generated.go",
// TS / JS generators.
".generated.ts", ".generated.tsx", ".generated.js", ".generated.jsx",
".gen.ts", ".gen.tsx", ".gen.js", ".gen.jsx",
// Rust.
".generated.rs",
// Dart generators (build_runner, freezed, protobuf, chopper, auto_route, …).
".g.dart", ".freezed.dart", ".pb.dart", ".pbgrpc.dart", ".pbenum.dart",
".pbjson.dart", ".pbserver.dart", ".chopper.dart", ".config.dart", ".gr.dart",
// C#.
".g.cs", ".designer.cs",
}
// IsGenerated reports whether a file name matches a common
// code-generation convention — protobuf stubs, *_gen.go, mocks,
// Kubernetes zz_generated deepcopy, Dart/C# generators, and friends.
// Edits to such files are overwritten by their generator, so callers
// (omission notes, retrieval ranking) treat them as second-class.
func IsGenerated(p string) bool {
if p == "" {
return false
}
base := strings.ToLower(path.Base(filepathToSlash(p)))
for _, suf := range generatedSuffixes {
if strings.HasSuffix(base, suf) {
return true
}
}
if strings.HasPrefix(base, "zz_generated") {
return true
}
if strings.HasSuffix(base, ".go") &&
(strings.HasPrefix(base, "mock_") || strings.HasSuffix(base, "_mock.go") ||
strings.HasSuffix(base, "_mocks.go") || strings.HasSuffix(base, ".pulsar.go")) {
return true
}
// Java protobuf / gRPC generated classes: FooOuterClass.java, FooGrpc.java.
if strings.HasSuffix(base, "grpc.java") || strings.HasSuffix(base, "outerclass.java") {
return true
}
// Minified bundles: not generated-from-source, but "don't edit, don't rank
// highly" the same way — codegraph groups them here.
if strings.HasSuffix(base, ".min.js") || strings.HasSuffix(base, ".min.mjs") {
return true
}
return false
}
// GeneratedPeerPaths returns the plausible hand-written peer file
// paths a generated file shadows — the "same-named implementation"
// gate the retrieval ranker uses before down-ranking a generated
// file. For foo.pb.go the peer is foo.go; for mock_user.go it is
// user.go; for user_pb2.py it is user.py.
//
// Returns nil when no clean peer name can be derived (e.g.
// zz_generated.deepcopy.go has no same-named hand-written twin). A
// nil result means "do not gate" — i.e. leave the generated file
// un-penalised, which is the safe default: a generated file that is
// the only definition should not be demoted into oblivion.
func GeneratedPeerPaths(p string) []string {
if p == "" {
return nil
}
norm := filepathToSlash(p)
dir := path.Dir(norm)
base := path.Base(norm)
lower := strings.ToLower(base)
join := func(name string) string {
if dir == "." || dir == "" {
return name
}
return dir + "/" + name
}
// Suffix markers: strip the generated marker, swap in the
// hand-written extension. Ordered longest-first so _pb2_grpc.py
// wins over _pb2.py and .designer.cs over .cs.
suffixRules := []struct{ suf, ext string }{
// Python.
{"_pb2_grpc.py", ".py"},
{"_pb2.pyi", ".py"},
{"_pb2.py", ".py"},
// Go.
{".pb.go", ".go"},
{"_generated.go", ".go"},
{".generated.go", ".go"},
{"_gen.go", ".go"},
{".gen.go", ".go"},
{"_mocks.go", ".go"},
{"_mock.go", ".go"},
{".pulsar.go", ".go"},
// TS / JS — `_grpc_pb` before `_pb`, longer `.generated`/`.gen` first.
{"_grpc_pb.ts", ".ts"},
{"_grpc_pb.js", ".js"},
{"_pb.ts", ".ts"},
{"_pb.js", ".js"},
{".generated.tsx", ".tsx"},
{".generated.jsx", ".jsx"},
{".generated.ts", ".ts"},
{".generated.js", ".js"},
{".gen.tsx", ".tsx"},
{".gen.jsx", ".jsx"},
{".gen.ts", ".ts"},
{".gen.js", ".js"},
{".min.mjs", ".mjs"},
{".min.js", ".js"},
// Rust.
{".generated.rs", ".rs"},
// Dart.
{".pbserver.dart", ".dart"},
{".pbgrpc.dart", ".dart"},
{".pbenum.dart", ".dart"},
{".pbjson.dart", ".dart"},
{".chopper.dart", ".dart"},
{".freezed.dart", ".dart"},
{".config.dart", ".dart"},
{".pb.dart", ".dart"},
{".gr.dart", ".dart"},
{".g.dart", ".dart"},
// C#.
{".designer.cs", ".cs"},
{".g.cs", ".cs"},
// Java generated classes (no separator before the marker).
{"grpc.java", ".java"},
{"outerclass.java", ".java"},
}
for _, r := range suffixRules {
if strings.HasSuffix(lower, r.suf) {
stem := base[:len(base)-len(r.suf)]
if stem == "" {
return nil
}
return []string{join(stem + r.ext)}
}
}
// Prefix marker: mock_user.go shadows user.go.
if strings.HasPrefix(lower, "mock_") && strings.HasSuffix(lower, ".go") {
rest := base[len("mock_"):]
if rest != "" && rest != ".go" {
return []string{join(rest)}
}
}
return nil
}
// filepathToSlash normalises backslashes to forward slashes without
// pulling in path/filepath (which would make the leaf package
// OS-aware). Graph paths are always stored forward-slashed.
func filepathToSlash(p string) string {
return strings.ReplaceAll(p, "\\", "/")
}
+112
View File
@@ -0,0 +1,112 @@
package excludes
import (
"reflect"
"testing"
)
func TestIsGenerated(t *testing.T) {
cases := []struct {
path string
want bool
}{
{"api/user.pb.go", true},
{"api/user.pb.cc", true},
{"api/user.pb.h", true},
{"api/user.pb.swift", true},
{"proto/user_pb2.py", true},
{"proto/user_pb2_grpc.py", true},
{"x_gen.go", true},
{"x.gen.go", true},
{"x_generated.go", true},
{"x.generated.go", true},
{"model.g.dart", true},
{"model.freezed.dart", true},
{"View.g.cs", true},
{"View.designer.cs", true},
{"zz_generated.deepcopy.go", true},
{"store/mock_store.go", true},
{"store/store_mock.go", true},
// Go — additional generators.
{"store/store_mocks.go", true},
{"topic.pulsar.go", true},
{"api/user_grpc.pb.go", true},
// TS / JS.
{"src/api.generated.ts", true},
{"src/api.generated.tsx", true},
{"src/api.gen.js", true},
{"src/api.gen.jsx", true},
{"proto/svc_pb.ts", true},
{"proto/svc_grpc_pb.js", true},
{"dist/bundle.min.js", true},
{"dist/bundle.min.mjs", true},
// Rust / Python.
{"src/schema.generated.rs", true},
{"proto/user_pb2.pyi", true},
// Dart.
{"m.pb.dart", true},
{"m.pbgrpc.dart", true},
{"m.chopper.dart", true},
{"m.gr.dart", true},
{"m.config.dart", true},
// Java.
{"pkg/UserServiceGrpc.java", true},
{"pkg/UserOuterClass.java", true},
// Windows separators normalise.
{`api\user.pb.go`, true},
// Negatives.
{"api/user.go", false},
{"store/store.go", false},
{"genuine.go", false}, // "gen" prefix is not a marker
{"dist/app.js", false},
{"src/UserService.java", false},
{"", false},
}
for _, c := range cases {
if got := IsGenerated(c.path); got != c.want {
t.Errorf("IsGenerated(%q) = %v, want %v", c.path, got, c.want)
}
}
}
func TestGeneratedPeerPaths(t *testing.T) {
cases := []struct {
path string
want []string
}{
{"api/user.pb.go", []string{"api/user.go"}},
{"proto/user_pb2.py", []string{"proto/user.py"}},
{"proto/user_pb2_grpc.py", []string{"proto/user.py"}},
{"x_gen.go", []string{"x.go"}},
{"x.generated.go", []string{"x.go"}},
{"model.freezed.dart", []string{"model.dart"}},
{"View.designer.cs", []string{"View.cs"}},
{"store/mock_store.go", []string{"store/store.go"}},
{"store/store_mock.go", []string{"store/store.go"}},
// New suffixes derive their hand-written peer.
{"store/store_mocks.go", []string{"store/store.go"}},
{"topic.pulsar.go", []string{"topic.go"}},
{"src/api.generated.ts", []string{"src/api.ts"}},
{"src/api.generated.tsx", []string{"src/api.tsx"}},
{"src/api.gen.js", []string{"src/api.js"}},
{"proto/svc_pb.ts", []string{"proto/svc.ts"}},
{"proto/svc_grpc_pb.js", []string{"proto/svc.js"}},
{"dist/bundle.min.js", []string{"dist/bundle.js"}},
{"src/schema.generated.rs", []string{"src/schema.rs"}},
{"proto/user_pb2.pyi", []string{"proto/user.py"}},
{"m.pbgrpc.dart", []string{"m.dart"}},
{"m.chopper.dart", []string{"m.dart"}},
{"pkg/UserServiceGrpc.java", []string{"pkg/UserService.java"}},
{"pkg/UserOuterClass.java", []string{"pkg/User.java"}},
// No clean peer.
{"zz_generated.deepcopy.go", nil},
{"api/user.go", nil},
{"", nil},
}
for _, c := range cases {
got := GeneratedPeerPaths(c.path)
if !reflect.DeepEqual(got, c.want) {
t.Errorf("GeneratedPeerPaths(%q) = %v, want %v", c.path, got, c.want)
}
}
}
+185
View File
@@ -0,0 +1,185 @@
package excludes
import (
"bufio"
"os"
"path/filepath"
"strings"
"sync"
)
// Hierarchical matches paths against per-directory ignore files
// discovered along the chain from a repo root down to each path's
// parent directory. Unlike the repo-root-only .gitignore handling, an
// ignore file placed in any directory is honored, with its patterns
// anchored at the directory that contains it — a pattern in
// <root>/sub/.gortexignore constrains only paths under <root>/sub.
//
// Each directory's ignore files are read and compiled once, on first
// request, and cached. A full index walk therefore pays one read per
// directory regardless of how deep the tree is. Hierarchical is safe
// for concurrent use.
type Hierarchical struct {
root string
filenames []string
mu sync.RWMutex
cache map[string]*Matcher // abs dir -> compiled matcher; nil value = directory has no ignore files
}
// NewHierarchical builds a per-directory ignore matcher rooted at root.
// filenames are the ignore-file basenames honored in every directory
// (e.g. ".gortexignore"). An empty filename list yields a matcher that
// excludes nothing.
func NewHierarchical(root string, filenames ...string) *Hierarchical {
if abs, err := filepath.Abs(root); err == nil {
root = abs
}
return &Hierarchical{
root: filepath.Clean(root),
filenames: filenames,
cache: make(map[string]*Matcher),
}
}
// Match reports whether an absolute path is excluded by an ignore file
// in any ancestor directory between the root and the path's parent.
// When isDir is true the path is treated as a directory, so trailing-
// slash patterns prune the whole subtree. A path outside the root is
// never excluded.
func (h *Hierarchical) Match(absPath string, isDir bool) bool {
if h == nil || len(h.filenames) == 0 {
return false
}
absPath = filepath.Clean(absPath)
rel, err := filepath.Rel(h.root, absPath)
if err != nil {
return false
}
rel = filepath.ToSlash(rel)
if rel == "." || rel == "" || rel == ".." || strings.HasPrefix(rel, "../") {
return false
}
// Test the path against the root's ignore matcher and that of every
// ancestor directory down to (but excluding) the path itself. Any
// level that excludes the path wins; a file's own directory cannot
// exclude the file from itself.
dir := h.root
if h.dirMatcher(dir).MatchAbsDir(absPath, dir, isDir) {
return true
}
segs := strings.Split(rel, "/")
for _, seg := range segs[:len(segs)-1] {
dir = filepath.Join(dir, seg)
if h.dirMatcher(dir).MatchAbsDir(absPath, dir, isDir) {
return true
}
}
return false
}
// HasNegatedDescendant reports whether any per-directory ignore file
// along the chain from the root down to absDir carries a re-include
// ("!") pattern that could match a path strictly beneath absDir. The
// index walk uses it to keep descending an excluded directory whose
// subtree a negation could resurrect, instead of pruning it outright —
// the per-directory counterpart of Matcher.HasNegatedDescendant. absDir
// is an absolute directory path; a path outside the root never matches.
func (h *Hierarchical) HasNegatedDescendant(absDir string) bool {
if h == nil || len(h.filenames) == 0 {
return false
}
absDir = filepath.Clean(absDir)
rel, err := filepath.Rel(h.root, absDir)
if err != nil {
return false
}
rel = filepath.ToSlash(rel)
if rel == ".." || strings.HasPrefix(rel, "../") {
return false
}
// Each directory's ignore patterns are anchored at that directory, so
// the question "is there a negated descendant of absDir?" is asked of
// every ancestor matcher (and absDir's own) with absDir expressed
// relative to that ancestor.
dir := h.root
if h.dirMatcher(dir).HasNegatedDescendant(relUnder(dir, absDir)) {
return true
}
if rel == "." || rel == "" {
return false
}
for _, seg := range strings.Split(rel, "/") {
dir = filepath.Join(dir, seg)
if h.dirMatcher(dir).HasNegatedDescendant(relUnder(dir, absDir)) {
return true
}
}
return false
}
// relUnder returns child expressed relative to dir, in forward-slash
// form. It returns "" when child is dir itself.
func relUnder(dir, child string) string {
rel, err := filepath.Rel(dir, child)
if err != nil {
return ""
}
rel = filepath.ToSlash(rel)
if rel == "." {
return ""
}
return rel
}
// dirMatcher returns the compiled ignore matcher for one directory,
// reading and parsing its ignore files on first request. A directory
// with no ignore files (or only empty ones) caches a nil matcher; the
// *Matcher methods are nil-safe so callers need no guard.
func (h *Hierarchical) dirMatcher(dir string) *Matcher {
h.mu.RLock()
m, ok := h.cache[dir]
h.mu.RUnlock()
if ok {
return m
}
var patterns []string
for _, name := range h.filenames {
patterns = append(patterns, readIgnoreFile(filepath.Join(dir, name))...)
}
if len(patterns) > 0 {
m = New(patterns)
}
h.mu.Lock()
h.cache[dir] = m
h.mu.Unlock()
return m
}
// readIgnoreFile reads one ignore file and returns its non-blank,
// non-comment lines as gitignore-syntax patterns. A missing or
// unreadable file yields nil — honoring ignore files is a convenience,
// never a hard requirement, so a missing or permission-denied file
// silently no-ops.
func readIgnoreFile(path string) []string {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
var patterns []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
patterns = append(patterns, line)
}
return patterns
}
+184
View File
@@ -0,0 +1,184 @@
package excludes
import (
"os"
"path/filepath"
"testing"
)
func mkIgnore(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), 0o644); err != nil {
t.Fatal(err)
}
}
func TestHierarchical_RootLevel(t *testing.T) {
root := t.TempDir()
mkIgnore(t, filepath.Join(root, ".gortexignore"), "*.gen.go\nbuild/\n")
h := NewHierarchical(root, ".gortexignore")
if !h.Match(filepath.Join(root, "foo.gen.go"), false) {
t.Error("root .gortexignore should exclude foo.gen.go")
}
if h.Match(filepath.Join(root, "foo.go"), false) {
t.Error("foo.go should not be excluded")
}
if !h.Match(filepath.Join(root, "build"), true) {
t.Error("build/ directory should be excluded so the subtree is pruned")
}
if !h.Match(filepath.Join(root, "pkg", "sub", "foo.gen.go"), false) {
t.Error("root pattern should reach a deeply nested foo.gen.go")
}
}
func TestHierarchical_PerDirectory(t *testing.T) {
root := t.TempDir()
// An ignore file deep in the tree scopes only to its own subtree.
mkIgnore(t, filepath.Join(root, "internal", ".gortexignore"), "secret.go\n")
h := NewHierarchical(root, ".gortexignore")
if !h.Match(filepath.Join(root, "internal", "secret.go"), false) {
t.Error("internal/.gortexignore should exclude internal/secret.go")
}
if !h.Match(filepath.Join(root, "internal", "sub", "secret.go"), false) {
t.Error("internal/.gortexignore should reach internal/sub/secret.go")
}
if h.Match(filepath.Join(root, "secret.go"), false) {
t.Error("internal/.gortexignore must not affect a root-level secret.go")
}
if h.Match(filepath.Join(root, "cmd", "secret.go"), false) {
t.Error("internal/.gortexignore must not affect a sibling directory")
}
}
func TestHierarchical_Nested(t *testing.T) {
root := t.TempDir()
mkIgnore(t, filepath.Join(root, ".gortexignore"), "*.tmp\n")
mkIgnore(t, filepath.Join(root, "pkg", ".gortexignore"), "local.go\nvendored/\n")
h := NewHierarchical(root, ".gortexignore")
// Both the root rule and the pkg rule apply under pkg/.
if !h.Match(filepath.Join(root, "pkg", "x.tmp"), false) {
t.Error("root rule should still apply inside pkg/")
}
if !h.Match(filepath.Join(root, "pkg", "local.go"), false) {
t.Error("pkg/.gortexignore should exclude pkg/local.go")
}
if !h.Match(filepath.Join(root, "pkg", "vendored"), true) {
t.Error("pkg/.gortexignore should prune the pkg/vendored/ subtree")
}
if h.Match(filepath.Join(root, "local.go"), false) {
t.Error("pkg/.gortexignore must not reach the root")
}
}
func TestHierarchical_Negation(t *testing.T) {
root := t.TempDir()
mkIgnore(t, filepath.Join(root, ".gortexignore"), "*.log\n!keep.log\n")
h := NewHierarchical(root, ".gortexignore")
if !h.Match(filepath.Join(root, "debug.log"), false) {
t.Error("*.log should exclude debug.log")
}
if h.Match(filepath.Join(root, "keep.log"), false) {
t.Error("!keep.log should re-include keep.log")
}
}
func TestHierarchical_HasNegatedDescendant(t *testing.T) {
root := t.TempDir()
// A blanket "dir/*" plus a re-include of one child, expressed in a
// per-directory ignore file. The walk must keep descending pkg/ so
// the re-included pkg/keep/ subtree is reached.
mkIgnore(t, filepath.Join(root, ".gortexignore"), "pkg/*\n!pkg/keep/\n")
h := NewHierarchical(root, ".gortexignore")
if !h.HasNegatedDescendant(filepath.Join(root, "pkg")) {
t.Error("pkg/ has a re-included child and must not be pruned")
}
if h.HasNegatedDescendant(filepath.Join(root, "pkg", "other")) {
t.Error("pkg/other has no re-included descendant and is prunable")
}
if h.HasNegatedDescendant(filepath.Join(root, "unrelated")) {
t.Error("an unrelated directory has no re-included descendant")
}
}
func TestHierarchical_HasNegatedDescendant_Anchored(t *testing.T) {
root := t.TempDir()
// The negation lives in a nested ignore file, anchored at internal/.
mkIgnore(t, filepath.Join(root, "internal", ".gortexignore"), "build/*\n!build/keep/\n")
h := NewHierarchical(root, ".gortexignore")
if !h.HasNegatedDescendant(filepath.Join(root, "internal", "build")) {
t.Error("internal/build has a re-included child via the nested ignore file")
}
if h.HasNegatedDescendant(filepath.Join(root, "build")) {
t.Error("a root-level build/ is unaffected by internal/.gortexignore")
}
}
func TestHierarchical_HasNegatedDescendant_NilAndEmpty(t *testing.T) {
var h *Hierarchical
if h.HasNegatedDescendant("/anything") {
t.Error("nil Hierarchical should report no negated descendants")
}
root := t.TempDir()
mkIgnore(t, filepath.Join(root, ".gortexignore"), "pkg/*\n!pkg/keep/\n")
empty := NewHierarchical(root) // no filenames configured
if empty.HasNegatedDescendant(filepath.Join(root, "pkg")) {
t.Error("an empty filename list should report no negated descendants")
}
}
func TestHierarchical_NoFiles(t *testing.T) {
root := t.TempDir()
mkIgnore(t, filepath.Join(root, "foo.go"), "package x\n")
h := NewHierarchical(root, ".gortexignore")
if h.Match(filepath.Join(root, "foo.go"), false) {
t.Error("with no ignore files, nothing should be excluded")
}
}
func TestHierarchical_OutsideRoot(t *testing.T) {
root := t.TempDir()
mkIgnore(t, filepath.Join(root, ".gortexignore"), "*\n")
h := NewHierarchical(root, ".gortexignore")
if h.Match(filepath.Join(filepath.Dir(root), "elsewhere.go"), false) {
t.Error("a path outside the root must never be excluded")
}
}
func TestHierarchical_NilAndEmpty(t *testing.T) {
var h *Hierarchical
if h.Match("/anything", false) {
t.Error("nil Hierarchical should match nothing")
}
root := t.TempDir()
mkIgnore(t, filepath.Join(root, ".gortexignore"), "*\n")
empty := NewHierarchical(root) // no filenames configured
if empty.Match(filepath.Join(root, "foo.go"), false) {
t.Error("an empty filename list should match nothing")
}
}
func TestHierarchical_MultipleFilenames(t *testing.T) {
root := t.TempDir()
mkIgnore(t, filepath.Join(root, ".gortexignore"), "a.go\n")
mkIgnore(t, filepath.Join(root, ".ignore"), "b.go\n")
h := NewHierarchical(root, ".gortexignore", ".ignore")
if !h.Match(filepath.Join(root, "a.go"), false) {
t.Error(".gortexignore pattern should apply")
}
if !h.Match(filepath.Join(root, "b.go"), false) {
t.Error(".ignore pattern should apply")
}
if h.Match(filepath.Join(root, "c.go"), false) {
t.Error("c.go should not be excluded")
}
}
+174
View File
@@ -0,0 +1,174 @@
package excludes
import (
"path/filepath"
"strings"
ignore "github.com/sabhiram/go-gitignore"
"github.com/zzet/gortex/internal/pathkey"
)
// Matcher tests whether a path should be excluded from indexing/watching.
// It is safe for concurrent reads after construction.
type Matcher struct {
ign *ignore.GitIgnore
patterns []string
}
// New compiles the given patterns into a Matcher. A nil/empty list is
// valid and will match nothing.
//
// Patterns are folded to Unicode NFC so a pattern naming a non-ASCII
// directory matches paths regardless of which Unicode form the
// filesystem walk produced — MatchRel folds the candidate path to the
// same form before testing it.
func New(patterns []string) *Matcher {
cleaned := make([]string, 0, len(patterns))
for _, p := range patterns {
p = strings.TrimSpace(p)
if p == "" || strings.HasPrefix(p, "#") {
continue
}
cleaned = append(cleaned, pathkey.Normalize(p))
}
return &Matcher{
ign: ignore.CompileIgnoreLines(cleaned...),
patterns: cleaned,
}
}
// Patterns returns the cleaned pattern list (empties and comments removed).
func (m *Matcher) Patterns() []string {
if m == nil {
return nil
}
out := make([]string, len(m.patterns))
copy(out, m.patterns)
return out
}
// MatchRel reports whether a repo-root-relative path is excluded.
// Path separators are normalised to forward slashes and the path is
// folded to Unicode NFC — matching how New normalised the patterns —
// before matching, so a non-ASCII path component compares equal to its
// pattern whether the OS supplied it decomposed (macOS NFD) or
// precomposed (Linux / git NFC).
func (m *Matcher) MatchRel(relPath string) bool {
if m == nil || m.ign == nil {
return false
}
rel := pathkey.Normalize(filepath.ToSlash(relPath))
rel = strings.TrimPrefix(rel, "./")
if rel == "" || rel == "." {
return false
}
return m.ign.MatchesPath(rel)
}
// MatchAbs reports whether an absolute path under root is excluded.
// Returns false if path is not under root.
func (m *Matcher) MatchAbs(absPath, root string) bool {
return m.MatchAbsDir(absPath, root, false)
}
// MatchAbsDir reports whether an absolute path under root is excluded.
// When isDir is true the path is treated as a directory, so a pattern
// written with a trailing slash (e.g. "build/") matches the directory
// itself — letting the caller prune the whole subtree instead of
// descending it and re-testing every file. Returns false if path is
// not under root.
func (m *Matcher) MatchAbsDir(absPath, root string, isDir bool) bool {
if m == nil || m.ign == nil {
return false
}
rel, err := filepath.Rel(root, absPath)
if err != nil {
return false
}
if isDir {
rel += "/"
}
return m.MatchRel(rel)
}
// HasNegatedDescendant reports whether any re-include ("!") pattern in
// the matcher could match a path strictly beneath relDir.
//
// The index walk prunes an excluded directory with filepath.SkipDir so
// it never descends a subtree it would only throw away. But go-gitignore
// treats "*" as matching across "/", so a blanket like "a/b/*" reports
// the directory "a/b" itself as excluded — pruning it would skip a later
// "!a/b/keep/" re-include before the walk ever reaches the child. This
// lets the walk ask "could a negation resurrect something under here?"
// and keep descending when the answer is yes, mirroring git, which never
// prunes a directory a negation could re-include a child from.
//
// relDir is a repo-root-relative, forward-slash directory path (a
// trailing slash and a leading "./" are tolerated). The check is
// deliberately conservative: an unanchored or wildcard-leading negation
// can match at varying depths, so it is treated as "could be under
// anything" and the directory is kept rather than pruned.
func (m *Matcher) HasNegatedDescendant(relDir string) bool {
if m == nil {
return false
}
relDir = pathkey.Normalize(filepath.ToSlash(relDir))
relDir = strings.TrimPrefix(relDir, "./")
relDir = strings.TrimSuffix(relDir, "/")
if relDir == "." {
relDir = ""
}
for _, p := range m.patterns {
if !strings.HasPrefix(p, "!") {
continue
}
np := strings.TrimSpace(p[1:])
np = strings.TrimPrefix(np, "/")
np = strings.TrimSuffix(np, "/")
if np == "" {
continue
}
// A negation with no internal slash is unanchored: gitignore
// matches it at any depth, so it can re-include something under
// any directory. Keep descending.
if !strings.Contains(np, "/") {
return true
}
anchor := literalAnchor(np)
if anchor == "" {
// First segment is itself a wildcard ("*/...", "**/..."): it
// can match at varying depths, so stay conservative.
return true
}
// At the root, every anchored negation lives somewhere beneath us.
if relDir == "" {
return true
}
// The negation's match-set intersects relDir's subtree when its
// literal anchor sits at or under relDir, or relDir sits under the
// anchor (a wildcard tail can then still reach into relDir).
if anchor == relDir ||
strings.HasPrefix(anchor, relDir+"/") ||
strings.HasPrefix(relDir, anchor+"/") {
return true
}
}
return false
}
// literalAnchor returns the leading path segments of a slash-bearing
// gitignore pattern up to (but excluding) the first segment that holds a
// wildcard meta-character. It returns "" when the first segment is
// itself a wildcard ("*", "**", "?foo", ...).
func literalAnchor(pattern string) string {
segs := strings.Split(pattern, "/")
lit := make([]string, 0, len(segs))
for _, s := range segs {
if strings.ContainsAny(s, "*?[") {
break
}
lit = append(lit, s)
}
return strings.Join(lit, "/")
}
+151
View File
@@ -0,0 +1,151 @@
package excludes
import (
"testing"
)
func TestMatcher_Nil(t *testing.T) {
var m *Matcher
if m.MatchRel("anything") {
t.Fatal("nil matcher should never match")
}
}
func TestMatcher_Builtin(t *testing.T) {
m := New(Builtin)
cases := []struct {
path string
want bool
}{
{".git/HEAD", true},
{"pkg/.git/HEAD", true},
{"src/node_modules/foo/index.js", true},
{"vendor/lib/x.go", true},
{"pkg/foo.go", false},
{"README.md", false},
{"tmp.tmp", true},
{"deep/nested/file.swp", true},
{".Makefile.swpx", true},
{"src/.foo.swo", true},
{"src/.foo.swn", true},
{"backup~", true},
}
for _, tc := range cases {
if got := m.MatchRel(tc.path); got != tc.want {
t.Errorf("MatchRel(%q) = %v, want %v", tc.path, got, tc.want)
}
}
}
func TestMatcher_Negation(t *testing.T) {
// Exclude all of dist, except dist/keep
m := New([]string{"dist/", "!dist/keep/**"})
if !m.MatchRel("dist/junk.txt") {
t.Error("dist/junk.txt should be excluded")
}
if m.MatchRel("dist/keep/foo.txt") {
t.Error("dist/keep/foo.txt should be re-included")
}
}
func TestMatcher_HasNegatedDescendant(t *testing.T) {
cases := []struct {
name string
patterns []string
relDir string
want bool
}{
{
// Issue #113: a blanket "dir/*" makes go-gitignore report the
// parent directory itself as excluded; the re-include lives
// beneath it, so the parent must keep being descended.
name: "blanket plus reinclude under parent",
patterns: []string{"wp-content/plugins/*", "!wp-content/plugins/foo/"},
relDir: "wp-content/plugins",
want: true,
},
{
name: "sibling without reinclude is prunable",
patterns: []string{"wp-content/plugins/*", "!wp-content/plugins/foo/"},
relDir: "wp-content/plugins/other",
want: false,
},
{
name: "unrelated dir is prunable",
patterns: []string{"wp-content/plugins/*", "!wp-content/plugins/foo/"},
relDir: "node_modules",
want: false,
},
{
name: "reinclude two levels down keeps grandparent",
patterns: []string{"a/*", "!a/b/c/"},
relDir: "a",
want: true,
},
{
name: "reinclude two levels down keeps parent",
patterns: []string{"a/*", "!a/b/c/"},
relDir: "a/b",
want: true,
},
{
name: "reinclude two levels down prunes uninvolved sibling",
patterns: []string{"a/*", "!a/b/c/", "!a/x"},
relDir: "a/y",
want: false,
},
{
name: "unanchored basename negation keeps every dir",
patterns: []string{"build/", "!keep.txt"},
relDir: "build",
want: true,
},
{
name: "wildcard-leading negation keeps every dir",
patterns: []string{"build/", "!**/keep/"},
relDir: "build",
want: true,
},
{
name: "no negations never keeps",
patterns: []string{"build/", "dist/"},
relDir: "build",
want: false,
},
{
name: "trailing-slash relDir is tolerated",
patterns: []string{"a/*", "!a/b/c/"},
relDir: "a/b/",
want: true,
},
{
name: "leading-dot-slash relDir is tolerated",
patterns: []string{"a/*", "!a/b/c/"},
relDir: "./a",
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := New(tc.patterns)
if got := m.HasNegatedDescendant(tc.relDir); got != tc.want {
t.Errorf("HasNegatedDescendant(%q) = %v, want %v", tc.relDir, got, tc.want)
}
})
}
}
func TestMatcher_HasNegatedDescendant_Nil(t *testing.T) {
var m *Matcher
if m.HasNegatedDescendant("anything") {
t.Fatal("nil matcher should report no negated descendants")
}
}
func TestMatcher_CommentsAndEmpty(t *testing.T) {
m := New([]string{"", "# comment", "foo/"})
pats := m.Patterns()
if len(pats) != 1 || pats[0] != "foo/" {
t.Errorf("expected [foo/], got %v", pats)
}
}
+50
View File
@@ -0,0 +1,50 @@
package excludes
import (
"path"
"path/filepath"
"strings"
)
// agentConfigRoots are editor/agent state directories that Builtin
// excludes wholesale (their bulk content — settings, transcripts, skills —
// is noise) but which may also carry an MCP server config the
// MCP-config-as-graph feature wants in the graph. Callers descend these
// subtrees and index only the MCP config files within them; see the
// indexer's shouldExclude.
var agentConfigRoots = []string{".claude", ".kiro"}
// mcpConfigExactBasenames and mcpConfigSuffix mirror the MCP config
// extractor's Extensions() (internal/parser/languages/mcp_config.go) —
// keep them in sync. ".mcp.json" is matched as a suffix (so a repo-root
// ".mcp.json" or "x.mcp.json" qualifies); the rest are exact basenames.
var mcpConfigExactBasenames = map[string]bool{
"mcp.json": true,
"claude_desktop_config.json": true,
}
const mcpConfigSuffix = ".mcp.json"
// InAgentConfigDir reports whether a repo-root-relative path is the root
// of, or lives under, one of the agent-config directories (.claude/,
// .kiro/). Used to carve a descend-but-only-MCP-configs exception out of
// the wholesale Builtin exclusion of those directories.
func InAgentConfigDir(relPath string) bool {
rel := strings.TrimPrefix(filepath.ToSlash(relPath), "./")
for _, root := range agentConfigRoots {
if rel == root || strings.HasPrefix(rel, root+"/") {
return true
}
}
return false
}
// IsMCPConfigFile reports whether a path's basename is a recognised MCP
// server config file name.
func IsMCPConfigFile(relPath string) bool {
base := path.Base(filepath.ToSlash(relPath))
if mcpConfigExactBasenames[base] {
return true
}
return strings.HasSuffix(base, mcpConfigSuffix)
}
+33
View File
@@ -0,0 +1,33 @@
package excludes
import "testing"
func TestInAgentConfigDir(t *testing.T) {
in := []string{".claude", ".claude/", ".claude/mcp.json", ".claude/skills/x.md", ".kiro", ".kiro/settings/mcp.json"}
out := []string{"", ".", "src/main.go", "claude/x", ".cursor/mcp.json", "a/.claude/mcp.json"}
for _, p := range in {
if !InAgentConfigDir(p) {
t.Errorf("InAgentConfigDir(%q) = false, want true", p)
}
}
for _, p := range out {
if InAgentConfigDir(p) {
t.Errorf("InAgentConfigDir(%q) = true, want false", p)
}
}
}
func TestIsMCPConfigFile(t *testing.T) {
yes := []string{"mcp.json", ".mcp.json", ".cursor/mcp.json", ".kiro/settings/mcp.json", "claude_desktop_config.json", "foo.mcp.json"}
no := []string{"settings.json", "package.json", "mcp.yaml", "x.md", "mcp.json.bak"}
for _, p := range yes {
if !IsMCPConfigFile(p) {
t.Errorf("IsMCPConfigFile(%q) = false, want true", p)
}
}
for _, p := range no {
if IsMCPConfigFile(p) {
t.Errorf("IsMCPConfigFile(%q) = true, want false", p)
}
}
}
+45
View File
@@ -0,0 +1,45 @@
package excludes
import (
"path/filepath"
"strings"
)
// vendorDirNames is the set of directory names that denote vendored
// dependencies or build outputs, derived from Builtin at init time.
// Glob entries (e.g. "*.tmp") are dropped — they identify files, not trees.
var vendorDirNames = func() map[string]struct{} {
m := make(map[string]struct{}, len(Builtin))
for _, p := range Builtin {
if !strings.HasSuffix(p, "/") {
continue
}
name := strings.TrimSuffix(p, "/")
if strings.ContainsAny(name, "*?!/") {
continue
}
m[name] = struct{}{}
}
return m
}()
// IsVendored reports whether a path lives inside a vendored or build-output
// directory tracked by Builtin (vendor/, node_modules/, Pods/, target/,
// .venv/, ...). It lets callers outside the indexer — e.g. the MCP contracts
// tool — apply the same "not our code" judgment without rolling a new list.
//
// Accepts any of: repo-root-relative ("pkg/x.go"), repo-prefixed
// ("repo/pkg/x.go"), or absolute ("/abs/path/pkg/x.go"). The match is
// path-component-wise because the repo root may not be known to the caller.
func IsVendored(path string) bool {
if path == "" {
return false
}
p := filepath.ToSlash(path)
for _, seg := range strings.Split(p, "/") {
if _, ok := vendorDirNames[seg]; ok {
return true
}
}
return false
}
+38
View File
@@ -0,0 +1,38 @@
package excludes
import "testing"
func TestIsVendored(t *testing.T) {
tests := []struct {
path string
want bool
}{
// Repo-root-relative
{"vendor/github.com/aws/aws-sdk-go/service.go", true},
{"node_modules/react/index.js", true},
{"Pods/AFNetworking/AFNetworking.m", true},
{".venv/lib/python3.11/site-packages/requests/__init__.py", true},
{"target/classes/Main.class", true},
// Repo-prefixed
{"myrepo/vendor/lib/x.go", true},
{"tuck_app/Pods/sqlite3.c", true},
{"daedalus/node_modules/foo/index.ts", true},
// Absolute
{"/Users/me/code/proj/vendor/x/y.go", true},
// Not vendored
{"internal/mcp/tools_enhancements.go", false},
{"cmd/gortex/main.go", false},
{"pkg/server.go", false},
{"", false},
// Partial name match must not trigger
{"vendoring/notes.md", false},
{"node_modulesx/foo.go", false},
// Glob-derived entries must not match (*.tmp isn't a dir)
{"foo.tmp", false},
}
for _, tt := range tests {
if got := IsVendored(tt.path); got != tt.want {
t.Errorf("IsVendored(%q) = %v, want %v", tt.path, got, tt.want)
}
}
}