d0afaa03bb80ea24482f03dd2db055d86e428b66

Author
Ayman Bagabas <ayman.bagabas@gmail.com>
Committer
GitHub <noreply@github.com>
Date

Message

fix: respect gitattributes (#342)

* fix: respect gitattributes

Check gitattributes for file attrs before displaying files.

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

* chore: add tests

Diff

  1diff --git a/git/attr.go b/git/attr.go
  2new file mode 100644
  3index 0000000000000000000000000000000000000000..1a086a0340dc78c124d405f79f5edff95ac9a300
  4--- /dev/null
  5+++ b/git/attr.go
  6@@ -0,0 +1,62 @@
  7+package git
  8+
  9+import (
 10+	"math/rand"
 11+	"os"
 12+	"path/filepath"
 13+	"strconv"
 14+	"strings"
 15+	"time"
 16+)
 17+
 18+// Attribute represents a Git attribute.
 19+type Attribute struct {
 20+	Name  string
 21+	Value string
 22+}
 23+
 24+// CheckAttributes checks the attributes of the given ref and path.
 25+func (r *Repository) CheckAttributes(ref *Reference, path string) ([]Attribute, error) {
 26+	rnd := rand.NewSource(time.Now().UnixNano())
 27+	fn := "soft-serve-index-" + strconv.Itoa(rand.New(rnd).Int()) // nolint: gosec
 28+	tmpindex := filepath.Join(os.TempDir(), fn)
 29+
 30+	defer os.Remove(tmpindex) // nolint: errcheck
 31+
 32+	readTree := NewCommand("read-tree", "--reset", "-i", ref.Name().String()).
 33+		AddEnvs("GIT_INDEX_FILE=" + tmpindex)
 34+	if _, err := readTree.RunInDir(r.Path); err != nil {
 35+		return nil, err
 36+	}
 37+
 38+	checkAttr := NewCommand("check-attr", "--cached", "-a", "--", path).
 39+		AddEnvs("GIT_INDEX_FILE=" + tmpindex)
 40+	out, err := checkAttr.RunInDir(r.Path)
 41+	if err != nil {
 42+		return nil, err
 43+	}
 44+
 45+	return parseAttributes(path, out), nil
 46+}
 47+
 48+func parseAttributes(path string, buf []byte) []Attribute {
 49+	attrs := make([]Attribute, 0)
 50+	for _, line := range strings.Split(string(buf), "\n") {
 51+		if line == "" {
 52+			continue
 53+		}
 54+
 55+		line = strings.TrimPrefix(line, path+": ")
 56+		parts := strings.SplitN(line, ": ", 2)
 57+		if len(parts) != 2 {
 58+			continue
 59+		}
 60+
 61+		attrs = append(attrs, Attribute{
 62+			Name:  parts[0],
 63+			Value: parts[1],
 64+		})
 65+	}
 66+
 67+	return attrs
 68+}
 69diff --git a/git/attr_test.go b/git/attr_test.go
 70new file mode 100644
 71index 0000000000000000000000000000000000000000..bda79cb369463ee04148b0cf5225a6439fd85d45
 72--- /dev/null
 73+++ b/git/attr_test.go
 74@@ -0,0 +1,91 @@
 75+package git
 76+
 77+import (
 78+	"testing"
 79+
 80+	"github.com/matryer/is"
 81+)
 82+
 83+func TestParseAttr(t *testing.T) {
 84+	cases := []struct {
 85+		in   string
 86+		file string
 87+		want []Attribute
 88+	}{
 89+		{
 90+			in:   "org/example/MyClass.java: diff: java\n",
 91+			file: "org/example/MyClass.java",
 92+			want: []Attribute{
 93+				{
 94+					Name:  "diff",
 95+					Value: "java",
 96+				},
 97+			},
 98+		},
 99+		{
100+			in: `org/example/MyClass.java: crlf: unset
101+org/example/MyClass.java: diff: java
102+org/example/MyClass.java: myAttr: set`,
103+			file: "org/example/MyClass.java",
104+			want: []Attribute{
105+				{
106+					Name:  "crlf",
107+					Value: "unset",
108+				},
109+				{
110+					Name:  "diff",
111+					Value: "java",
112+				},
113+				{
114+					Name:  "myAttr",
115+					Value: "set",
116+				},
117+			},
118+		},
119+		{
120+			in: `org/example/MyClass.java: diff: java
121+org/example/MyClass.java: myAttr: set`,
122+			file: "org/example/MyClass.java",
123+			want: []Attribute{
124+				{
125+					Name:  "diff",
126+					Value: "java",
127+				},
128+				{
129+					Name:  "myAttr",
130+					Value: "set",
131+				},
132+			},
133+		},
134+		{
135+			in:   `README: caveat: unspecified`,
136+			file: "README",
137+			want: []Attribute{
138+				{
139+					Name:  "caveat",
140+					Value: "unspecified",
141+				},
142+			},
143+		},
144+		{
145+			in:   "",
146+			file: "foo",
147+			want: []Attribute{},
148+		},
149+		{
150+			in:   "\n",
151+			file: "foo",
152+			want: []Attribute{},
153+		},
154+	}
155+
156+	is := is.New(t)
157+	for _, c := range cases {
158+		attrs := parseAttributes(c.file, []byte(c.in))
159+		if len(attrs) != len(c.want) {
160+			t.Fatalf("parseAttributes(%q, %q) = %v, want %v", c.file, c.in, attrs, c.want)
161+		}
162+
163+		is.Equal(attrs, c.want)
164+	}
165+}
166diff --git a/server/ui/pages/repo/files.go b/server/ui/pages/repo/files.go
167index bded6aa9632e5ef5d98ab1ef74fb49359fc776d3..a0a79352bf4fee2b564be5cb1d6b10d810c6489e 100644
168--- a/server/ui/pages/repo/files.go
169+++ b/server/ui/pages/repo/files.go
170@@ -376,26 +376,54 @@ func (f *Files) selectFileCmd() tea.Msg {
171 			log.Printf("ui: files: current item is not a file")
172 			return common.ErrorMsg(errInvalidFile)
173 		}
174-		bin, err := fi.IsBinary()
175-		if err != nil {
176-			f.path = filepath.Dir(f.path)
177-			log.Printf("ui: files: error checking if file is binary %v", err)
178-			return common.ErrorMsg(err)
179+
180+		var err error
181+		var bin bool
182+
183+		r, err := f.repo.Open()
184+		if err == nil {
185+			attrs, err := r.CheckAttributes(f.ref, fi.Path())
186+			if err == nil {
187+				for _, attr := range attrs {
188+					if (attr.Name == "binary" && attr.Value == "set") ||
189+						(attr.Name == "text" && attr.Value == "unset") {
190+						bin = true
191+						break
192+					}
193+				}
194+			} else {
195+				log.Printf("ui: files: error checking attributes %v", err)
196+			}
197+		} else {
198+			log.Printf("ui: files: error opening repo %v", err)
199+		}
200+
201+		if !bin {
202+			bin, err = fi.IsBinary()
203+			if err != nil {
204+				f.path = filepath.Dir(f.path)
205+				log.Printf("ui: files: error checking if file is binary %v", err)
206+				return common.ErrorMsg(err)
207+			}
208 		}
209+
210 		if bin {
211 			f.path = filepath.Dir(f.path)
212 			log.Printf("ui: files: file is binary")
213 			return common.ErrorMsg(errBinaryFile)
214 		}
215+
216 		c, err := fi.Bytes()
217 		if err != nil {
218 			f.path = filepath.Dir(f.path)
219 			log.Printf("ui: files: error reading file %v", err)
220 			return common.ErrorMsg(err)
221 		}
222+
223 		f.lastSelected = append(f.lastSelected, f.selector.Index())
224 		return FileContentMsg{string(c), i.entry.Name()}
225 	}
226+
227 	log.Printf("ui: files: current item is not a file")
228 	return common.ErrorMsg(errNoFileSelected)
229 }