aboutsummaryrefslogtreecommitdiffstats
path: root/plumbing/format/gitattributes
diff options
context:
space:
mode:
Diffstat (limited to 'plumbing/format/gitattributes')
-rw-r--r--plumbing/format/gitattributes/attributes.go214
-rw-r--r--plumbing/format/gitattributes/attributes_test.go67
-rw-r--r--plumbing/format/gitattributes/dir.go126
-rw-r--r--plumbing/format/gitattributes/dir_test.go199
-rw-r--r--plumbing/format/gitattributes/matcher.go78
-rw-r--r--plumbing/format/gitattributes/matcher_test.go29
-rw-r--r--plumbing/format/gitattributes/pattern.go101
-rw-r--r--plumbing/format/gitattributes/pattern_test.go229
8 files changed, 1043 insertions, 0 deletions
diff --git a/plumbing/format/gitattributes/attributes.go b/plumbing/format/gitattributes/attributes.go
new file mode 100644
index 0000000..d13c2a9
--- /dev/null
+++ b/plumbing/format/gitattributes/attributes.go
@@ -0,0 +1,214 @@
+package gitattributes
+
+import (
+ "errors"
+ "io"
+ "io/ioutil"
+ "strings"
+)
+
+const (
+ commentPrefix = "#"
+ eol = "\n"
+ macroPrefix = "[attr]"
+)
+
+var (
+ ErrMacroNotAllowed = errors.New("macro not allowed")
+ ErrInvalidAttributeName = errors.New("Invalid attribute name")
+)
+
+type MatchAttribute struct {
+ Name string
+ Pattern Pattern
+ Attributes []Attribute
+}
+
+type attributeState byte
+
+const (
+ attributeUnknown attributeState = 0
+ attributeSet attributeState = 1
+ attributeUnspecified attributeState = '!'
+ attributeUnset attributeState = '-'
+ attributeSetValue attributeState = '='
+)
+
+type Attribute interface {
+ Name() string
+ IsSet() bool
+ IsUnset() bool
+ IsUnspecified() bool
+ IsValueSet() bool
+ Value() string
+ String() string
+}
+
+type attribute struct {
+ name string
+ state attributeState
+ value string
+}
+
+func (a attribute) Name() string {
+ return a.name
+}
+
+func (a attribute) IsSet() bool {
+ return a.state == attributeSet
+}
+
+func (a attribute) IsUnset() bool {
+ return a.state == attributeUnset
+}
+
+func (a attribute) IsUnspecified() bool {
+ return a.state == attributeUnspecified
+}
+
+func (a attribute) IsValueSet() bool {
+ return a.state == attributeSetValue
+}
+
+func (a attribute) Value() string {
+ return a.value
+}
+
+func (a attribute) String() string {
+ switch a.state {
+ case attributeSet:
+ return a.name + ": set"
+ case attributeUnset:
+ return a.name + ": unset"
+ case attributeUnspecified:
+ return a.name + ": unspecified"
+ default:
+ return a.name + ": " + a.value
+ }
+}
+
+// ReadAttributes reads patterns and attributes from the gitattributes format.
+func ReadAttributes(r io.Reader, domain []string, allowMacro bool) (attributes []MatchAttribute, err error) {
+ data, err := ioutil.ReadAll(r)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, line := range strings.Split(string(data), eol) {
+ attribute, err := ParseAttributesLine(line, domain, allowMacro)
+ if err != nil {
+ return attributes, err
+ }
+ if len(attribute.Name) == 0 {
+ continue
+ }
+
+ attributes = append(attributes, attribute)
+ }
+
+ return attributes, nil
+}
+
+// ParseAttributesLine parses a gitattribute line, extracting path pattern and
+// attributes.
+func ParseAttributesLine(line string, domain []string, allowMacro bool) (m MatchAttribute, err error) {
+ line = strings.TrimSpace(line)
+
+ if strings.HasPrefix(line, commentPrefix) || len(line) == 0 {
+ return
+ }
+
+ name, unquoted := unquote(line)
+ attrs := strings.Fields(unquoted)
+ if len(name) == 0 {
+ name = attrs[0]
+ attrs = attrs[1:]
+ }
+
+ var macro bool
+ macro, name, err = checkMacro(name, allowMacro)
+ if err != nil {
+ return
+ }
+
+ m.Name = name
+ m.Attributes = make([]Attribute, 0, len(attrs))
+
+ for _, attrName := range attrs {
+ attr := attribute{
+ name: attrName,
+ state: attributeSet,
+ }
+
+ // ! and - prefixes
+ state := attributeState(attr.name[0])
+ if state == attributeUnspecified || state == attributeUnset {
+ attr.state = state
+ attr.name = attr.name[1:]
+ }
+
+ kv := strings.SplitN(attrName, "=", 2)
+ if len(kv) == 2 {
+ attr.name = kv[0]
+ attr.value = kv[1]
+ attr.state = attributeSetValue
+ }
+
+ if !validAttributeName(attr.name) {
+ return m, ErrInvalidAttributeName
+ }
+ m.Attributes = append(m.Attributes, attr)
+ }
+
+ if !macro {
+ m.Pattern = ParsePattern(name, domain)
+ }
+ return
+}
+
+func checkMacro(name string, allowMacro bool) (macro bool, macroName string, err error) {
+ if !strings.HasPrefix(name, macroPrefix) {
+ return false, name, nil
+ }
+ if !allowMacro {
+ return true, name, ErrMacroNotAllowed
+ }
+
+ macroName = name[len(macroPrefix):]
+ if !validAttributeName(macroName) {
+ return true, name, ErrInvalidAttributeName
+ }
+ return true, macroName, nil
+}
+
+func validAttributeName(name string) bool {
+ if len(name) == 0 || name[0] == '-' {
+ return false
+ }
+
+ for _, ch := range name {
+ if !(ch == '-' || ch == '.' || ch == '_' ||
+ ('0' <= ch && ch <= '9') ||
+ ('a' <= ch && ch <= 'z') ||
+ ('A' <= ch && ch <= 'Z')) {
+ return false
+ }
+ }
+ return true
+}
+
+func unquote(str string) (string, string) {
+ if str[0] != '"' {
+ return "", str
+ }
+
+ for i := 1; i < len(str); i++ {
+ switch str[i] {
+ case '\\':
+ i++
+ case '"':
+ return str[1:i], str[i+1:]
+ }
+ }
+ return "", str
+}
diff --git a/plumbing/format/gitattributes/attributes_test.go b/plumbing/format/gitattributes/attributes_test.go
new file mode 100644
index 0000000..aea70ba
--- /dev/null
+++ b/plumbing/format/gitattributes/attributes_test.go
@@ -0,0 +1,67 @@
+package gitattributes
+
+import (
+ "strings"
+
+ . "gopkg.in/check.v1"
+)
+
+type AttributesSuite struct{}
+
+var _ = Suite(&AttributesSuite{})
+
+func (s *AttributesSuite) TestAttributes_ReadAttributes(c *C) {
+ lines := []string{
+ "[attr]sub -a",
+ "[attr]add a",
+ "* sub a",
+ "* !a foo=bar -b c",
+ }
+
+ mas, err := ReadAttributes(strings.NewReader(strings.Join(lines, "\n")), nil, true)
+ c.Assert(err, IsNil)
+ c.Assert(len(mas), Equals, 4)
+
+ c.Assert(mas[0].Name, Equals, "sub")
+ c.Assert(mas[0].Pattern, IsNil)
+ c.Assert(mas[0].Attributes[0].IsUnset(), Equals, true)
+
+ c.Assert(mas[1].Name, Equals, "add")
+ c.Assert(mas[1].Pattern, IsNil)
+ c.Assert(mas[1].Attributes[0].IsSet(), Equals, true)
+
+ c.Assert(mas[2].Name, Equals, "*")
+ c.Assert(mas[2].Pattern, NotNil)
+ c.Assert(mas[2].Attributes[0].IsSet(), Equals, true)
+
+ c.Assert(mas[3].Name, Equals, "*")
+ c.Assert(mas[3].Pattern, NotNil)
+ c.Assert(mas[3].Attributes[0].IsUnspecified(), Equals, true)
+ c.Assert(mas[3].Attributes[1].IsValueSet(), Equals, true)
+ c.Assert(mas[3].Attributes[1].Value(), Equals, "bar")
+ c.Assert(mas[3].Attributes[2].IsUnset(), Equals, true)
+ c.Assert(mas[3].Attributes[3].IsSet(), Equals, true)
+ c.Assert(mas[3].Attributes[0].String(), Equals, "a: unspecified")
+ c.Assert(mas[3].Attributes[1].String(), Equals, "foo: bar")
+ c.Assert(mas[3].Attributes[2].String(), Equals, "b: unset")
+ c.Assert(mas[3].Attributes[3].String(), Equals, "c: set")
+}
+
+func (s *AttributesSuite) TestAttributes_ReadAttributesDisallowMacro(c *C) {
+ lines := []string{
+ "[attr]sub -a",
+ "* a add",
+ }
+
+ _, err := ReadAttributes(strings.NewReader(strings.Join(lines, "\n")), nil, false)
+ c.Assert(err, Equals, ErrMacroNotAllowed)
+}
+
+func (s *AttributesSuite) TestAttributes_ReadAttributesInvalidName(c *C) {
+ lines := []string{
+ "[attr]foo!bar -a",
+ }
+
+ _, err := ReadAttributes(strings.NewReader(strings.Join(lines, "\n")), nil, true)
+ c.Assert(err, Equals, ErrInvalidAttributeName)
+}
diff --git a/plumbing/format/gitattributes/dir.go b/plumbing/format/gitattributes/dir.go
new file mode 100644
index 0000000..d5c1e6a
--- /dev/null
+++ b/plumbing/format/gitattributes/dir.go
@@ -0,0 +1,126 @@
+package gitattributes
+
+import (
+ "os"
+ "os/user"
+
+ "gopkg.in/src-d/go-billy.v4"
+ "gopkg.in/src-d/go-git.v4/plumbing/format/config"
+ gioutil "gopkg.in/src-d/go-git.v4/utils/ioutil"
+)
+
+const (
+ coreSection = "core"
+ attributesfile = "attributesfile"
+ gitDir = ".git"
+ gitattributesFile = ".gitattributes"
+ gitconfigFile = ".gitconfig"
+ systemFile = "/etc/gitconfig"
+)
+
+func ReadAttributesFile(fs billy.Filesystem, path []string, attributesFile string, allowMacro bool) ([]MatchAttribute, error) {
+ f, err := fs.Open(fs.Join(append(path, attributesFile)...))
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return ReadAttributes(f, path, allowMacro)
+}
+
+// ReadPatterns reads gitattributes patterns recursively through the directory
+// structure. The result is in ascending order of priority (last higher).
+//
+// The .gitattribute file in the root directory will allow custom macro
+// definitions. Custom macro definitions in other directories .gitattributes
+// will return an error.
+func ReadPatterns(fs billy.Filesystem, path []string) (attributes []MatchAttribute, err error) {
+ attributes, err = ReadAttributesFile(fs, path, gitattributesFile, true)
+ if err != nil {
+ return
+ }
+
+ attrs, err := walkDirectory(fs, path)
+ return append(attributes, attrs...), err
+}
+
+func walkDirectory(fs billy.Filesystem, root []string) (attributes []MatchAttribute, err error) {
+ fis, err := fs.ReadDir(fs.Join(root...))
+ if err != nil {
+ return attributes, err
+ }
+
+ for _, fi := range fis {
+ if !fi.IsDir() || fi.Name() == ".git" {
+ continue
+ }
+
+ path := append(root, fi.Name())
+
+ dirAttributes, err := ReadAttributesFile(fs, path, gitattributesFile, false)
+ if err != nil {
+ return attributes, err
+ }
+
+ subAttributes, err := walkDirectory(fs, path)
+ if err != nil {
+ return attributes, err
+ }
+
+ attributes = append(attributes, append(dirAttributes, subAttributes...)...)
+ }
+
+ return
+}
+
+func loadPatterns(fs billy.Filesystem, path string) ([]MatchAttribute, error) {
+ f, err := fs.Open(path)
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ defer gioutil.CheckClose(f, &err)
+
+ raw := config.New()
+ if err = config.NewDecoder(f).Decode(raw); err != nil {
+ return nil, nil
+ }
+
+ path = raw.Section(coreSection).Options.Get(attributesfile)
+ if path == "" {
+ return nil, nil
+ }
+
+ return ReadAttributesFile(fs, nil, path, true)
+}
+
+// LoadGlobalPatterns loads gitattributes patterns and attributes from the
+// gitattributes file declared in a user's ~/.gitconfig file. If the
+// ~/.gitconfig file does not exist the function will return nil. If the
+// core.attributesFile property is not declared, the function will return nil.
+// If the file pointed to by the core.attributesfile property does not exist,
+// the function will return nil. The function assumes fs is rooted at the root
+// filesystem.
+func LoadGlobalPatterns(fs billy.Filesystem) (attributes []MatchAttribute, err error) {
+ usr, err := user.Current()
+ if err != nil {
+ return
+ }
+
+ return loadPatterns(fs, fs.Join(usr.HomeDir, gitconfigFile))
+}
+
+// LoadSystemPatterns loads gitattributes patterns and attributes from the
+// gitattributes file declared in a system's /etc/gitconfig file. If the
+// /etc/gitconfig file does not exist the function will return nil. If the
+// core.attributesfile property is not declared, the function will return nil.
+// If the file pointed to by the core.attributesfile property does not exist,
+// the function will return nil. The function assumes fs is rooted at the root
+// filesystem.
+func LoadSystemPatterns(fs billy.Filesystem) (attributes []MatchAttribute, err error) {
+ return loadPatterns(fs, systemFile)
+}
diff --git a/plumbing/format/gitattributes/dir_test.go b/plumbing/format/gitattributes/dir_test.go
new file mode 100644
index 0000000..34b915d
--- /dev/null
+++ b/plumbing/format/gitattributes/dir_test.go
@@ -0,0 +1,199 @@
+package gitattributes
+
+import (
+ "os"
+ "os/user"
+ "strconv"
+
+ . "gopkg.in/check.v1"
+ "gopkg.in/src-d/go-billy.v4"
+ "gopkg.in/src-d/go-billy.v4/memfs"
+)
+
+type MatcherSuite struct {
+ GFS billy.Filesystem // git repository root
+ RFS billy.Filesystem // root that contains user home
+ MCFS billy.Filesystem // root that contains user home, but missing ~/.gitattributes
+ MEFS billy.Filesystem // root that contains user home, but missing attributesfile entry
+ MIFS billy.Filesystem // root that contains user home, but missing .gitattributes
+
+ SFS billy.Filesystem // root that contains /etc/gitattributes
+}
+
+var _ = Suite(&MatcherSuite{})
+
+func (s *MatcherSuite) SetUpTest(c *C) {
+ // setup root that contains user home
+ usr, err := user.Current()
+ c.Assert(err, IsNil)
+
+ gitAttributesGlobal := func(fs billy.Filesystem, filename string) {
+ f, err := fs.Create(filename)
+ c.Assert(err, IsNil)
+ _, err = f.Write([]byte("# IntelliJ\n"))
+ c.Assert(err, IsNil)
+ _, err = f.Write([]byte(".idea/** text\n"))
+ c.Assert(err, IsNil)
+ _, err = f.Write([]byte("*.iml -text\n"))
+ c.Assert(err, IsNil)
+ err = f.Close()
+ c.Assert(err, IsNil)
+ }
+
+ // setup generic git repository root
+ fs := memfs.New()
+ f, err := fs.Create(".gitattributes")
+ c.Assert(err, IsNil)
+ _, err = f.Write([]byte("vendor/g*/** foo=bar\n"))
+ c.Assert(err, IsNil)
+ err = f.Close()
+ c.Assert(err, IsNil)
+
+ err = fs.MkdirAll("vendor", os.ModePerm)
+ c.Assert(err, IsNil)
+ f, err = fs.Create("vendor/.gitattributes")
+ c.Assert(err, IsNil)
+ _, err = f.Write([]byte("github.com/** -foo\n"))
+ c.Assert(err, IsNil)
+ err = f.Close()
+ c.Assert(err, IsNil)
+
+ fs.MkdirAll("another", os.ModePerm)
+ fs.MkdirAll("vendor/github.com", os.ModePerm)
+ fs.MkdirAll("vendor/gopkg.in", os.ModePerm)
+
+ gitAttributesGlobal(fs, fs.Join(usr.HomeDir, ".gitattributes_global"))
+
+ s.GFS = fs
+
+ fs = memfs.New()
+ err = fs.MkdirAll(usr.HomeDir, os.ModePerm)
+ c.Assert(err, IsNil)
+
+ f, err = fs.Create(fs.Join(usr.HomeDir, gitconfigFile))
+ c.Assert(err, IsNil)
+ _, err = f.Write([]byte("[core]\n"))
+ c.Assert(err, IsNil)
+ _, err = f.Write([]byte(" attributesfile = " + strconv.Quote(fs.Join(usr.HomeDir, ".gitattributes_global")) + "\n"))
+ c.Assert(err, IsNil)
+ err = f.Close()
+ c.Assert(err, IsNil)
+
+ gitAttributesGlobal(fs, fs.Join(usr.HomeDir, ".gitattributes_global"))
+
+ s.RFS = fs
+
+ // root that contains user home, but missing ~/.gitconfig
+ fs = memfs.New()
+ gitAttributesGlobal(fs, fs.Join(usr.HomeDir, ".gitattributes_global"))
+
+ s.MCFS = fs
+
+ // setup root that contains user home, but missing attributesfile entry
+ fs = memfs.New()
+ err = fs.MkdirAll(usr.HomeDir, os.ModePerm)
+ c.Assert(err, IsNil)
+
+ f, err = fs.Create(fs.Join(usr.HomeDir, gitconfigFile))
+ c.Assert(err, IsNil)
+ _, err = f.Write([]byte("[core]\n"))
+ c.Assert(err, IsNil)
+ err = f.Close()
+ c.Assert(err, IsNil)
+
+ gitAttributesGlobal(fs, fs.Join(usr.HomeDir, ".gitattributes_global"))
+
+ s.MEFS = fs
+
+ // setup root that contains user home, but missing .gitattributes
+ fs = memfs.New()
+ err = fs.MkdirAll(usr.HomeDir, os.ModePerm)
+ c.Assert(err, IsNil)
+
+ f, err = fs.Create(fs.Join(usr.HomeDir, gitconfigFile))
+ c.Assert(err, IsNil)
+ _, err = f.Write([]byte("[core]\n"))
+ c.Assert(err, IsNil)
+ _, err = f.Write([]byte(" attributesfile = " + strconv.Quote(fs.Join(usr.HomeDir, ".gitattributes_global")) + "\n"))
+ c.Assert(err, IsNil)
+ err = f.Close()
+ c.Assert(err, IsNil)
+
+ s.MIFS = fs
+
+ // setup root that contains user home
+ fs = memfs.New()
+ err = fs.MkdirAll("etc", os.ModePerm)
+ c.Assert(err, IsNil)
+
+ f, err = fs.Create(systemFile)
+ c.Assert(err, IsNil)
+ _, err = f.Write([]byte("[core]\n"))
+ c.Assert(err, IsNil)
+ _, err = f.Write([]byte(" attributesfile = /etc/gitattributes_global\n"))
+ c.Assert(err, IsNil)
+ err = f.Close()
+ c.Assert(err, IsNil)
+
+ gitAttributesGlobal(fs, "/etc/gitattributes_global")
+
+ s.SFS = fs
+}
+
+func (s *MatcherSuite) TestDir_ReadPatterns(c *C) {
+ ps, err := ReadPatterns(s.GFS, nil)
+ c.Assert(err, IsNil)
+ c.Assert(ps, HasLen, 2)
+
+ m := NewMatcher(ps)
+ results, _ := m.Match([]string{"vendor", "gopkg.in", "file"}, nil)
+ c.Assert(results["foo"].Value(), Equals, "bar")
+
+ results, _ = m.Match([]string{"vendor", "github.com", "file"}, nil)
+ c.Assert(results["foo"].IsUnset(), Equals, false)
+}
+
+func (s *MatcherSuite) TestDir_LoadGlobalPatterns(c *C) {
+ ps, err := LoadGlobalPatterns(s.RFS)
+ c.Assert(err, IsNil)
+ c.Assert(ps, HasLen, 2)
+
+ m := NewMatcher(ps)
+
+ results, _ := m.Match([]string{"go-git.v4.iml"}, nil)
+ c.Assert(results["text"].IsUnset(), Equals, true)
+
+ results, _ = m.Match([]string{".idea", "file"}, nil)
+ c.Assert(results["text"].IsSet(), Equals, true)
+}
+
+func (s *MatcherSuite) TestDir_LoadGlobalPatternsMissingGitconfig(c *C) {
+ ps, err := LoadGlobalPatterns(s.MCFS)
+ c.Assert(err, IsNil)
+ c.Assert(ps, HasLen, 0)
+}
+
+func (s *MatcherSuite) TestDir_LoadGlobalPatternsMissingAttributesfile(c *C) {
+ ps, err := LoadGlobalPatterns(s.MEFS)
+ c.Assert(err, IsNil)
+ c.Assert(ps, HasLen, 0)
+}
+
+func (s *MatcherSuite) TestDir_LoadGlobalPatternsMissingGitattributes(c *C) {
+ ps, err := LoadGlobalPatterns(s.MIFS)
+ c.Assert(err, IsNil)
+ c.Assert(ps, HasLen, 0)
+}
+
+func (s *MatcherSuite) TestDir_LoadSystemPatterns(c *C) {
+ ps, err := LoadSystemPatterns(s.SFS)
+ c.Assert(err, IsNil)
+ c.Assert(ps, HasLen, 2)
+
+ m := NewMatcher(ps)
+ results, _ := m.Match([]string{"go-git.v4.iml"}, nil)
+ c.Assert(results["text"].IsUnset(), Equals, true)
+
+ results, _ = m.Match([]string{".idea", "file"}, nil)
+ c.Assert(results["text"].IsSet(), Equals, true)
+}
diff --git a/plumbing/format/gitattributes/matcher.go b/plumbing/format/gitattributes/matcher.go
new file mode 100644
index 0000000..df12864
--- /dev/null
+++ b/plumbing/format/gitattributes/matcher.go
@@ -0,0 +1,78 @@
+package gitattributes
+
+// Matcher defines a global multi-pattern matcher for gitattributes patterns
+type Matcher interface {
+ // Match matches patterns in the order of priorities.
+ Match(path []string, attributes []string) (map[string]Attribute, bool)
+}
+
+type MatcherOptions struct{}
+
+// NewMatcher constructs a new matcher. Patterns must be given in the order of
+// increasing priority. That is the most generic settings files first, then the
+// content of the repo .gitattributes, then content of .gitattributes down the
+// path.
+func NewMatcher(stack []MatchAttribute) Matcher {
+ m := &matcher{stack: stack}
+ m.init()
+
+ return m
+}
+
+type matcher struct {
+ stack []MatchAttribute
+ macros map[string]MatchAttribute
+}
+
+func (m *matcher) init() {
+ m.macros = make(map[string]MatchAttribute)
+
+ for _, attr := range m.stack {
+ if attr.Pattern == nil {
+ m.macros[attr.Name] = attr
+ }
+ }
+}
+
+// Match matches path against the patterns in gitattributes files and returns
+// the attributes associated with the path.
+//
+// Specific attributes can be specified otherwise all attributes are returned.
+//
+// Matched is true if any path was matched to a rule, even if the results map
+// is empty.
+func (m *matcher) Match(path []string, attributes []string) (results map[string]Attribute, matched bool) {
+ results = make(map[string]Attribute, len(attributes))
+
+ n := len(m.stack)
+ for i := n - 1; i >= 0; i-- {
+ if len(attributes) > 0 && len(attributes) == len(results) {
+ return
+ }
+
+ pattern := m.stack[i].Pattern
+ if pattern == nil {
+ continue
+ }
+
+ if match := pattern.Match(path); match {
+ matched = true
+ for _, attr := range m.stack[i].Attributes {
+ if attr.IsSet() {
+ m.expandMacro(attr.Name(), results)
+ }
+ results[attr.Name()] = attr
+ }
+ }
+ }
+ return
+}
+
+func (m *matcher) expandMacro(name string, results map[string]Attribute) bool {
+ if macro, ok := m.macros[name]; ok {
+ for _, attr := range macro.Attributes {
+ results[attr.Name()] = attr
+ }
+ }
+ return false
+}
diff --git a/plumbing/format/gitattributes/matcher_test.go b/plumbing/format/gitattributes/matcher_test.go
new file mode 100644
index 0000000..edb71a1
--- /dev/null
+++ b/plumbing/format/gitattributes/matcher_test.go
@@ -0,0 +1,29 @@
+package gitattributes
+
+import (
+ "strings"
+
+ . "gopkg.in/check.v1"
+)
+
+func (s *MatcherSuite) TestMatcher_Match(c *C) {
+ lines := []string{
+ "[attr]binary -diff -merge -text",
+ "**/middle/v[uo]l?ano binary text eol=crlf",
+ "volcano -eol",
+ "foobar diff merge text eol=lf foo=bar",
+ }
+
+ ma, err := ReadAttributes(strings.NewReader(strings.Join(lines, "\n")), nil, true)
+ c.Assert(err, IsNil)
+
+ m := NewMatcher(ma)
+ results, matched := m.Match([]string{"head", "middle", "vulkano"}, nil)
+
+ c.Assert(matched, Equals, true)
+ c.Assert(results["binary"].IsSet(), Equals, true)
+ c.Assert(results["diff"].IsUnset(), Equals, true)
+ c.Assert(results["merge"].IsUnset(), Equals, true)
+ c.Assert(results["text"].IsSet(), Equals, true)
+ c.Assert(results["eol"].Value(), Equals, "crlf")
+}
diff --git a/plumbing/format/gitattributes/pattern.go b/plumbing/format/gitattributes/pattern.go
new file mode 100644
index 0000000..c5ca0c7
--- /dev/null
+++ b/plumbing/format/gitattributes/pattern.go
@@ -0,0 +1,101 @@
+package gitattributes
+
+import (
+ "path/filepath"
+ "strings"
+)
+
+const (
+ patternDirSep = "/"
+ zeroToManyDirs = "**"
+)
+
+// Pattern defines a gitattributes pattern.
+type Pattern interface {
+ // Match matches the given path to the pattern.
+ Match(path []string) bool
+}
+
+type pattern struct {
+ domain []string
+ pattern []string
+}
+
+// ParsePattern parses a gitattributes pattern string into the Pattern
+// structure.
+func ParsePattern(p string, domain []string) Pattern {
+ return &pattern{
+ domain: domain,
+ pattern: strings.Split(p, patternDirSep),
+ }
+}
+
+func (p *pattern) Match(path []string) bool {
+ if len(path) <= len(p.domain) {
+ return false
+ }
+ for i, e := range p.domain {
+ if path[i] != e {
+ return false
+ }
+ }
+
+ if len(p.pattern) == 1 {
+ // for a simple rule, .gitattribute matching rules differs from
+ // .gitignore and only the last part of the path is considered.
+ path = path[len(path)-1:]
+ } else {
+ path = path[len(p.domain):]
+ }
+
+ pattern := p.pattern
+ var match, doublestar bool
+ var err error
+ for _, part := range path {
+ // skip empty
+ if pattern[0] == "" {
+ pattern = pattern[1:]
+ }
+
+ // eat doublestar
+ if pattern[0] == zeroToManyDirs {
+ pattern = pattern[1:]
+ if len(pattern) == 0 {
+ return true
+ }
+ doublestar = true
+ }
+
+ switch true {
+ case strings.Contains(pattern[0], "**"):
+ return false
+
+ // keep going down the path until we hit a match
+ case doublestar:
+ match, err = filepath.Match(pattern[0], part)
+ if err != nil {
+ return false
+ }
+
+ if match {
+ doublestar = false
+ pattern = pattern[1:]
+ }
+
+ default:
+ match, err = filepath.Match(pattern[0], part)
+ if err != nil {
+ return false
+ }
+ if !match {
+ return false
+ }
+ pattern = pattern[1:]
+ }
+ }
+
+ if len(pattern) > 0 {
+ return false
+ }
+ return match
+}
diff --git a/plumbing/format/gitattributes/pattern_test.go b/plumbing/format/gitattributes/pattern_test.go
new file mode 100644
index 0000000..f95be6e
--- /dev/null
+++ b/plumbing/format/gitattributes/pattern_test.go
@@ -0,0 +1,229 @@
+package gitattributes
+
+import (
+ "testing"
+
+ . "gopkg.in/check.v1"
+)
+
+func Test(t *testing.T) { TestingT(t) }
+
+type PatternSuite struct{}
+
+var _ = Suite(&PatternSuite{})
+
+func (s *PatternSuite) TestMatch_domainLonger_mismatch(c *C) {
+ p := ParsePattern("value", []string{"head", "middle", "tail"})
+ r := p.Match([]string{"head", "middle"})
+ c.Assert(r, Equals, false)
+}
+
+func (s *PatternSuite) TestMatch_domainSameLength_mismatch(c *C) {
+ p := ParsePattern("value", []string{"head", "middle", "tail"})
+ r := p.Match([]string{"head", "middle", "tail"})
+ c.Assert(r, Equals, false)
+}
+
+func (s *PatternSuite) TestMatch_domainMismatch_mismatch(c *C) {
+ p := ParsePattern("value", []string{"head", "middle", "tail"})
+ r := p.Match([]string{"head", "middle", "_tail_", "value"})
+ c.Assert(r, Equals, false)
+}
+
+func (s *PatternSuite) TestSimpleMatch_match(c *C) {
+ p := ParsePattern("vul?ano", nil)
+ r := p.Match([]string{"value", "vulkano"})
+ c.Assert(r, Equals, true)
+}
+
+func (s *PatternSuite) TestSimpleMatch_withDomain(c *C) {
+ p := ParsePattern("middle/tail", []string{"value", "volcano"})
+ r := p.Match([]string{"value", "volcano", "middle", "tail"})
+ c.Assert(r, Equals, true)
+}
+
+func (s *PatternSuite) TestSimpleMatch_onlyMatchInDomain_mismatch(c *C) {
+ p := ParsePattern("value/volcano", []string{"value", "volcano"})
+ r := p.Match([]string{"value", "volcano", "tail"})
+ c.Assert(r, Equals, false)
+}
+
+func (s *PatternSuite) TestSimpleMatch_atStart(c *C) {
+ p := ParsePattern("value", nil)
+ r := p.Match([]string{"value", "tail"})
+ c.Assert(r, Equals, false)
+}
+
+func (s *PatternSuite) TestSimpleMatch_inTheMiddle(c *C) {
+ p := ParsePattern("value", nil)
+ r := p.Match([]string{"head", "value", "tail"})
+ c.Assert(r, Equals, false)
+}
+
+func (s *PatternSuite) TestSimpleMatch_atEnd(c *C) {
+ p := ParsePattern("value", nil)
+ r := p.Match([]string{"head", "value"})
+ c.Assert(r, Equals, true)
+}
+
+func (s *PatternSuite) TestSimpleMatch_mismatch(c *C) {
+ p := ParsePattern("value", nil)
+ r := p.Match([]string{"head", "val", "tail"})
+ c.Assert(r, Equals, false)
+}
+
+func (s *PatternSuite) TestSimpleMatch_valueLonger_mismatch(c *C) {
+ p := ParsePattern("tai", nil)
+ r := p.Match([]string{"head", "value", "tail"})
+ c.Assert(r, Equals, false)
+}
+
+func (s *PatternSuite) TestSimpleMatch_withAsterisk(c *C) {
+ p := ParsePattern("t*l", nil)
+ r := p.Match([]string{"value", "vulkano", "tail"})
+ c.Assert(r, Equals, true)
+}
+
+func (s *PatternSuite) TestSimpleMatch_withQuestionMark(c *C) {
+ p := ParsePattern("ta?l", nil)
+ r := p.Match([]string{"value", "vulkano", "tail"})
+ c.Assert(r, Equals, true)
+}
+
+func (s *PatternSuite) TestSimpleMatch_magicChars(c *C) {
+ p := ParsePattern("v[ou]l[kc]ano", nil)
+ r := p.Match([]string{"value", "volcano"})
+ c.Assert(r, Equals, true)
+}
+
+func (s *PatternSuite) TestSimpleMatch_wrongPattern_mismatch(c *C) {
+ p := ParsePattern("v[ou]l[", nil)
+ r := p.Match([]string{"value", "vol["})
+ c.Assert(r, Equals, false)
+}
+
+func (s *PatternSuite) TestGlobMatch_fromRootWithSlash(c *C) {
+ p := ParsePattern("/value/vul?ano/tail", nil)
+ r := p.Match([]string{"value", "vulkano", "tail"})
+ c.Assert(r, Equals, true)
+}
+
+func (s *PatternSuite) TestGlobMatch_withDomain(c *C) {
+ p := ParsePattern("middle/tail", []string{"value", "volcano"})
+ r := p.Match([]string{"value", "volcano", "middle", "tail"})
+ c.Assert(r, Equals, true)
+}
+
+func (s *PatternSuite) TestGlobMatch_onlyMatchInDomain_mismatch(c *C) {
+ p := ParsePattern("volcano/tail", []string{"value", "volcano"})
+ r := p.Match([]string{"value", "volcano", "tail"})
+ c.Assert(r, Equals, false)
+}
+
+func (s *PatternSuite) TestGlobMatch_fromRootWithoutSlash(c *C) {
+ p := ParsePattern("value/vul?ano/tail", nil)
+ r := p.Match([]string{"value", "vulkano", "tail"})
+ c.Assert(r, Equals, true)
+}
+
+func (s *PatternSuite) TestGlobMatch_fromRoot_mismatch(c *C) {
+ p := ParsePattern("value/vulkano", nil)
+ r := p.Match([]string{"value", "volcano"})
+ c.Assert(r, Equals, false)
+}
+
+func (s *PatternSuite) TestGlobMatch_fromRoot_tooShort_mismatch(c *C) {
+ p := ParsePattern("value/vul?ano", nil)
+ r := p.Match([]string{"value"})
+ c.Assert(r, Equals, false)
+}
+
+func (s *PatternSuite) TestGlobMatch_fromRoot_notAtRoot_mismatch(c *C) {
+ p := ParsePattern("/value/volcano", nil)
+ r := p.Match([]string{"value", "value", "volcano"})
+ c.Assert(r, Equals, false)
+}
+
+func (s *PatternSuite) TestGlobMatch_leadingAsterisks_atStart(c *C) {
+ p := ParsePattern("**/*lue/vol?ano/ta?l", nil)
+ r := p.Match([]string{"value", "volcano", "tail"})
+ c.Assert(r, Equals, true)
+}
+
+func (s *PatternSuite) TestGlobMatch_leadingAsterisks_notAtStart(c *C) {
+ p := ParsePattern("**/*lue/vol?ano/tail", nil)
+ r := p.Match([]string{"head", "value", "volcano", "tail"})
+ c.Assert(r, Equals, true)
+}
+
+func (s *PatternSuite) TestGlobMatch_leadingAsterisks_mismatch(c *C) {
+ p := ParsePattern("**/*lue/vol?ano/tail", nil)
+ r := p.Match([]string{"head", "value", "Volcano", "tail"})
+ c.Assert(r, Equals, false)
+}
+
+func (s *PatternSuite) TestGlobMatch_tailingAsterisks(c *C) {
+ p := ParsePattern("/*lue/vol?ano/**", nil)
+ r := p.Match([]string{"value", "volcano", "tail", "moretail"})
+ c.Assert(r, Equals, true)
+}
+
+func (s *PatternSuite) TestGlobMatch_tailingAsterisks_single(c *C) {
+ p := ParsePattern("/*lue/**", nil)
+ r := p.Match([]string{"value", "volcano"})
+ c.Assert(r, Equals, true)
+}
+
+func (s *PatternSuite) TestGlobMatch_tailingAsterisks_exactMatch(c *C) {
+ p := ParsePattern("/*lue/vol?ano/**", nil)
+ r := p.Match([]string{"value", "volcano"})
+ c.Assert(r, Equals, false)
+}
+
+func (s *PatternSuite) TestGlobMatch_middleAsterisks_emptyMatch(c *C) {
+ p := ParsePattern("/*lue/**/vol?ano", nil)
+ r := p.Match([]string{"value", "volcano"})
+ c.Assert(r, Equals, true)
+}
+
+func (s *PatternSuite) TestGlobMatch_middleAsterisks_oneMatch(c *C) {
+ p := ParsePattern("/*lue/**/vol?ano", nil)
+ r := p.Match([]string{"value", "middle", "volcano"})
+ c.Assert(r, Equals, true)
+}
+
+func (s *PatternSuite) TestGlobMatch_middleAsterisks_multiMatch(c *C) {
+ p := ParsePattern("/*lue/**/vol?ano", nil)
+ r := p.Match([]string{"value", "middle1", "middle2", "volcano"})
+ c.Assert(r, Equals, true)
+}
+
+func (s *PatternSuite) TestGlobMatch_wrongDoubleAsterisk_mismatch(c *C) {
+ p := ParsePattern("/*lue/**foo/vol?ano/tail", nil)
+ r := p.Match([]string{"value", "foo", "volcano", "tail"})
+ c.Assert(r, Equals, false)
+}
+
+func (s *PatternSuite) TestGlobMatch_magicChars(c *C) {
+ p := ParsePattern("**/head/v[ou]l[kc]ano", nil)
+ r := p.Match([]string{"value", "head", "volcano"})
+ c.Assert(r, Equals, true)
+}
+
+func (s *PatternSuite) TestGlobMatch_wrongPattern_noTraversal_mismatch(c *C) {
+ p := ParsePattern("**/head/v[ou]l[", nil)
+ r := p.Match([]string{"value", "head", "vol["})
+ c.Assert(r, Equals, false)
+}
+
+func (s *PatternSuite) TestGlobMatch_wrongPattern_onTraversal_mismatch(c *C) {
+ p := ParsePattern("/value/**/v[ou]l[", nil)
+ r := p.Match([]string{"value", "head", "vol["})
+ c.Assert(r, Equals, false)
+}
+
+func (s *PatternSuite) TestGlobMatch_issue_923(c *C) {
+ p := ParsePattern("**/android/**/GeneratedPluginRegistrant.java", nil)
+ r := p.Match([]string{"packages", "flutter_tools", "lib", "src", "android", "gradle.dart"})
+ c.Assert(r, Equals, false)
+}