Merge pull request #1130 from saracen/gitattributes

plumbing: format/gitattributes support
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)
+}