mirror of
https://github.com/go-task/task.git
synced 2026-07-01 00:24:30 +00:00
212 lines
5.1 KiB
Go
212 lines
5.1 KiB
Go
package fingerprint
|
|
|
|
import (
|
|
"bufio"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-task/task/v3/internal/gitignore"
|
|
)
|
|
|
|
type linesCacheEntry struct {
|
|
mtime time.Time
|
|
lines []string
|
|
}
|
|
|
|
var (
|
|
gitignoreLinesCache sync.Map // dir -> linesCacheEntry, invalidated by mtime
|
|
repoRootCache sync.Map // dir -> repo root (or "" when not in a repo)
|
|
)
|
|
|
|
// findRepoRoot returns the first ancestor of dir containing a .git entry, or
|
|
// ("", false) when dir is not inside a git repository.
|
|
func findRepoRoot(dir string) (string, bool) {
|
|
if v, ok := repoRootCache.Load(dir); ok {
|
|
root := v.(string)
|
|
return root, root != ""
|
|
}
|
|
|
|
current := dir
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(current, ".git")); err == nil {
|
|
repoRootCache.Store(dir, current)
|
|
return current, true
|
|
}
|
|
parent := filepath.Dir(current)
|
|
if parent == current {
|
|
break
|
|
}
|
|
current = parent
|
|
}
|
|
|
|
repoRootCache.Store(dir, "")
|
|
return "", false
|
|
}
|
|
|
|
// filterGitignored marks entries matching gitignore rules as excluded (false).
|
|
// All .gitignore files from the repo root down to each candidate file's
|
|
// directory feed a single matcher so that precedence and cross-file negations
|
|
// (`!pattern`) resolve correctly.
|
|
func filterGitignored(files map[string]bool, dir string) map[string]bool {
|
|
if len(files) == 0 {
|
|
return files
|
|
}
|
|
|
|
absDir, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
return files
|
|
}
|
|
repoRoot, ok := findRepoRoot(absDir)
|
|
if !ok {
|
|
return files
|
|
}
|
|
|
|
// Every directory from the repo root down to each candidate file's dir, so
|
|
// nested .gitignore files reached by deep globs are included too.
|
|
dirSet := make(map[string]struct{})
|
|
for path, included := range files {
|
|
if !included {
|
|
continue
|
|
}
|
|
absPath, err := filepath.Abs(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
d := filepath.Dir(absPath)
|
|
if !withinRepo(repoRoot, d) {
|
|
continue
|
|
}
|
|
for {
|
|
dirSet[d] = struct{}{}
|
|
if d == repoRoot {
|
|
break
|
|
}
|
|
parent := filepath.Dir(d)
|
|
if parent == d {
|
|
break
|
|
}
|
|
d = parent
|
|
}
|
|
}
|
|
|
|
// Shallow dirs first (lower priority): the matcher scans patterns last to
|
|
// first, so deeper rules win and can negate shallower ones.
|
|
dirs := make([]string, 0, len(dirSet))
|
|
for d := range dirSet {
|
|
dirs = append(dirs, d)
|
|
}
|
|
sort.Slice(dirs, func(i, j int) bool {
|
|
di := strings.Count(dirs[i], string(filepath.Separator))
|
|
dj := strings.Count(dirs[j], string(filepath.Separator))
|
|
if di != dj {
|
|
return di < dj
|
|
}
|
|
return dirs[i] < dirs[j]
|
|
})
|
|
|
|
var patterns []gitignore.Pattern
|
|
for _, d := range dirs {
|
|
lines := readGitignoreLines(d)
|
|
if len(lines) == 0 {
|
|
continue
|
|
}
|
|
// domain scopes each pattern to its .gitignore subtree (go-git semantics).
|
|
var domain []string
|
|
if rel, err := filepath.Rel(repoRoot, d); err == nil && rel != "." {
|
|
domain = strings.Split(filepath.ToSlash(rel), "/")
|
|
}
|
|
for _, line := range lines {
|
|
patterns = append(patterns, gitignore.ParsePattern(line, domain))
|
|
}
|
|
}
|
|
if len(patterns) == 0 {
|
|
return files
|
|
}
|
|
|
|
matcher := gitignore.NewMatcher(patterns)
|
|
for path, included := range files {
|
|
if !included {
|
|
continue
|
|
}
|
|
absPath, err := filepath.Abs(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
relPath, err := filepath.Rel(repoRoot, absPath)
|
|
if err != nil || relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) {
|
|
continue
|
|
}
|
|
if ignored(matcher, strings.Split(filepath.ToSlash(relPath), "/")) {
|
|
files[path] = false
|
|
}
|
|
}
|
|
|
|
return files
|
|
}
|
|
|
|
// ignored honors Git's rule that a file under an ignored directory cannot be
|
|
// re-included by a deeper negation: if any ancestor directory is ignored, so is
|
|
// the file. Otherwise the file's own verdict applies (isDir=false).
|
|
func ignored(matcher gitignore.Matcher, segments []string) bool {
|
|
for i := 1; i < len(segments); i++ {
|
|
if matcher.Match(segments[:i], true) {
|
|
return true
|
|
}
|
|
}
|
|
return matcher.Match(segments, false)
|
|
}
|
|
|
|
func withinRepo(repoRoot, p string) bool {
|
|
rel, err := filepath.Rel(repoRoot, p)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
|
|
}
|
|
|
|
// readGitignoreLines returns the .gitignore lines in dir (nil if none), cached
|
|
// per directory and invalidated by mtime so watch mode picks up edits.
|
|
func readGitignoreLines(dir string) []string {
|
|
path := filepath.Join(dir, ".gitignore")
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
mtime := info.ModTime()
|
|
|
|
if v, ok := gitignoreLinesCache.Load(dir); ok {
|
|
entry := v.(linesCacheEntry)
|
|
if entry.mtime.Equal(mtime) {
|
|
return entry.lines
|
|
}
|
|
}
|
|
|
|
lines := parseGitignoreLines(path)
|
|
gitignoreLinesCache.Store(dir, linesCacheEntry{mtime: mtime, lines: lines})
|
|
return lines
|
|
}
|
|
|
|
func parseGitignoreLines(path string) []string {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer f.Close()
|
|
|
|
var lines []string
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
line := strings.TrimRight(scanner.Text(), "\r")
|
|
if line != "" && !strings.HasPrefix(line, "#") {
|
|
lines = append(lines, line)
|
|
}
|
|
}
|
|
// On a scan error (e.g. an over-long line) keep what was parsed rather than
|
|
// dropping the whole file.
|
|
return lines
|
|
}
|