// Copyright 2021 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package git import ( "context" "fmt" "io" "io/fs" "os" "path/filepath" "runtime" "strings" "testing" "time" "code.gitea.io/gitea/modules/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewCheckAttrStdoutReader(t *testing.T) { t.Run("two_times", func(t *testing.T) { read := newCheckAttrStdoutReader(strings.NewReader( ".gitignore\x00linguist-vendored\x00unspecified\x00"+ ".gitignore\x00linguist-vendored\x00specified", ), 1) // first read attr, err := read() assert.NoError(t, err) assert.Equal(t, map[string]GitAttribute{ "linguist-vendored": GitAttribute("unspecified"), }, attr) // second read attr, err = read() assert.NoError(t, err) assert.Equal(t, map[string]GitAttribute{ "linguist-vendored": GitAttribute("specified"), }, attr) }) t.Run("incomplete", func(t *testing.T) { read := newCheckAttrStdoutReader(strings.NewReader( "filename\x00linguist-vendored", ), 1) _, err := read() assert.Equal(t, io.ErrUnexpectedEOF, err) }) t.Run("three_times", func(t *testing.T) { read := newCheckAttrStdoutReader(strings.NewReader( "shouldbe.vendor\x00linguist-vendored\x00set\x00"+ "shouldbe.vendor\x00linguist-generated\x00unspecified\x00"+ "shouldbe.vendor\x00linguist-language\x00unspecified\x00", ), 1) // first read attr, err := read() assert.NoError(t, err) assert.Equal(t, map[string]GitAttribute{ "linguist-vendored": GitAttribute("set"), }, attr) // second read attr, err = read() assert.NoError(t, err) assert.Equal(t, map[string]GitAttribute{ "linguist-generated": GitAttribute("unspecified"), }, attr) // third read attr, err = read() assert.NoError(t, err) assert.Equal(t, map[string]GitAttribute{ "linguist-language": GitAttribute("unspecified"), }, attr) }) } func TestGitAttributeBareNonBare(t *testing.T) { if !SupportCheckAttrOnBare { t.Skip("git check-attr supported on bare repo starting with git 2.40") } repoPath := filepath.Join(testReposDir, "language_stats_repo") gitRepo, err := openRepositoryWithDefaultContext(repoPath) require.NoError(t, err) defer gitRepo.Close() for _, commitID := range []string{ "8fee858da5796dfb37704761701bb8e800ad9ef3", "341fca5b5ea3de596dc483e54c2db28633cd2f97", } { bareStats, err := gitRepo.GitAttributes(commitID, "i-am-a-python.p", LinguistAttributes...) assert.NoError(t, err) defer test.MockVariableValue(&SupportCheckAttrOnBare, false)() cloneStats, err := gitRepo.GitAttributes(commitID, "i-am-a-python.p", LinguistAttributes...) assert.NoError(t, err) assert.EqualValues(t, cloneStats, bareStats) refStats := cloneStats t.Run("GitAttributeChecker/"+commitID+"/SupportBare", func(t *testing.T) { bareChecker, err := gitRepo.GitAttributeChecker(commitID, LinguistAttributes...) assert.NoError(t, err) defer bareChecker.Close() bareStats, err := bareChecker.CheckPath("i-am-a-python.p") assert.NoError(t, err) assert.EqualValues(t, refStats, bareStats) }) t.Run("GitAttributeChecker/"+commitID+"/NoBareSupport", func(t *testing.T) { defer test.MockVariableValue(&SupportCheckAttrOnBare, false)() cloneChecker, err := gitRepo.GitAttributeChecker(commitID, LinguistAttributes...) assert.NoError(t, err) defer cloneChecker.Close() cloneStats, err := cloneChecker.CheckPath("i-am-a-python.p") assert.NoError(t, err) assert.EqualValues(t, refStats, cloneStats) }) } } func TestGitAttributes(t *testing.T) { repoPath := filepath.Join(testReposDir, "language_stats_repo") gitRepo, err := openRepositoryWithDefaultContext(repoPath) require.NoError(t, err) defer gitRepo.Close() attr, err := gitRepo.GitAttributes("8fee858da5796dfb37704761701bb8e800ad9ef3", "i-am-a-python.p", LinguistAttributes...) assert.NoError(t, err) assert.EqualValues(t, map[string]GitAttribute{ "gitlab-language": "unspecified", "linguist-detectable": "unspecified", "linguist-documentation": "unspecified", "linguist-generated": "unspecified", "linguist-language": "Python", "linguist-vendored": "unspecified", }, attr) attr, err = gitRepo.GitAttributes("341fca5b5ea3de596dc483e54c2db28633cd2f97", "i-am-a-python.p", LinguistAttributes...) assert.NoError(t, err) assert.EqualValues(t, map[string]GitAttribute{ "gitlab-language": "unspecified", "linguist-detectable": "unspecified", "linguist-documentation": "unspecified", "linguist-generated": "unspecified", "linguist-language": "Cobra", "linguist-vendored": "unspecified", }, attr) } func TestGitAttributeFirst(t *testing.T) { repoPath := filepath.Join(testReposDir, "language_stats_repo") gitRepo, err := openRepositoryWithDefaultContext(repoPath) require.NoError(t, err) defer gitRepo.Close() t.Run("first is specified", func(t *testing.T) { language, err := gitRepo.GitAttributeFirst("8fee858da5796dfb37704761701bb8e800ad9ef3", "i-am-a-python.p", "linguist-language", "gitlab-language") assert.NoError(t, err) assert.Equal(t, "Python", language.String()) }) t.Run("second is specified", func(t *testing.T) { language, err := gitRepo.GitAttributeFirst("8fee858da5796dfb37704761701bb8e800ad9ef3", "i-am-a-python.p", "gitlab-language", "linguist-language") assert.NoError(t, err) assert.Equal(t, "Python", language.String()) }) t.Run("none is specified", func(t *testing.T) { language, err := gitRepo.GitAttributeFirst("8fee858da5796dfb37704761701bb8e800ad9ef3", "i-am-a-python.p", "linguist-detectable", "gitlab-language", "non-existing") assert.NoError(t, err) assert.Equal(t, "", language.String()) }) } func TestGitAttributeStruct(t *testing.T) { assert.Equal(t, "", GitAttribute("").String()) assert.Equal(t, "", GitAttribute("unspecified").String()) assert.Equal(t, "python", GitAttribute("python").String()) assert.Equal(t, "text?token=Error", GitAttribute("text?token=Error").String()) assert.Equal(t, "text", GitAttribute("text?token=Error").Prefix()) } func TestGitAttributeCheckerError(t *testing.T) { prepareRepo := func(t *testing.T) *Repository { t.Helper() path := t.TempDir() // we can't use unittest.CopyDir because of an import cycle (git.Init in unittest) require.NoError(t, CopyFS(path, os.DirFS(filepath.Join(testReposDir, "language_stats_repo")))) gitRepo, err := openRepositoryWithDefaultContext(path) require.NoError(t, err) return gitRepo } t.Run("RemoveAll/BeforeRun", func(t *testing.T) { gitRepo := prepareRepo(t) defer gitRepo.Close() assert.NoError(t, os.RemoveAll(gitRepo.Path)) ac, err := gitRepo.GitAttributeChecker("", "linguist-language") require.NoError(t, err) _, err = ac.CheckPath("i-am-a-python.p") assert.Error(t, err) assert.Contains(t, err.Error(), `git check-attr (stderr: ""):`) }) t.Run("RemoveAll/DuringRun", func(t *testing.T) { gitRepo := prepareRepo(t) defer gitRepo.Close() ac, err := gitRepo.GitAttributeChecker("", "linguist-language") require.NoError(t, err) // calling CheckPath before would allow git to cache part of it and successfully return later assert.NoError(t, os.RemoveAll(gitRepo.Path)) _, err = ac.CheckPath("i-am-a-python.p") if err == nil { t.Skip( "git check-attr started too fast and CheckPath was successful (and likely cached)", "https://codeberg.org/forgejo/forgejo/issues/2948", ) } // Depending on the order of execution, the returned error can be: // - a launch error "fork/exec /usr/bin/git: no such file or directory" (when the removal happens before the Run) // - 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) // (pipe error "write |1: broken pipe" should be replaced by one of the Run errors above) assert.Contains(t, err.Error(), `git check-attr`) }) t.Run("Cancelled/BeforeRun", func(t *testing.T) { gitRepo := prepareRepo(t) defer gitRepo.Close() var cancel context.CancelFunc gitRepo.Ctx, cancel = context.WithCancel(gitRepo.Ctx) cancel() ac, err := gitRepo.GitAttributeChecker("8fee858da5796dfb37704761701bb8e800ad9ef3", "linguist-language") require.NoError(t, err) _, err = ac.CheckPath("i-am-a-python.p") assert.ErrorIs(t, err, context.Canceled) }) t.Run("Cancelled/DuringRun", func(t *testing.T) { gitRepo := prepareRepo(t) defer gitRepo.Close() var cancel context.CancelFunc gitRepo.Ctx, cancel = context.WithCancel(gitRepo.Ctx) ac, err := gitRepo.GitAttributeChecker("8fee858da5796dfb37704761701bb8e800ad9ef3", "linguist-language") require.NoError(t, err) attr, err := ac.CheckPath("i-am-a-python.p") assert.NoError(t, err) assert.Equal(t, "Python", attr["linguist-language"].String()) errCh := make(chan error) go func() { cancel() for err == nil { _, err = ac.CheckPath("i-am-a-python.p") runtime.Gosched() // the cancellation must have time to propagate } errCh <- err }() select { case <-time.After(time.Second): t.Error("CheckPath did not complete within 1s") case err = <-errCh: assert.ErrorIs(t, err, context.Canceled) } }) t.Run("Closed/BeforeRun", func(t *testing.T) { gitRepo := prepareRepo(t) defer gitRepo.Close() ac, err := gitRepo.GitAttributeChecker("8fee858da5796dfb37704761701bb8e800ad9ef3", "linguist-language") require.NoError(t, err) assert.NoError(t, ac.Close()) _, err = ac.CheckPath("i-am-a-python.p") assert.ErrorIs(t, err, fs.ErrClosed) }) t.Run("Closed/DuringRun", func(t *testing.T) { gitRepo := prepareRepo(t) defer gitRepo.Close() ac, err := gitRepo.GitAttributeChecker("8fee858da5796dfb37704761701bb8e800ad9ef3", "linguist-language") require.NoError(t, err) attr, err := ac.CheckPath("i-am-a-python.p") assert.NoError(t, err) assert.Equal(t, "Python", attr["linguist-language"].String()) assert.NoError(t, ac.Close()) _, err = ac.CheckPath("i-am-a-python.p") assert.ErrorIs(t, err, fs.ErrClosed) }) } // CopyFS is adapted from https://github.com/golang/go/issues/62484 // which should be available with go1.23 func CopyFS(dir string, fsys fs.FS) error { return fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, _ error) error { targ := filepath.Join(dir, filepath.FromSlash(path)) if d.IsDir() { return os.MkdirAll(targ, 0o777) } r, err := fsys.Open(path) if err != nil { return err } defer r.Close() info, err := r.Stat() if err != nil { return err } w, err := os.OpenFile(targ, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o666|info.Mode()&0o777) if err != nil { return err } if _, err := io.Copy(w, r); err != nil { w.Close() return fmt.Errorf("copying %s: %v", path, err) } return w.Close() }) }