blob: 1d9a7c3b67c38aeed30603565ba4eae741ffd5b2 [file] [log] [blame]
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Package gitignore implements .gitignore check predicate.
//
// Uses only checked in .gitignore files, ignoring any globals.
package gitignore
import (
"bufio"
"os"
"path/filepath"
"strings"
"gopkg.in/src-d/go-git.v4/plumbing/format/gitignore"
"go.chromium.org/luci/common/errors"
"infra/cmd/cloudbuildhelper/fileset"
)
const (
gitDir = ".git"
ignoreFile = ".gitignore"
)
// NewExcluder returns a predicate that checks whether the given absolute path
// under given `dir` is excluded by some .gitignore file which is active in
// that directory.
func NewExcluder(dir string) (fileset.Excluder, error) {
dir, err := filepath.Abs(dir)
if err != nil {
return nil, err
}
// Find a directory with ".git".
repoRoot, err := findRepoRoot(dir)
if err != nil {
return nil, err
}
// Find possible ".gitignore" files in parent directories and *all*
// ".gitignore" files recursively under `dir`.
paths := scanUp(dir, repoRoot)
if paths, err = scanDown(paths, dir); err != nil {
return nil, err
}
// Load and parse them.
var pats []gitignore.Pattern
for _, path := range paths {
parsed, err := readIgnoreFile(path)
if err != nil {
return nil, errors.Annotate(err, "when parsing %q", path).Err()
}
pats = append(pats, parsed...)
}
// Build a fileset.Excluder out of parsed patterns.
matcher := gitignore.NewMatcher(pats)
return func(absPath string, isDir bool) bool {
return matcher.Match(splitPath(absPath), isDir)
}, nil
}
// findRepoRoot searches for a parent directory that has ".git" child directory.
//
// `start` itself is also considered during the search. Returns `start` as well
// if no better parent can be found.
func findRepoRoot(start string) (string, error) {
cur := start
for {
switch stat, err := os.Stat(filepath.Join(cur, gitDir)); {
case err == nil && stat.IsDir():
return cur, nil
case err != nil && !os.IsNotExist(err):
return "", errors.Annotate(err, "when searching for repo root of %q", start).Err()
}
par := filepath.Dir(cur)
if par == cur {
return start, nil // reached the file system root
}
cur = par
}
}
// scanUp returns potential .gitignore paths in parents of `start` up to `root`.
//
// It is purely lexicographical computation, the file system is not touched.
// Files closer to `root` come first.
func scanUp(start, root string) (paths []string) {
cur := start
for {
par := filepath.Dir(cur)
paths = append(paths, filepath.Join(par, ignoreFile))
if par == root || par == cur {
break
}
cur = par
}
// We need the result in reversed order (`root` to `start`).
for l, r := 0, len(paths)-1; l < r; l, r = l+1, r-1 {
paths[l], paths[r] = paths[r], paths[l]
}
return
}
// scanDown recursively searches for ".gitignore" files under `start`.
//
// Adds them to `paths` slice, returning it in the end.
func scanDown(paths []string, start string) ([]string, error) {
err := filepath.Walk(start, func(path string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() && filepath.Base(path) == ignoreFile {
paths = append(paths, path)
}
return err
})
if err != nil {
return nil, errors.Annotate(err, "when scanning for .gitignore in %q", start).Err()
}
return paths, nil
}
// splitPath splits a path into components, as weird go-git.v4 API wants it.
func splitPath(p string) []string {
return strings.Split(filepath.Clean(p), string(filepath.Separator))
}
// readIgnoreFile reads a single git ignore file.
//
// Returns (nil, nil) if it doesn't exist.
func readIgnoreFile(path string) (pat []gitignore.Pattern, err error) {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
err = nil
}
return
}
defer f.Close()
// Weird go-git.v4 API wants paths split into components.
domain := splitPath(filepath.Dir(path))
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if len(line) > 0 && !strings.HasPrefix(line, "#") {
pat = append(pat, gitignore.ParsePattern(line, domain))
}
}
err = scanner.Err()
return
}