loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request '[REFACTOR] git attribute: test proper cancellation and unify nul-byte reader' (#2906) from oliverpool/forgejo:git_attr_error into forgejo

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/2906
Reviewed-by: Gusted <gusted@noreply.codeberg.org>

+354 -245
+124 -151
modules/git/repo_attribute.go
··· 4 4 package git 5 5 6 6 import ( 7 + "bufio" 7 8 "bytes" 8 9 "context" 9 10 "fmt" 11 + "io" 10 12 "os" 11 13 "strings" 14 + "sync/atomic" 12 15 13 - "code.gitea.io/gitea/modules/log" 14 16 "code.gitea.io/gitea/modules/optional" 15 17 ) 16 18 17 19 var LinguistAttributes = []string{"linguist-vendored", "linguist-generated", "linguist-language", "gitlab-language", "linguist-documentation", "linguist-detectable"} 18 20 21 + // newCheckAttrStdoutReader parses the nul-byte separated output of git check-attr on each call of 22 + // the returned function. The first reading error will stop the reading and be returned on all 23 + // subsequent calls. 24 + func newCheckAttrStdoutReader(r io.Reader, count int) func() (map[string]GitAttribute, error) { 25 + scanner := bufio.NewScanner(r) 26 + 27 + // adapted from bufio.ScanLines to split on nul-byte \x00 28 + scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) { 29 + if atEOF && len(data) == 0 { 30 + return 0, nil, nil 31 + } 32 + if i := bytes.IndexByte(data, '\x00'); i >= 0 { 33 + // We have a full nul-terminated line. 34 + return i + 1, data[0:i], nil 35 + } 36 + // If we're at EOF, we have a final, non-terminated line. Return it. 37 + if atEOF { 38 + return len(data), data, nil 39 + } 40 + // Request more data. 41 + return 0, nil, nil 42 + }) 43 + 44 + var err error 45 + nextText := func() string { 46 + if err != nil { 47 + return "" 48 + } 49 + if !scanner.Scan() { 50 + err = scanner.Err() 51 + if err == nil { 52 + err = io.ErrUnexpectedEOF 53 + } 54 + return "" 55 + } 56 + return scanner.Text() 57 + } 58 + nextAttribute := func() (string, GitAttribute, error) { 59 + nextText() // discard filename 60 + key := nextText() 61 + value := GitAttribute(nextText()) 62 + return key, value, err 63 + } 64 + return func() (map[string]GitAttribute, error) { 65 + values := make(map[string]GitAttribute, count) 66 + for range count { 67 + k, v, err := nextAttribute() 68 + if err != nil { 69 + return values, err 70 + } 71 + values[k] = v 72 + } 73 + return values, scanner.Err() 74 + } 75 + } 76 + 19 77 // GitAttribute exposes an attribute from the .gitattribute file 20 78 type GitAttribute string //nolint:revive 21 79 ··· 54 112 return optional.None[bool]() 55 113 } 56 114 57 - // GitAttributeFirst returns the first specified attribute 58 - // 59 - // If treeish is empty, the gitattribute will be read from the current repo (which MUST be a working directory and NOT bare). 60 - func (repo *Repository) GitAttributeFirst(treeish, filename string, attributes ...string) (GitAttribute, error) { 61 - values, err := repo.GitAttributes(treeish, filename, attributes...) 62 - if err != nil { 63 - return "", err 64 - } 65 - for _, a := range attributes { 66 - if values[a].IsSpecified() { 67 - return values[a], nil 68 - } 69 - } 70 - return "", nil 71 - } 72 - 115 + // gitCheckAttrCommand prepares the "git check-attr" command for later use as one-shot or streaming 116 + // instanciation. 73 117 func (repo *Repository) gitCheckAttrCommand(treeish string, attributes ...string) (*Command, *RunOpts, context.CancelFunc, error) { 74 118 if len(attributes) == 0 { 75 119 return nil, nil, nil, fmt.Errorf("no provided attributes to check-attr") 76 120 } 77 121 78 122 env := os.Environ() 79 - var deleteTemporaryFile context.CancelFunc 123 + var removeTempFiles context.CancelFunc = func() {} 80 124 81 125 // git < 2.40 cannot run check-attr on bare repo, but needs INDEX + WORK_TREE 82 126 hasIndex := treeish == "" ··· 85 129 if err != nil { 86 130 return nil, nil, nil, err 87 131 } 88 - deleteTemporaryFile = cancel 132 + removeTempFiles = cancel 89 133 90 134 env = append(env, "GIT_INDEX_FILE="+indexFilename, "GIT_WORK_TREE="+worktree) 91 135 ··· 94 138 // clear treeish to read from provided index/work_tree 95 139 treeish = "" 96 140 } 97 - ctx, cancel := context.WithCancel(repo.Ctx) 98 - if deleteTemporaryFile != nil { 99 - ctxCancel := cancel 100 - cancel = func() { 101 - ctxCancel() 102 - deleteTemporaryFile() 103 - } 104 - } 105 141 106 - cmd := NewCommand(ctx, "check-attr", "-z") 142 + cmd := NewCommand(repo.Ctx, "check-attr", "-z") 107 143 108 144 if hasIndex { 109 145 cmd.AddArguments("--cached") ··· 126 162 return cmd, &RunOpts{ 127 163 Env: env, 128 164 Dir: repo.Path, 129 - }, cancel, nil 165 + }, removeTempFiles, nil 166 + } 167 + 168 + // GitAttributeFirst returns the first specified attribute of the given filename. 169 + // 170 + // If treeish is empty, the gitattribute will be read from the current repo (which MUST be a working directory and NOT bare). 171 + func (repo *Repository) GitAttributeFirst(treeish, filename string, attributes ...string) (GitAttribute, error) { 172 + values, err := repo.GitAttributes(treeish, filename, attributes...) 173 + if err != nil { 174 + return "", err 175 + } 176 + for _, a := range attributes { 177 + if values[a].IsSpecified() { 178 + return values[a], nil 179 + } 180 + } 181 + return "", nil 130 182 } 131 183 132 - // GitAttributes returns gitattribute. 184 + // GitAttributes returns the gitattribute of the given filename. 133 185 // 134 186 // If treeish is empty, the gitattribute will be read from the current repo (which MUST be a working directory and NOT bare). 135 187 func (repo *Repository) GitAttributes(treeish, filename string, attributes ...string) (map[string]GitAttribute, error) { 136 - cmd, runOpts, cancel, err := repo.gitCheckAttrCommand(treeish, attributes...) 188 + cmd, runOpts, removeTempFiles, err := repo.gitCheckAttrCommand(treeish, attributes...) 137 189 if err != nil { 138 190 return nil, err 139 191 } 140 - defer cancel() 192 + defer removeTempFiles() 141 193 142 194 stdOut := new(bytes.Buffer) 143 195 runOpts.Stdout = stdOut ··· 151 203 return nil, fmt.Errorf("failed to run check-attr: %w\n%s\n%s", err, stdOut.String(), stdErr.String()) 152 204 } 153 205 154 - // FIXME: This is incorrect on versions < 1.8.5 155 - fields := bytes.Split(stdOut.Bytes(), []byte{'\000'}) 156 - 157 - if len(fields)%3 != 1 { 158 - return nil, fmt.Errorf("wrong number of fields in return from check-attr") 159 - } 160 - 161 - values := make(map[string]GitAttribute, len(attributes)) 162 - for ; len(fields) >= 3; fields = fields[3:] { 163 - // filename := string(fields[0]) 164 - attribute := string(fields[1]) 165 - value := string(fields[2]) 166 - values[attribute] = GitAttribute(value) 167 - } 168 - return values, nil 206 + return newCheckAttrStdoutReader(stdOut, len(attributes))() 169 207 } 170 208 171 - type attributeTriple struct { 172 - Filename string 173 - Attribute string 174 - Value string 175 - } 176 - 177 - type nulSeparatedAttributeWriter struct { 178 - tmp []byte 179 - attributes chan attributeTriple 180 - closed chan struct{} 181 - working attributeTriple 182 - pos int 183 - } 184 - 185 - func (wr *nulSeparatedAttributeWriter) Write(p []byte) (n int, err error) { 186 - l, read := len(p), 0 187 - 188 - nulIdx := bytes.IndexByte(p, '\x00') 189 - for nulIdx >= 0 { 190 - wr.tmp = append(wr.tmp, p[:nulIdx]...) 191 - switch wr.pos { 192 - case 0: 193 - wr.working = attributeTriple{ 194 - Filename: string(wr.tmp), 195 - } 196 - case 1: 197 - wr.working.Attribute = string(wr.tmp) 198 - case 2: 199 - wr.working.Value = string(wr.tmp) 200 - } 201 - wr.tmp = wr.tmp[:0] 202 - wr.pos++ 203 - if wr.pos > 2 { 204 - wr.attributes <- wr.working 205 - wr.pos = 0 206 - } 207 - read += nulIdx + 1 208 - if l > read { 209 - p = p[nulIdx+1:] 210 - nulIdx = bytes.IndexByte(p, '\x00') 211 - } else { 212 - return l, nil 213 - } 214 - } 215 - wr.tmp = append(wr.tmp, p...) 216 - return len(p), nil 217 - } 218 - 219 - func (wr *nulSeparatedAttributeWriter) Close() error { 220 - select { 221 - case <-wr.closed: 222 - return nil 223 - default: 224 - } 225 - close(wr.attributes) 226 - close(wr.closed) 227 - return nil 228 - } 229 - 230 - // GitAttributeChecker creates an AttributeChecker for the given repository and provided commit ID. 209 + // GitAttributeChecker creates an AttributeChecker for the given repository and provided commit ID 210 + // to retrieve the attributes of multiple files. The AttributeChecker must be closed after use. 231 211 // 232 212 // If treeish is empty, the gitattribute will be read from the current repo (which MUST be a working directory and NOT bare). 233 213 func (repo *Repository) GitAttributeChecker(treeish string, attributes ...string) (AttributeChecker, error) { 234 - cmd, runOpts, cancel, err := repo.gitCheckAttrCommand(treeish, attributes...) 214 + cmd, runOpts, removeTempFiles, err := repo.gitCheckAttrCommand(treeish, attributes...) 235 215 if err != nil { 236 216 return AttributeChecker{}, err 237 217 } 238 218 239 - ac := AttributeChecker{ 240 - attributeNumber: len(attributes), 241 - ctx: cmd.parentContext, 242 - cancel: cancel, // will be cancelled on Close 243 - } 219 + cmd.AddArguments("--stdin") 244 220 245 - stdinReader, stdinWriter, err := os.Pipe() 221 + // os.Pipe is needed (and not io.Pipe), otherwise cmd.Wait will wait for the stdinReader 222 + // to be closed before returning (which would require another goroutine) 223 + // https://go.dev/issue/23019 224 + stdinReader, stdinWriter, err := os.Pipe() // reader closed in goroutine / writer closed on ac.Close 246 225 if err != nil { 247 - ac.cancel() 248 226 return AttributeChecker{}, err 249 227 } 250 - ac.stdinWriter = stdinWriter // will be closed on Close 228 + stdoutReader, stdoutWriter := io.Pipe() // closed in goroutine 251 229 252 - lw := new(nulSeparatedAttributeWriter) 253 - lw.attributes = make(chan attributeTriple, len(attributes)) 254 - lw.closed = make(chan struct{}) 255 - ac.attributesCh = lw.attributes 230 + ac := AttributeChecker{ 231 + removeTempFiles: removeTempFiles, // called on ac.Close 232 + stdinWriter: stdinWriter, 233 + readStdout: newCheckAttrStdoutReader(stdoutReader, len(attributes)), 234 + err: &atomic.Value{}, 235 + } 256 236 257 - cmd.AddArguments("--stdin") 258 237 go func() { 259 238 defer stdinReader.Close() 260 - defer lw.Close() 239 + defer stdoutWriter.Close() // in case of a panic (no-op if already closed by CloseWithError at the end) 261 240 262 241 stdErr := new(bytes.Buffer) 263 242 runOpts.Stdin = stdinReader 264 - runOpts.Stdout = lw 243 + runOpts.Stdout = stdoutWriter 265 244 runOpts.Stderr = stdErr 245 + 266 246 err := cmd.Run(runOpts) 267 247 268 - if err != nil && // If there is an error we need to return but: 269 - cmd.parentContext.Err() != err && // 1. Ignore the context error if the context is cancelled or exceeds the deadline (RunWithContext could return c.ctx.Err() which is Canceled or DeadlineExceeded) 270 - err.Error() != "signal: killed" { // 2. We should not pass up errors due to the program being killed 271 - log.Error("failed to run attr-check. Error: %v\nStderr: %s", err, stdErr.String()) 248 + // if the context was cancelled, Run error is irrelevant 249 + if e := cmd.parentContext.Err(); e != nil { 250 + err = e 251 + } 252 + 253 + if err != nil { // decorate the returned error 254 + err = fmt.Errorf("git check-attr (stderr: %q): %w", strings.TrimSpace(stdErr.String()), err) 255 + ac.err.Store(err) 272 256 } 257 + stdoutWriter.CloseWithError(err) 273 258 }() 274 259 275 260 return ac, nil 276 261 } 277 262 278 263 type AttributeChecker struct { 279 - ctx context.Context 280 - cancel context.CancelFunc 281 - stdinWriter *os.File 282 - attributeNumber int 283 - attributesCh <-chan attributeTriple 264 + removeTempFiles context.CancelFunc 265 + stdinWriter io.WriteCloser 266 + readStdout func() (map[string]GitAttribute, error) 267 + err *atomic.Value 284 268 } 285 269 286 270 func (ac AttributeChecker) CheckPath(path string) (map[string]GitAttribute, error) { 287 - if err := ac.ctx.Err(); err != nil { 288 - return nil, err 289 - } 290 - 291 271 if _, err := ac.stdinWriter.Write([]byte(path + "\x00")); err != nil { 292 - return nil, err 293 - } 294 - 295 - rs := make(map[string]GitAttribute) 296 - for i := 0; i < ac.attributeNumber; i++ { 297 - select { 298 - case attr, ok := <-ac.attributesCh: 299 - if !ok { 300 - return nil, ac.ctx.Err() 301 - } 302 - rs[attr.Attribute] = GitAttribute(attr.Value) 303 - case <-ac.ctx.Done(): 304 - return nil, ac.ctx.Err() 272 + // try to return the Run error if available, since it is likely more helpful 273 + // than just "broken pipe" 274 + if aerr, _ := ac.err.Load().(error); aerr != nil { 275 + return nil, aerr 305 276 } 277 + return nil, fmt.Errorf("git check-attr: %w", err) 306 278 } 307 - return rs, nil 279 + 280 + return ac.readStdout() 308 281 } 309 282 310 283 func (ac AttributeChecker) Close() error { 311 - ac.cancel() 284 + ac.removeTempFiles() 312 285 return ac.stdinWriter.Close() 313 286 }
+230 -94
modules/git/repo_attribute_test.go
··· 4 4 package git 5 5 6 6 import ( 7 + "context" 8 + "fmt" 9 + "io" 10 + "io/fs" 11 + "os" 7 12 "path/filepath" 13 + "runtime" 14 + "strings" 8 15 "testing" 9 16 "time" 10 17 ··· 14 21 "github.com/stretchr/testify/require" 15 22 ) 16 23 17 - func Test_nulSeparatedAttributeWriter_ReadAttribute(t *testing.T) { 18 - wr := &nulSeparatedAttributeWriter{ 19 - attributes: make(chan attributeTriple, 5), 20 - } 24 + func TestNewCheckAttrStdoutReader(t *testing.T) { 25 + t.Run("two_times", func(t *testing.T) { 26 + read := newCheckAttrStdoutReader(strings.NewReader( 27 + ".gitignore\x00linguist-vendored\x00unspecified\x00"+ 28 + ".gitignore\x00linguist-vendored\x00specified", 29 + ), 1) 21 30 22 - testStr := ".gitignore\"\n\x00linguist-vendored\x00unspecified\x00" 31 + // first read 32 + attr, err := read() 33 + assert.NoError(t, err) 34 + assert.Equal(t, map[string]GitAttribute{ 35 + "linguist-vendored": GitAttribute("unspecified"), 36 + }, attr) 23 37 24 - n, err := wr.Write([]byte(testStr)) 38 + // second read 39 + attr, err = read() 40 + assert.NoError(t, err) 41 + assert.Equal(t, map[string]GitAttribute{ 42 + "linguist-vendored": GitAttribute("specified"), 43 + }, attr) 44 + }) 45 + t.Run("incomplete", func(t *testing.T) { 46 + read := newCheckAttrStdoutReader(strings.NewReader( 47 + "filename\x00linguist-vendored", 48 + ), 1) 25 49 26 - assert.Len(t, testStr, n) 27 - assert.NoError(t, err) 28 - select { 29 - case attr := <-wr.attributes: 30 - assert.Equal(t, ".gitignore\"\n", attr.Filename) 31 - assert.Equal(t, "linguist-vendored", attr.Attribute) 32 - assert.Equal(t, "unspecified", attr.Value) 33 - case <-time.After(100 * time.Millisecond): 34 - assert.FailNow(t, "took too long to read an attribute from the list") 35 - } 36 - // Write a second attribute again 37 - n, err = wr.Write([]byte(testStr)) 38 - 39 - assert.Len(t, testStr, n) 40 - assert.NoError(t, err) 41 - 42 - select { 43 - case attr := <-wr.attributes: 44 - assert.Equal(t, ".gitignore\"\n", attr.Filename) 45 - assert.Equal(t, "linguist-vendored", attr.Attribute) 46 - assert.Equal(t, "unspecified", attr.Value) 47 - case <-time.After(100 * time.Millisecond): 48 - assert.FailNow(t, "took too long to read an attribute from the list") 49 - } 50 - 51 - // Write a partial attribute 52 - _, err = wr.Write([]byte("incomplete-file")) 53 - assert.NoError(t, err) 54 - _, err = wr.Write([]byte("name\x00")) 55 - assert.NoError(t, err) 56 - 57 - select { 58 - case <-wr.attributes: 59 - assert.FailNow(t, "There should not be an attribute ready to read") 60 - case <-time.After(100 * time.Millisecond): 61 - } 62 - _, err = wr.Write([]byte("attribute\x00")) 63 - assert.NoError(t, err) 64 - select { 65 - case <-wr.attributes: 66 - assert.FailNow(t, "There should not be an attribute ready to read") 67 - case <-time.After(100 * time.Millisecond): 68 - } 50 + _, err := read() 51 + assert.Equal(t, io.ErrUnexpectedEOF, err) 52 + }) 53 + t.Run("three_times", func(t *testing.T) { 54 + read := newCheckAttrStdoutReader(strings.NewReader( 55 + "shouldbe.vendor\x00linguist-vendored\x00set\x00"+ 56 + "shouldbe.vendor\x00linguist-generated\x00unspecified\x00"+ 57 + "shouldbe.vendor\x00linguist-language\x00unspecified\x00", 58 + ), 1) 69 59 70 - _, err = wr.Write([]byte("value\x00")) 71 - assert.NoError(t, err) 60 + // first read 61 + attr, err := read() 62 + assert.NoError(t, err) 63 + assert.Equal(t, map[string]GitAttribute{ 64 + "linguist-vendored": GitAttribute("set"), 65 + }, attr) 72 66 73 - attr := <-wr.attributes 74 - assert.Equal(t, "incomplete-filename", attr.Filename) 75 - assert.Equal(t, "attribute", attr.Attribute) 76 - assert.Equal(t, "value", attr.Value) 67 + // second read 68 + attr, err = read() 69 + assert.NoError(t, err) 70 + assert.Equal(t, map[string]GitAttribute{ 71 + "linguist-generated": GitAttribute("unspecified"), 72 + }, attr) 77 73 78 - _, err = wr.Write([]byte("shouldbe.vendor\x00linguist-vendored\x00set\x00shouldbe.vendor\x00linguist-generated\x00unspecified\x00shouldbe.vendor\x00linguist-language\x00unspecified\x00")) 79 - assert.NoError(t, err) 80 - attr = <-wr.attributes 81 - assert.NoError(t, err) 82 - assert.EqualValues(t, attributeTriple{ 83 - Filename: "shouldbe.vendor", 84 - Attribute: "linguist-vendored", 85 - Value: "set", 86 - }, attr) 87 - attr = <-wr.attributes 88 - assert.NoError(t, err) 89 - assert.EqualValues(t, attributeTriple{ 90 - Filename: "shouldbe.vendor", 91 - Attribute: "linguist-generated", 92 - Value: "unspecified", 93 - }, attr) 94 - attr = <-wr.attributes 95 - assert.NoError(t, err) 96 - assert.EqualValues(t, attributeTriple{ 97 - Filename: "shouldbe.vendor", 98 - Attribute: "linguist-language", 99 - Value: "unspecified", 100 - }, attr) 74 + // third read 75 + attr, err = read() 76 + assert.NoError(t, err) 77 + assert.Equal(t, map[string]GitAttribute{ 78 + "linguist-language": GitAttribute("unspecified"), 79 + }, attr) 80 + }) 101 81 } 102 82 103 83 func TestGitAttributeBareNonBare(t *testing.T) { ··· 114 94 "8fee858da5796dfb37704761701bb8e800ad9ef3", 115 95 "341fca5b5ea3de596dc483e54c2db28633cd2f97", 116 96 } { 117 - t.Run("GitAttributeChecker/"+commitID, func(t *testing.T) { 97 + bareStats, err := gitRepo.GitAttributes(commitID, "i-am-a-python.p", LinguistAttributes...) 98 + assert.NoError(t, err) 99 + 100 + defer test.MockVariableValue(&SupportCheckAttrOnBare, false)() 101 + cloneStats, err := gitRepo.GitAttributes(commitID, "i-am-a-python.p", LinguistAttributes...) 102 + assert.NoError(t, err) 103 + 104 + assert.EqualValues(t, cloneStats, bareStats) 105 + refStats := cloneStats 106 + 107 + t.Run("GitAttributeChecker/"+commitID+"/SupportBare", func(t *testing.T) { 118 108 bareChecker, err := gitRepo.GitAttributeChecker(commitID, LinguistAttributes...) 119 109 assert.NoError(t, err) 120 - t.Cleanup(func() { bareChecker.Close() }) 110 + defer bareChecker.Close() 121 111 122 112 bareStats, err := bareChecker.CheckPath("i-am-a-python.p") 123 113 assert.NoError(t, err) 124 - 114 + assert.EqualValues(t, refStats, bareStats) 115 + }) 116 + t.Run("GitAttributeChecker/"+commitID+"/NoBareSupport", func(t *testing.T) { 125 117 defer test.MockVariableValue(&SupportCheckAttrOnBare, false)() 126 118 cloneChecker, err := gitRepo.GitAttributeChecker(commitID, LinguistAttributes...) 127 119 assert.NoError(t, err) 128 - t.Cleanup(func() { cloneChecker.Close() }) 129 - cloneStats, err := cloneChecker.CheckPath("i-am-a-python.p") 130 - assert.NoError(t, err) 131 - 132 - assert.EqualValues(t, cloneStats, bareStats) 133 - }) 134 - 135 - t.Run("GitAttributes/"+commitID, func(t *testing.T) { 136 - bareStats, err := gitRepo.GitAttributes(commitID, "i-am-a-python.p", LinguistAttributes...) 137 - assert.NoError(t, err) 120 + defer cloneChecker.Close() 138 121 139 - defer test.MockVariableValue(&SupportCheckAttrOnBare, false)() 140 - cloneStats, err := gitRepo.GitAttributes(commitID, "i-am-a-python.p", LinguistAttributes...) 122 + cloneStats, err := cloneChecker.CheckPath("i-am-a-python.p") 141 123 assert.NoError(t, err) 142 124 143 - assert.EqualValues(t, cloneStats, bareStats) 125 + assert.EqualValues(t, refStats, cloneStats) 144 126 }) 145 127 } 146 128 } ··· 208 190 assert.Equal(t, "text?token=Error", GitAttribute("text?token=Error").String()) 209 191 assert.Equal(t, "text", GitAttribute("text?token=Error").Prefix()) 210 192 } 193 + 194 + func TestGitAttributeCheckerError(t *testing.T) { 195 + prepareRepo := func(t *testing.T) *Repository { 196 + t.Helper() 197 + path := t.TempDir() 198 + 199 + // we can't use unittest.CopyDir because of an import cycle (git.Init in unittest) 200 + require.NoError(t, CopyFS(path, os.DirFS(filepath.Join(testReposDir, "language_stats_repo")))) 201 + 202 + gitRepo, err := openRepositoryWithDefaultContext(path) 203 + require.NoError(t, err) 204 + return gitRepo 205 + } 206 + 207 + t.Run("RemoveAll/BeforeRun", func(t *testing.T) { 208 + gitRepo := prepareRepo(t) 209 + defer gitRepo.Close() 210 + 211 + assert.NoError(t, os.RemoveAll(gitRepo.Path)) 212 + 213 + ac, err := gitRepo.GitAttributeChecker("", "linguist-language") 214 + require.NoError(t, err) 215 + 216 + _, err = ac.CheckPath("i-am-a-python.p") 217 + assert.Error(t, err) 218 + assert.Contains(t, err.Error(), `git check-attr (stderr: ""):`) 219 + }) 220 + 221 + t.Run("RemoveAll/DuringRun", func(t *testing.T) { 222 + gitRepo := prepareRepo(t) 223 + defer gitRepo.Close() 224 + 225 + ac, err := gitRepo.GitAttributeChecker("", "linguist-language") 226 + require.NoError(t, err) 227 + 228 + // calling CheckPath before would allow git to cache part of it and succesfully return later 229 + assert.NoError(t, os.RemoveAll(gitRepo.Path)) 230 + 231 + _, err = ac.CheckPath("i-am-a-python.p") 232 + assert.Error(t, err) 233 + // Depending on the order of execution, the returned error can be: 234 + // - a launch error "fork/exec /usr/bin/git: no such file or directory" (when the removal happens before the Run) 235 + // - a git error (stderr: "fatal: Unable to read current working directory: No such file or directory"): exit status 128 (when the removal happens after the Run) 236 + // (pipe error "write |1: broken pipe" should be replaced by one of the Run errors above) 237 + assert.Contains(t, err.Error(), `git check-attr`) 238 + }) 239 + 240 + t.Run("Cancelled/BeforeRun", func(t *testing.T) { 241 + gitRepo := prepareRepo(t) 242 + defer gitRepo.Close() 243 + 244 + var cancel context.CancelFunc 245 + gitRepo.Ctx, cancel = context.WithCancel(gitRepo.Ctx) 246 + cancel() 247 + 248 + ac, err := gitRepo.GitAttributeChecker("8fee858da5796dfb37704761701bb8e800ad9ef3", "linguist-language") 249 + require.NoError(t, err) 250 + 251 + _, err = ac.CheckPath("i-am-a-python.p") 252 + assert.ErrorIs(t, err, context.Canceled) 253 + }) 254 + 255 + t.Run("Cancelled/DuringRun", func(t *testing.T) { 256 + gitRepo := prepareRepo(t) 257 + defer gitRepo.Close() 258 + 259 + var cancel context.CancelFunc 260 + gitRepo.Ctx, cancel = context.WithCancel(gitRepo.Ctx) 261 + 262 + ac, err := gitRepo.GitAttributeChecker("8fee858da5796dfb37704761701bb8e800ad9ef3", "linguist-language") 263 + require.NoError(t, err) 264 + 265 + attr, err := ac.CheckPath("i-am-a-python.p") 266 + assert.NoError(t, err) 267 + assert.Equal(t, "Python", attr["linguist-language"].String()) 268 + 269 + errCh := make(chan error) 270 + go func() { 271 + cancel() 272 + 273 + for err == nil { 274 + _, err = ac.CheckPath("i-am-a-python.p") 275 + runtime.Gosched() // the cancellation must have time to propagate 276 + } 277 + errCh <- err 278 + }() 279 + 280 + select { 281 + case <-time.After(time.Second): 282 + t.Error("CheckPath did not complete within 1s") 283 + case err = <-errCh: 284 + assert.ErrorIs(t, err, context.Canceled) 285 + } 286 + }) 287 + 288 + t.Run("Closed/BeforeRun", func(t *testing.T) { 289 + gitRepo := prepareRepo(t) 290 + defer gitRepo.Close() 291 + 292 + ac, err := gitRepo.GitAttributeChecker("8fee858da5796dfb37704761701bb8e800ad9ef3", "linguist-language") 293 + require.NoError(t, err) 294 + 295 + assert.NoError(t, ac.Close()) 296 + 297 + _, err = ac.CheckPath("i-am-a-python.p") 298 + assert.ErrorIs(t, err, fs.ErrClosed) 299 + }) 300 + 301 + t.Run("Closed/DuringRun", func(t *testing.T) { 302 + gitRepo := prepareRepo(t) 303 + defer gitRepo.Close() 304 + 305 + ac, err := gitRepo.GitAttributeChecker("8fee858da5796dfb37704761701bb8e800ad9ef3", "linguist-language") 306 + require.NoError(t, err) 307 + 308 + attr, err := ac.CheckPath("i-am-a-python.p") 309 + assert.NoError(t, err) 310 + assert.Equal(t, "Python", attr["linguist-language"].String()) 311 + 312 + assert.NoError(t, ac.Close()) 313 + 314 + _, err = ac.CheckPath("i-am-a-python.p") 315 + assert.ErrorIs(t, err, fs.ErrClosed) 316 + }) 317 + } 318 + 319 + // CopyFS is adapted from https://github.com/golang/go/issues/62484 320 + // which should be available with go1.23 321 + func CopyFS(dir string, fsys fs.FS) error { 322 + return fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, _ error) error { 323 + targ := filepath.Join(dir, filepath.FromSlash(path)) 324 + if d.IsDir() { 325 + return os.MkdirAll(targ, 0o777) 326 + } 327 + r, err := fsys.Open(path) 328 + if err != nil { 329 + return err 330 + } 331 + defer r.Close() 332 + info, err := r.Stat() 333 + if err != nil { 334 + return err 335 + } 336 + w, err := os.OpenFile(targ, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o666|info.Mode()&0o777) 337 + if err != nil { 338 + return err 339 + } 340 + if _, err := io.Copy(w, r); err != nil { 341 + w.Close() 342 + return fmt.Errorf("copying %s: %v", path, err) 343 + } 344 + return w.Close() 345 + }) 346 + }