fix: respect gitattributes (#342)

Ayman Bagabas created

* fix: respect gitattributes

Check gitattributes for file attrs before displaying files.

Fixes: https://github.com/charmbracelet/soft-serve/issues/238

* chore: add tests

Change summary

git/attr.go                   | 62 +++++++++++++++++++++++++
git/attr_test.go              | 91 +++++++++++++++++++++++++++++++++++++
server/ui/pages/repo/files.go | 38 +++++++++++++--
3 files changed, 186 insertions(+), 5 deletions(-)

Detailed changes

git/attr.go 🔗

@@ -0,0 +1,62 @@
+package git
+
+import (
+	"math/rand"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+)
+
+// Attribute represents a Git attribute.
+type Attribute struct {
+	Name  string
+	Value string
+}
+
+// CheckAttributes checks the attributes of the given ref and path.
+func (r *Repository) CheckAttributes(ref *Reference, path string) ([]Attribute, error) {
+	rnd := rand.NewSource(time.Now().UnixNano())
+	fn := "soft-serve-index-" + strconv.Itoa(rand.New(rnd).Int()) // nolint: gosec
+	tmpindex := filepath.Join(os.TempDir(), fn)
+
+	defer os.Remove(tmpindex) // nolint: errcheck
+
+	readTree := NewCommand("read-tree", "--reset", "-i", ref.Name().String()).
+		AddEnvs("GIT_INDEX_FILE=" + tmpindex)
+	if _, err := readTree.RunInDir(r.Path); err != nil {
+		return nil, err
+	}
+
+	checkAttr := NewCommand("check-attr", "--cached", "-a", "--", path).
+		AddEnvs("GIT_INDEX_FILE=" + tmpindex)
+	out, err := checkAttr.RunInDir(r.Path)
+	if err != nil {
+		return nil, err
+	}
+
+	return parseAttributes(path, out), nil
+}
+
+func parseAttributes(path string, buf []byte) []Attribute {
+	attrs := make([]Attribute, 0)
+	for _, line := range strings.Split(string(buf), "\n") {
+		if line == "" {
+			continue
+		}
+
+		line = strings.TrimPrefix(line, path+": ")
+		parts := strings.SplitN(line, ": ", 2)
+		if len(parts) != 2 {
+			continue
+		}
+
+		attrs = append(attrs, Attribute{
+			Name:  parts[0],
+			Value: parts[1],
+		})
+	}
+
+	return attrs
+}

git/attr_test.go 🔗

@@ -0,0 +1,91 @@
+package git
+
+import (
+	"testing"
+
+	"github.com/matryer/is"
+)
+
+func TestParseAttr(t *testing.T) {
+	cases := []struct {
+		in   string
+		file string
+		want []Attribute
+	}{
+		{
+			in:   "org/example/MyClass.java: diff: java\n",
+			file: "org/example/MyClass.java",
+			want: []Attribute{
+				{
+					Name:  "diff",
+					Value: "java",
+				},
+			},
+		},
+		{
+			in: `org/example/MyClass.java: crlf: unset
+org/example/MyClass.java: diff: java
+org/example/MyClass.java: myAttr: set`,
+			file: "org/example/MyClass.java",
+			want: []Attribute{
+				{
+					Name:  "crlf",
+					Value: "unset",
+				},
+				{
+					Name:  "diff",
+					Value: "java",
+				},
+				{
+					Name:  "myAttr",
+					Value: "set",
+				},
+			},
+		},
+		{
+			in: `org/example/MyClass.java: diff: java
+org/example/MyClass.java: myAttr: set`,
+			file: "org/example/MyClass.java",
+			want: []Attribute{
+				{
+					Name:  "diff",
+					Value: "java",
+				},
+				{
+					Name:  "myAttr",
+					Value: "set",
+				},
+			},
+		},
+		{
+			in:   `README: caveat: unspecified`,
+			file: "README",
+			want: []Attribute{
+				{
+					Name:  "caveat",
+					Value: "unspecified",
+				},
+			},
+		},
+		{
+			in:   "",
+			file: "foo",
+			want: []Attribute{},
+		},
+		{
+			in:   "\n",
+			file: "foo",
+			want: []Attribute{},
+		},
+	}
+
+	is := is.New(t)
+	for _, c := range cases {
+		attrs := parseAttributes(c.file, []byte(c.in))
+		if len(attrs) != len(c.want) {
+			t.Fatalf("parseAttributes(%q, %q) = %v, want %v", c.file, c.in, attrs, c.want)
+		}
+
+		is.Equal(attrs, c.want)
+	}
+}

server/ui/pages/repo/files.go 🔗

@@ -376,26 +376,54 @@ func (f *Files) selectFileCmd() tea.Msg {
 			log.Printf("ui: files: current item is not a file")
 			return common.ErrorMsg(errInvalidFile)
 		}
-		bin, err := fi.IsBinary()
-		if err != nil {
-			f.path = filepath.Dir(f.path)
-			log.Printf("ui: files: error checking if file is binary %v", err)
-			return common.ErrorMsg(err)
+
+		var err error
+		var bin bool
+
+		r, err := f.repo.Open()
+		if err == nil {
+			attrs, err := r.CheckAttributes(f.ref, fi.Path())
+			if err == nil {
+				for _, attr := range attrs {
+					if (attr.Name == "binary" && attr.Value == "set") ||
+						(attr.Name == "text" && attr.Value == "unset") {
+						bin = true
+						break
+					}
+				}
+			} else {
+				log.Printf("ui: files: error checking attributes %v", err)
+			}
+		} else {
+			log.Printf("ui: files: error opening repo %v", err)
+		}
+
+		if !bin {
+			bin, err = fi.IsBinary()
+			if err != nil {
+				f.path = filepath.Dir(f.path)
+				log.Printf("ui: files: error checking if file is binary %v", err)
+				return common.ErrorMsg(err)
+			}
 		}
+
 		if bin {
 			f.path = filepath.Dir(f.path)
 			log.Printf("ui: files: file is binary")
 			return common.ErrorMsg(errBinaryFile)
 		}
+
 		c, err := fi.Bytes()
 		if err != nil {
 			f.path = filepath.Dir(f.path)
 			log.Printf("ui: files: error reading file %v", err)
 			return common.ErrorMsg(err)
 		}
+
 		f.lastSelected = append(f.lastSelected, f.selector.Index())
 		return FileContentMsg{string(c), i.entry.Name()}
 	}
+
 	log.Printf("ui: files: current item is not a file")
 	return common.ErrorMsg(errNoFileSelected)
 }