package ignore import ( "bufio" "os" "path/filepath" "strings" "sync" ) /* Ignore file parser for .larcignore and .gitignore. * Supports basic glob patterns like: * - *.log * - /build/ * - node_modules/ * - !important.log (negation) * * Also supports nested ignore files in subdirectories, * matching git behavior. */ // Matcher checks if paths should be ignored type Matcher struct { root string patterns []pattern // root-level patterns cache map[string][]pattern // dir -> patterns cache mu sync.RWMutex } type pattern struct { pattern string negation bool dirOnly bool baseDir string // directory this pattern is relative to } // New creates a new ignore matcher for the given repository root func New(root string) *Matcher { m := &Matcher{ root: root, cache: make(map[string][]pattern), } /* load root .gitignore first */ m.patterns = m.loadPatterns(root, "") return m } func (m *Matcher) loadPatterns(dir, baseDir string) []pattern { var patterns []pattern /* load .gitignore first */ patterns = append(patterns, m.loadFile(filepath.Join(dir, ".gitignore"), baseDir)...) /* load .larcignore (overrides .gitignore) */ patterns = append(patterns, m.loadFile(filepath.Join(dir, ".larcignore"), baseDir)...) return patterns } func (m *Matcher) loadFile(path, baseDir string) []pattern { var patterns []pattern f, err := os.Open(path) if err != nil { return patterns } defer f.Close() scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) /* skip empty lines and comments */ if line == "" || strings.HasPrefix(line, "#") { continue } p := pattern{pattern: line, baseDir: baseDir} /* check for negation */ if strings.HasPrefix(line, "!") { p.negation = true p.pattern = line[1:] } /* check for directory-only pattern */ if strings.HasSuffix(p.pattern, "/") { p.dirOnly = true p.pattern = strings.TrimSuffix(p.pattern, "/") } /* remove leading slash (makes it relative to this dir) */ p.pattern = strings.TrimPrefix(p.pattern, "/") patterns = append(patterns, p) } return patterns } // Match checks if a path should be ignored func (m *Matcher) Match(path string, isDir bool) bool { /* always ignore .larc directory */ if path == ".larc" || strings.HasPrefix(path, ".larc/") { return true } /* collect all applicable patterns from root to parent dir */ allPatterns := m.collectPatterns(path) ignored := false for _, p := range allPatterns { /* skip dir-only patterns for files */ if p.dirOnly && !isDir { continue } /* compute relative path from pattern's base directory */ relPath := path if p.baseDir != "" { if !strings.HasPrefix(path, p.baseDir+"/") && path != p.baseDir { continue } relPath = strings.TrimPrefix(path, p.baseDir+"/") } if m.matchPattern(p.pattern, relPath) { ignored = !p.negation } } return ignored } // collectPatterns gathers patterns from root and all parent directories of path func (m *Matcher) collectPatterns(path string) []pattern { var allPatterns []pattern /* start with root patterns */ allPatterns = append(allPatterns, m.patterns...) /* walk through parent directories and collect their patterns */ parts := strings.Split(filepath.Dir(path), "/") currentDir := "" for _, part := range parts { if part == "." || part == "" { continue } if currentDir == "" { currentDir = part } else { currentDir = currentDir + "/" + part } /* skip .larc directory */ if currentDir == ".larc" || strings.HasPrefix(currentDir, ".larc/") { continue } patterns := m.getPatternsForDir(currentDir) allPatterns = append(allPatterns, patterns...) } return allPatterns } // getPatternsForDir returns cached patterns for a directory func (m *Matcher) getPatternsForDir(dir string) []pattern { m.mu.RLock() patterns, ok := m.cache[dir] m.mu.RUnlock() if ok { return patterns } /* load patterns from this directory */ m.mu.Lock() defer m.mu.Unlock() /* double-check after acquiring write lock */ if patterns, ok := m.cache[dir]; ok { return patterns } absDir := filepath.Join(m.root, dir) /* quick check: skip if directory doesn't exist */ if _, err := os.Stat(absDir); err != nil { m.cache[dir] = nil return nil } patterns = m.loadPatterns(absDir, dir) m.cache[dir] = patterns return patterns } func (m *Matcher) matchPattern(pattern, path string) bool { /* exact match */ if pattern == path { return true } /* check if pattern matches any component */ if !strings.Contains(pattern, "/") { /* pattern like "*.log" or "node_modules" */ name := filepath.Base(path) /* try glob match on filename */ if matched, _ := filepath.Match(pattern, name); matched { return true } /* also check if any path component matches */ parts := strings.Split(path, "/") for _, part := range parts { if matched, _ := filepath.Match(pattern, part); matched { return true } } return false } /* pattern with path separator */ /* try glob match on full path */ if matched, _ := filepath.Match(pattern, path); matched { return true } /* try matching as prefix */ if strings.HasPrefix(path, pattern+"/") { return true } /* try double-star pattern */ if strings.Contains(pattern, "**") { return m.matchDoubleStar(pattern, path) } return false } func (m *Matcher) matchDoubleStar(pattern, path string) bool { /* split pattern by ** */ parts := strings.Split(pattern, "**") if len(parts) != 2 { return false } prefix := parts[0] suffix := strings.TrimPrefix(parts[1], "/") /* check prefix */ if prefix != "" && !strings.HasPrefix(path, prefix) { return false } /* check suffix */ if suffix == "" { return true } /* find suffix in remaining path */ remaining := strings.TrimPrefix(path, prefix) pathParts := strings.Split(remaining, "/") for i := range pathParts { subpath := strings.Join(pathParts[i:], "/") if matched, _ := filepath.Match(suffix, subpath); matched { return true } /* also try matching just the filename part */ if matched, _ := filepath.Match(suffix, pathParts[len(pathParts)-1]); matched { return true } } return false } // ShouldIgnore is a convenience function func ShouldIgnore(root, path string, isDir bool) bool { m := New(root) return m.Match(path, isDir) }