package builtin // gitignore.go -- 共用的 .gitignore 解析器. // // 提供 .gitignore 文件的解析和路径匹配功能,供 Glob 和 Grep 工具共用. // 支持标准 .gitignore 语法:通配符 *,双星 **,否定 !,目录标记 /,注释 #. // // 只使用 Go 标准库实现. import ( "bufio" "os" "path/filepath" "strings" ) // IgnorePattern 表示一条 .gitignore 规则. type IgnorePattern struct { // pattern 是规则的原始模式(已去除前缀/后缀标记) pattern string // negate 表示是否为否定规则(以 ! 开头) negate bool // dirOnly 表示是否只匹配目录(以 / 结尾) dirOnly bool // anchored 表示是否锚定到 .gitignore 所在目录(包含 /) anchored bool // baseDir 是该规则所属的 .gitignore 文件所在目录(相对于项目根目录) baseDir string } // defaultIgnorePatterns 返回默认排除的模式列表. // 这些是常见的不需要搜索的目录和文件. func defaultIgnorePatterns() []IgnorePattern { defaults := []string{ ".git/", "node_modules/", "__pycache__/", ".DS_Store", "*.pyc", "vendor/", "dist/", "build/", } var patterns []IgnorePattern for _, d := range defaults { p := parseIgnoreLine(d, "") if p != nil { patterns = append(patterns, *p) } } return patterns } // ParseGitignore 解析一个 .gitignore 文件,返回规则列表. // path 是 .gitignore 文件的绝对路径. // baseDir 是该 .gitignore 相对于项目根目录的路径(根目录下为 ""). func ParseGitignore(path string, baseDir string) []IgnorePattern { f, err := os.Open(path) if err != nil { return nil } defer f.Close() var patterns []IgnorePattern scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() p := parseIgnoreLine(line, baseDir) if p != nil { patterns = append(patterns, *p) } } _ = scanner.Err() // 非 EOF 错误静默忽略;已解析的规则仍有效 return patterns } // parseIgnoreLine 解析一行 .gitignore 规则. func parseIgnoreLine(line string, baseDir string) *IgnorePattern { // 去除行尾空白(行尾转义空格除外) line = strings.TrimRight(line, " \t\r") // 空行和注释行跳过 if line == "" || strings.HasPrefix(line, "#") { return nil } p := &IgnorePattern{ baseDir: baseDir, } // 检查否定规则 if strings.HasPrefix(line, "!") { p.negate = true line = line[1:] } // 检查目录标记 if strings.HasSuffix(line, "/") { p.dirOnly = true line = strings.TrimRight(line, "/") } // 检查是否锚定(包含 / 但不是在末尾) // 如果模式中包含 /(除了末尾),则锚定到 baseDir if strings.Contains(line, "/") { p.anchored = true // 去除开头的 / line = strings.TrimPrefix(line, "/") } p.pattern = line return p } // CollectIgnorePatterns 从项目根目录递归收集所有 .gitignore 规则. // rootDir 是项目根目录的绝对路径. // 返回的规则列表包含默认排除模式. // 精妙之处(CLEVER): 递归收集 .gitignore 时利用已有规则过滤目录-- // 已收集的忽略规则会影响后续目录是否被递归进入(如 node_modules/ 的 .gitignore 不会被读取). // 这意味着规则收集顺序很重要:父目录的 .gitignore 优先于子目录的. // 这和 Git 的实际行为一致. func CollectIgnorePatterns(rootDir string) []IgnorePattern { // 先加入默认排除模式 patterns := defaultIgnorePatterns() // 遍历目录收集 .gitignore 文件 // 为了避免进入被忽略的目录,我们按层级处理 collectGitignoreRecursive(rootDir, rootDir, &patterns) return patterns } // collectGitignoreRecursive 递归收集 .gitignore 规则. func collectGitignoreRecursive(dir string, rootDir string, patterns *[]IgnorePattern) { // 计算相对路径 relDir, err := filepath.Rel(rootDir, dir) if err != nil { return } if relDir == "." { relDir = "" } // 检查当前目录是否应被忽略(跳过根目录本身) if relDir != "" && IsIgnored(relDir, *patterns, true) { return } // 解析当前目录的 .gitignore gitignorePath := filepath.Join(dir, ".gitignore") if _, err := os.Stat(gitignorePath); err == nil { newPatterns := ParseGitignore(gitignorePath, relDir) *patterns = append(*patterns, newPatterns...) } // 读取子目录 entries, err := os.ReadDir(dir) if err != nil { return } for _, entry := range entries { if !entry.IsDir() { continue } name := entry.Name() // 跳过 .git 目录本身(不递归进去收集) if name == ".git" { continue } subDir := filepath.Join(dir, name) collectGitignoreRecursive(subDir, rootDir, patterns) } } // IsIgnored 判断给定的相对路径是否应被忽略. // relPath 是相对于项目根目录的路径(使用 / 分隔). // isDir 表示该路径是否为目录. func IsIgnored(relPath string, patterns []IgnorePattern, isDir bool) bool { // 统一使用 / 分隔符 relPath = filepath.ToSlash(relPath) ignored := false for _, p := range patterns { if matchPattern(relPath, isDir, p) { if p.negate { ignored = false } else { ignored = true } } } return ignored } // matchPattern 检查路径是否匹配某条忽略规则. func matchPattern(relPath string, isDir bool, p IgnorePattern) bool { // 仅目录规则只匹配目录 if p.dirOnly && !isDir { return false } pattern := p.pattern // 如果规则有 baseDir,需要处理相对路径 targetPath := relPath if p.baseDir != "" { // 路径必须在 baseDir 下 prefix := p.baseDir + "/" if !strings.HasPrefix(relPath, prefix) { return false } targetPath = relPath[len(prefix):] } if p.anchored { // 锚定模式:从 baseDir 开始完整匹配 return globMatchIgnore(pattern, targetPath) } // 非锚定模式:可以匹配路径的任何部分 // 先尝试完整路径匹配 if globMatchIgnore(pattern, targetPath) { return true } // 再尝试匹配路径的最后一段(文件名/目录名) base := filepath.Base(targetPath) if globMatchIgnore(pattern, base) { return true } // 尝试匹配路径的各个后缀部分 parts := strings.Split(targetPath, "/") for i := 1; i < len(parts); i++ { suffix := strings.Join(parts[i:], "/") if globMatchIgnore(pattern, suffix) { return true } } return false } // globMatchIgnore 实现 .gitignore 风格的 glob 匹配. // 支持 *,?,[...],** 通配符. func globMatchIgnore(pattern, name string) bool { // 处理 ** 的情况 if strings.Contains(pattern, "**") { return matchDoublestar(pattern, name) } // 如果模式不含 /,直接用 filepath.Match if !strings.Contains(pattern, "/") { matched, _ := filepath.Match(pattern, name) return matched } // 含 / 的模式需要逐段匹配 patParts := strings.Split(pattern, "/") nameParts := strings.Split(name, "/") if len(patParts) != len(nameParts) { return false } for i, pp := range patParts { matched, _ := filepath.Match(pp, nameParts[i]) if !matched { return false } } return true } // matchDoublestar 处理包含 ** 的 glob 模式. func matchDoublestar(pattern, name string) bool { // 分割模式为 ** 分隔的段 parts := strings.Split(pattern, "**") if len(parts) == 2 { before := strings.TrimSuffix(parts[0], "/") after := strings.TrimPrefix(parts[1], "/") nameParts := strings.Split(name, "/") // ** 在开头 if before == "" { if after == "" { return true } // 尝试从每个位置匹配剩余部分 for i := 0; i <= len(nameParts); i++ { rest := strings.Join(nameParts[i:], "/") if globMatchIgnore(after, rest) { return true } } return false } // ** 在末尾 if after == "" { return globMatchIgnore(before, strings.Join(nameParts[:min(len(strings.Split(before, "/")), len(nameParts))], "/")) } // ** 在中间 beforeParts := strings.Split(before, "/") afterParts := strings.Split(after, "/") if len(nameParts) < len(beforeParts)+len(afterParts) { return false } // 检查前缀 for i, bp := range beforeParts { matched, _ := filepath.Match(bp, nameParts[i]) if !matched { return false } } // 检查后缀(从末尾开始) for i := 0; i < len(afterParts); i++ { matched, _ := filepath.Match(afterParts[len(afterParts)-1-i], nameParts[len(nameParts)-1-i]) if !matched { return false } } return true } // 多个 **,简化处理:用递归 idx := strings.Index(pattern, "**") before := pattern[:idx] after := pattern[idx+2:] if strings.HasSuffix(before, "/") { before = before[:len(before)-1] } if strings.HasPrefix(after, "/") { after = after[1:] } nameParts := strings.Split(name, "/") if before == "" { for i := 0; i <= len(nameParts); i++ { rest := strings.Join(nameParts[i:], "/") if after == "" || globMatchIgnore(after, rest) { return true } } return false } beforeParts := strings.Split(before, "/") if len(nameParts) < len(beforeParts) { return false } for i, bp := range beforeParts { matched, _ := filepath.Match(bp, nameParts[i]) if !matched { return false } } rest := strings.Join(nameParts[len(beforeParts):], "/") if after == "" { return true } return matchDoublestar("**/"+after, rest) }