Skip to content
This repository was archived by the owner on Sep 11, 2020. It is now read-only.

Commit 2f4ac21

Browse files
author
Oleg Sklyar
committed
Adds gitignore support
1 parent 2a00316 commit 2f4ac21

File tree

9 files changed

+758
-0
lines changed

9 files changed

+758
-0
lines changed

Diff for: plumbing/format/gitignore/dir.go

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package gitignore
2+
3+
import (
4+
"io/ioutil"
5+
"strings"
6+
7+
"gopkg.in/src-d/go-billy.v2"
8+
)
9+
10+
const (
11+
commentPrefix = "#"
12+
eol = "\n"
13+
gitDir = ".git"
14+
gitignoreFile = ".gitignore"
15+
)
16+
17+
// ReadPatterns reads gitignore patterns recursively traversing through the directory
18+
// structure. The result is in the ascending order of priority (last higher).
19+
func ReadPatterns(fs billy.Filesystem, path []string) (ps []Pattern, err error) {
20+
if f, err := fs.Open(fs.Join(append(path, gitignoreFile)...)); err == nil {
21+
defer f.Close()
22+
if data, err := ioutil.ReadAll(f); err == nil {
23+
for _, s := range strings.Split(string(data), eol) {
24+
if !strings.HasPrefix(s, commentPrefix) && len(strings.TrimSpace(s)) > 0 {
25+
ps = append(ps, ParsePattern(s, path))
26+
}
27+
}
28+
}
29+
}
30+
31+
var fis []billy.FileInfo
32+
fis, err = fs.ReadDir(fs.Join(path...))
33+
if err != nil {
34+
return
35+
}
36+
for _, fi := range fis {
37+
if fi.IsDir() && fi.Name() != gitDir {
38+
var subps []Pattern
39+
subps, err = ReadPatterns(fs, append(path, fi.Name()))
40+
if err != nil {
41+
return
42+
}
43+
if len(subps) > 0 {
44+
ps = append(ps, subps...)
45+
}
46+
}
47+
}
48+
49+
return
50+
}

Diff for: plumbing/format/gitignore/dir_test.go

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package gitignore
2+
3+
import (
4+
"os"
5+
"testing"
6+
7+
"gopkg.in/src-d/go-billy.v2"
8+
"gopkg.in/src-d/go-billy.v2/memfs"
9+
)
10+
11+
func setupTestFS(subdirError bool) billy.Filesystem {
12+
fs := memfs.New()
13+
f, _ := fs.Create(".gitignore")
14+
f.Write([]byte("vendor/g*/\n"))
15+
f.Close()
16+
fs.MkdirAll("vendor", os.ModePerm)
17+
f, _ = fs.Create("vendor/.gitignore")
18+
f.Write([]byte("!github.com/\n"))
19+
f.Close()
20+
fs.MkdirAll("another", os.ModePerm)
21+
fs.MkdirAll("vendor/github.com", os.ModePerm)
22+
fs.MkdirAll("vendor/gopkg.in", os.ModePerm)
23+
return fs
24+
}
25+
26+
func TestDir_ReadPatterns(t *testing.T) {
27+
ps, err := ReadPatterns(setupTestFS(false), nil)
28+
if err != nil {
29+
t.Errorf("no error expected, found %v", err)
30+
}
31+
if len(ps) != 2 {
32+
t.Errorf("expected 2 patterns, found %v", len(ps))
33+
}
34+
m := NewMatcher(ps)
35+
if !m.Match([]string{"vendor", "gopkg.in"}, true) {
36+
t.Error("expected a match")
37+
}
38+
if m.Match([]string{"vendor", "github.com"}, true) {
39+
t.Error("expected no match")
40+
}
41+
}

Diff for: plumbing/format/gitignore/doc.go

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Package gitignore implements matching file system paths to gitignore patterns that
2+
// can be automatically read from a git repository tree in the order of definition
3+
// priorities. It support all pattern formats as specified in the original gitignore
4+
// documentation, copied below:
5+
//
6+
// Pattern format
7+
// ==============
8+
//
9+
// - A blank line matches no files, so it can serve as a separator for readability.
10+
//
11+
// - A line starting with # serves as a comment. Put a backslash ("\") in front of
12+
// the first hash for patterns that begin with a hash.
13+
//
14+
// - Trailing spaces are ignored unless they are quoted with backslash ("\").
15+
//
16+
// - An optional prefix "!" which negates the pattern; any matching file excluded
17+
// by a previous pattern will become included again. It is not possible to
18+
// re-include a file if a parent directory of that file is excluded.
19+
// Git doesn’t list excluded directories for performance reasons, so
20+
// any patterns on contained files have no effect, no matter where they are
21+
// defined. Put a backslash ("\") in front of the first "!" for patterns
22+
// that begin with a literal "!", for example, "\!important!.txt".
23+
//
24+
// - If the pattern ends with a slash, it is removed for the purpose of the
25+
// following description, but it would only find a match with a directory.
26+
// In other words, foo/ will match a directory foo and paths underneath it,
27+
// but will not match a regular file or a symbolic link foo (this is consistent
28+
// with the way how pathspec works in general in Git).
29+
//
30+
// - If the pattern does not contain a slash /, Git treats it as a shell glob
31+
// pattern and checks for a match against the pathname relative to the location
32+
// of the .gitignore file (relative to the toplevel of the work tree if not
33+
// from a .gitignore file).
34+
//
35+
// - Otherwise, Git treats the pattern as a shell glob suitable for consumption
36+
// by fnmatch(3) with the FNM_PATHNAME flag: wildcards in the pattern will
37+
// not match a / in the pathname. For example, "Documentation/*.html" matches
38+
// "Documentation/git.html" but not "Documentation/ppc/ppc.html" or
39+
// "tools/perf/Documentation/perf.html".
40+
//
41+
// - A leading slash matches the beginning of the pathname. For example,
42+
// "/*.c" matches "cat-file.c" but not "mozilla-sha1/sha1.c".
43+
//
44+
// Two consecutive asterisks ("**") in patterns matched against full pathname
45+
// may have special meaning:
46+
//
47+
// - A leading "**" followed by a slash means match in all directories.
48+
// For example, "**/foo" matches file or directory "foo" anywhere, the same as
49+
// pattern "foo". "**/foo/bar" matches file or directory "bar"
50+
// anywhere that is directly under directory "foo".
51+
//
52+
// - A trailing "/**" matches everything inside. For example, "abc/**" matches
53+
// all files inside directory "abc", relative to the location of the
54+
// .gitignore file, with infinite depth.
55+
//
56+
// - A slash followed by two consecutive asterisks then a slash matches
57+
// zero or more directories. For example, "a/**/b" matches "a/b", "a/x/b",
58+
// "a/x/y/b" and so on.
59+
//
60+
// - Other consecutive asterisks are considered invalid.
61+
//
62+
// Copyright and license
63+
// =====================
64+
//
65+
// Copyright (c) Oleg Sklyar, Silvertern and source{d}
66+
//
67+
// The package code was donated to source{d} to include, modify and develop
68+
// further as a part of the `go-git` project, release it on the license of
69+
// the whole project or delete it from the project.
70+
package gitignore

Diff for: plumbing/format/gitignore/matcher.go

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package gitignore
2+
3+
// Matcher defines a global multi-pattern matcher for gitignore patterns
4+
type Matcher interface {
5+
// Match matches patterns in the order of priorities. As soon as an inclusion or
6+
// exclusion is found, not further matching is performed.
7+
Match(path []string, isDir bool) bool
8+
}
9+
10+
// NewMatcher constructs a new global matcher. Patterns must be given in the order of
11+
// increasing priority. That is most generic settings files first, then the content of
12+
// the repo .gitignore, then content of .gitignore down the path or the repo and then
13+
// the content command line arguments.
14+
func NewMatcher(ps []Pattern) Matcher {
15+
return &matcher{ps}
16+
}
17+
18+
type matcher struct {
19+
patterns []Pattern
20+
}
21+
22+
func (m *matcher) Match(path []string, isDir bool) bool {
23+
n := len(m.patterns)
24+
for i := n - 1; i >= 0; i-- {
25+
if match := m.patterns[i].Match(path, isDir); match > NoMatch {
26+
return match == Exclude
27+
}
28+
}
29+
return false
30+
}

Diff for: plumbing/format/gitignore/matcher_test.go

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package gitignore
2+
3+
import "testing"
4+
5+
func TestMatcher_Match(t *testing.T) {
6+
ps := []Pattern{
7+
ParsePattern("**/middle/v[uo]l?ano", nil),
8+
ParsePattern("!volcano", nil),
9+
}
10+
m := NewMatcher(ps)
11+
if !m.Match([]string{"head", "middle", "vulkano"}, false) {
12+
t.Errorf("expected a match, found mismatch")
13+
}
14+
if m.Match([]string{"head", "middle", "volcano"}, false) {
15+
t.Errorf("expected a mismatch, found a match")
16+
}
17+
}

Diff for: plumbing/format/gitignore/pattern.go

+150
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package gitignore
2+
3+
import (
4+
"path/filepath"
5+
"strings"
6+
)
7+
8+
// MatchResult defines outcomes of a match, no match, exclusion or inclusion.
9+
type MatchResult int
10+
11+
const (
12+
// NoMatch defines the no match outcome of a match check
13+
NoMatch MatchResult = iota
14+
// Exclude defines an exclusion of a file as a result of a match check
15+
Exclude
16+
// Exclude defines an explicit inclusion of a file as a result of a match check
17+
Include
18+
)
19+
20+
const (
21+
inclusionPrefix = "!"
22+
zeroToManyDirs = "**"
23+
patternDirSep = "/"
24+
)
25+
26+
// Pattern defines a single gitignore pattern.
27+
type Pattern interface {
28+
// Match matches the given path to the pattern.
29+
Match(path []string, isDir bool) MatchResult
30+
}
31+
32+
type pattern struct {
33+
domain []string
34+
pattern []string
35+
inclusion bool
36+
dirOnly bool
37+
isGlob bool
38+
}
39+
40+
// ParsePattern parses a gitignore pattern string into the Pattern structure.
41+
func ParsePattern(p string, domain []string) Pattern {
42+
res := pattern{domain: domain}
43+
44+
if strings.HasPrefix(p, inclusionPrefix) {
45+
res.inclusion = true
46+
p = p[1:]
47+
}
48+
49+
if !strings.HasSuffix(p, "\\ ") {
50+
p = strings.TrimRight(p, " ")
51+
}
52+
53+
if strings.HasSuffix(p, patternDirSep) {
54+
res.dirOnly = true
55+
p = p[:len(p)-1]
56+
}
57+
58+
if strings.Contains(p, patternDirSep) {
59+
res.isGlob = true
60+
}
61+
62+
res.pattern = strings.Split(p, patternDirSep)
63+
return &res
64+
}
65+
66+
func (p *pattern) Match(path []string, isDir bool) MatchResult {
67+
if len(path) <= len(p.domain) {
68+
return NoMatch
69+
}
70+
for i, e := range p.domain {
71+
if path[i] != e {
72+
return NoMatch
73+
}
74+
}
75+
76+
path = path[len(p.domain):]
77+
if p.isGlob && !p.globMatch(path, isDir) {
78+
return NoMatch
79+
} else if !p.isGlob && !p.simpleNameMatch(path, isDir) {
80+
return NoMatch
81+
}
82+
83+
if p.inclusion {
84+
return Include
85+
} else {
86+
return Exclude
87+
}
88+
}
89+
90+
func (p *pattern) simpleNameMatch(path []string, isDir bool) bool {
91+
for i, name := range path {
92+
if match, err := filepath.Match(p.pattern[0], name); err != nil {
93+
return false
94+
} else if !match {
95+
continue
96+
}
97+
if p.dirOnly && !isDir && i == len(path)-1 {
98+
return false
99+
}
100+
return true
101+
}
102+
return false
103+
}
104+
105+
func (p *pattern) globMatch(path []string, isDir bool) bool {
106+
matched := false
107+
canTraverse := false
108+
for i, pattern := range p.pattern {
109+
if pattern == "" {
110+
canTraverse = false
111+
continue
112+
}
113+
if pattern == zeroToManyDirs {
114+
if i == len(p.pattern)-1 {
115+
break
116+
}
117+
canTraverse = true
118+
continue
119+
}
120+
if strings.Contains(pattern, zeroToManyDirs) {
121+
return false
122+
}
123+
if len(path) == 0 {
124+
return false
125+
}
126+
if canTraverse {
127+
canTraverse = false
128+
for len(path) > 0 {
129+
e := path[0]
130+
path = path[1:]
131+
if match, err := filepath.Match(pattern, e); err != nil {
132+
return false
133+
} else if match {
134+
matched = true
135+
break
136+
}
137+
}
138+
} else {
139+
if match, err := filepath.Match(pattern, path[0]); err != nil || !match {
140+
return false
141+
}
142+
matched = true
143+
path = path[1:]
144+
}
145+
}
146+
if matched && p.dirOnly && !isDir && len(path) == 0 {
147+
matched = false
148+
}
149+
return matched
150+
}

0 commit comments

Comments
 (0)