From 9e9e971ab3f87f33d597e9f6348617603a54cba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Wed, 23 Apr 2025 14:59:03 +0200 Subject: [PATCH] Provisioning: unit test and bug fixes go-git repository (#104390) * Add unit test for unimplemented methods * Add unit test for GoGitRepo_Read * Add tests for Delete * Add more tests * Add unit test for GoGitRepo_Push * Add unit test for ReadTree --- .../repository/go-git/repository_mock.go | 84 ++ .../repository/go-git/worktree_mock.go | 261 ++++ .../provisioning/repository/go-git/wrapper.go | 66 +- .../repository/go-git/wrapper_test.go | 1230 ++++++++++++++++- 4 files changed, 1620 insertions(+), 21 deletions(-) create mode 100644 pkg/registry/apis/provisioning/repository/go-git/repository_mock.go create mode 100644 pkg/registry/apis/provisioning/repository/go-git/worktree_mock.go diff --git a/pkg/registry/apis/provisioning/repository/go-git/repository_mock.go b/pkg/registry/apis/provisioning/repository/go-git/repository_mock.go new file mode 100644 index 00000000000..da1f2a005cf --- /dev/null +++ b/pkg/registry/apis/provisioning/repository/go-git/repository_mock.go @@ -0,0 +1,84 @@ +// Code generated by mockery v2.52.4. DO NOT EDIT. + +package gogit + +import ( + context "context" + + git "github.com/go-git/go-git/v5" + mock "github.com/stretchr/testify/mock" +) + +// MockRepository is an autogenerated mock type for the Repository type +type MockRepository struct { + mock.Mock +} + +type MockRepository_Expecter struct { + mock *mock.Mock +} + +func (_m *MockRepository) EXPECT() *MockRepository_Expecter { + return &MockRepository_Expecter{mock: &_m.Mock} +} + +// PushContext provides a mock function with given fields: ctx, o +func (_m *MockRepository) PushContext(ctx context.Context, o *git.PushOptions) error { + ret := _m.Called(ctx, o) + + if len(ret) == 0 { + panic("no return value specified for PushContext") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *git.PushOptions) error); ok { + r0 = rf(ctx, o) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockRepository_PushContext_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PushContext' +type MockRepository_PushContext_Call struct { + *mock.Call +} + +// PushContext is a helper method to define mock.On call +// - ctx context.Context +// - o *git.PushOptions +func (_e *MockRepository_Expecter) PushContext(ctx interface{}, o interface{}) *MockRepository_PushContext_Call { + return &MockRepository_PushContext_Call{Call: _e.mock.On("PushContext", ctx, o)} +} + +func (_c *MockRepository_PushContext_Call) Run(run func(ctx context.Context, o *git.PushOptions)) *MockRepository_PushContext_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*git.PushOptions)) + }) + return _c +} + +func (_c *MockRepository_PushContext_Call) Return(_a0 error) *MockRepository_PushContext_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockRepository_PushContext_Call) RunAndReturn(run func(context.Context, *git.PushOptions) error) *MockRepository_PushContext_Call { + _c.Call.Return(run) + return _c +} + +// NewMockRepository creates a new instance of MockRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockRepository(t interface { + mock.TestingT + Cleanup(func()) +}) *MockRepository { + mock := &MockRepository{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/registry/apis/provisioning/repository/go-git/worktree_mock.go b/pkg/registry/apis/provisioning/repository/go-git/worktree_mock.go new file mode 100644 index 00000000000..0a85a14e12b --- /dev/null +++ b/pkg/registry/apis/provisioning/repository/go-git/worktree_mock.go @@ -0,0 +1,261 @@ +// Code generated by mockery v2.52.4. DO NOT EDIT. + +package gogit + +import ( + billy "github.com/go-git/go-billy/v5" + git "github.com/go-git/go-git/v5" + + mock "github.com/stretchr/testify/mock" + + plumbing "github.com/go-git/go-git/v5/plumbing" +) + +// MockWorktree is an autogenerated mock type for the Worktree type +type MockWorktree struct { + mock.Mock +} + +type MockWorktree_Expecter struct { + mock *mock.Mock +} + +func (_m *MockWorktree) EXPECT() *MockWorktree_Expecter { + return &MockWorktree_Expecter{mock: &_m.Mock} +} + +// Add provides a mock function with given fields: path +func (_m *MockWorktree) Add(path string) (plumbing.Hash, error) { + ret := _m.Called(path) + + if len(ret) == 0 { + panic("no return value specified for Add") + } + + var r0 plumbing.Hash + var r1 error + if rf, ok := ret.Get(0).(func(string) (plumbing.Hash, error)); ok { + return rf(path) + } + if rf, ok := ret.Get(0).(func(string) plumbing.Hash); ok { + r0 = rf(path) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(plumbing.Hash) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(path) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockWorktree_Add_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Add' +type MockWorktree_Add_Call struct { + *mock.Call +} + +// Add is a helper method to define mock.On call +// - path string +func (_e *MockWorktree_Expecter) Add(path interface{}) *MockWorktree_Add_Call { + return &MockWorktree_Add_Call{Call: _e.mock.On("Add", path)} +} + +func (_c *MockWorktree_Add_Call) Run(run func(path string)) *MockWorktree_Add_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockWorktree_Add_Call) Return(_a0 plumbing.Hash, _a1 error) *MockWorktree_Add_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockWorktree_Add_Call) RunAndReturn(run func(string) (plumbing.Hash, error)) *MockWorktree_Add_Call { + _c.Call.Return(run) + return _c +} + +// Commit provides a mock function with given fields: message, opts +func (_m *MockWorktree) Commit(message string, opts *git.CommitOptions) (plumbing.Hash, error) { + ret := _m.Called(message, opts) + + if len(ret) == 0 { + panic("no return value specified for Commit") + } + + var r0 plumbing.Hash + var r1 error + if rf, ok := ret.Get(0).(func(string, *git.CommitOptions) (plumbing.Hash, error)); ok { + return rf(message, opts) + } + if rf, ok := ret.Get(0).(func(string, *git.CommitOptions) plumbing.Hash); ok { + r0 = rf(message, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(plumbing.Hash) + } + } + + if rf, ok := ret.Get(1).(func(string, *git.CommitOptions) error); ok { + r1 = rf(message, opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockWorktree_Commit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Commit' +type MockWorktree_Commit_Call struct { + *mock.Call +} + +// Commit is a helper method to define mock.On call +// - message string +// - opts *git.CommitOptions +func (_e *MockWorktree_Expecter) Commit(message interface{}, opts interface{}) *MockWorktree_Commit_Call { + return &MockWorktree_Commit_Call{Call: _e.mock.On("Commit", message, opts)} +} + +func (_c *MockWorktree_Commit_Call) Run(run func(message string, opts *git.CommitOptions)) *MockWorktree_Commit_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(*git.CommitOptions)) + }) + return _c +} + +func (_c *MockWorktree_Commit_Call) Return(_a0 plumbing.Hash, _a1 error) *MockWorktree_Commit_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockWorktree_Commit_Call) RunAndReturn(run func(string, *git.CommitOptions) (plumbing.Hash, error)) *MockWorktree_Commit_Call { + _c.Call.Return(run) + return _c +} + +// Filesystem provides a mock function with no fields +func (_m *MockWorktree) Filesystem() billy.Filesystem { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Filesystem") + } + + var r0 billy.Filesystem + if rf, ok := ret.Get(0).(func() billy.Filesystem); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(billy.Filesystem) + } + } + + return r0 +} + +// MockWorktree_Filesystem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Filesystem' +type MockWorktree_Filesystem_Call struct { + *mock.Call +} + +// Filesystem is a helper method to define mock.On call +func (_e *MockWorktree_Expecter) Filesystem() *MockWorktree_Filesystem_Call { + return &MockWorktree_Filesystem_Call{Call: _e.mock.On("Filesystem")} +} + +func (_c *MockWorktree_Filesystem_Call) Run(run func()) *MockWorktree_Filesystem_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockWorktree_Filesystem_Call) Return(_a0 billy.Filesystem) *MockWorktree_Filesystem_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockWorktree_Filesystem_Call) RunAndReturn(run func() billy.Filesystem) *MockWorktree_Filesystem_Call { + _c.Call.Return(run) + return _c +} + +// Remove provides a mock function with given fields: path +func (_m *MockWorktree) Remove(path string) (plumbing.Hash, error) { + ret := _m.Called(path) + + if len(ret) == 0 { + panic("no return value specified for Remove") + } + + var r0 plumbing.Hash + var r1 error + if rf, ok := ret.Get(0).(func(string) (plumbing.Hash, error)); ok { + return rf(path) + } + if rf, ok := ret.Get(0).(func(string) plumbing.Hash); ok { + r0 = rf(path) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(plumbing.Hash) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(path) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockWorktree_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove' +type MockWorktree_Remove_Call struct { + *mock.Call +} + +// Remove is a helper method to define mock.On call +// - path string +func (_e *MockWorktree_Expecter) Remove(path interface{}) *MockWorktree_Remove_Call { + return &MockWorktree_Remove_Call{Call: _e.mock.On("Remove", path)} +} + +func (_c *MockWorktree_Remove_Call) Run(run func(path string)) *MockWorktree_Remove_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockWorktree_Remove_Call) Return(_a0 plumbing.Hash, _a1 error) *MockWorktree_Remove_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockWorktree_Remove_Call) RunAndReturn(run func(string) (plumbing.Hash, error)) *MockWorktree_Remove_Call { + _c.Call.Return(run) + return _c +} + +// NewMockWorktree creates a new instance of MockWorktree. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockWorktree(t interface { + mock.TestingT + Cleanup(func()) +}) *MockWorktree { + mock := &MockWorktree{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/registry/apis/provisioning/repository/go-git/wrapper.go b/pkg/registry/apis/provisioning/repository/go-git/wrapper.go index 7a722cee2ce..a6f9ecfd655 100644 --- a/pkg/registry/apis/provisioning/repository/go-git/wrapper.go +++ b/pkg/registry/apis/provisioning/repository/go-git/wrapper.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/go-git/go-billy/v5" "github.com/go-git/go-billy/v5/util" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" @@ -43,6 +44,27 @@ func init() { client.InstallProtocol("http", httpClient) } +//go:generate mockery --name=Worktree --output=mocks --inpackage --filename=worktree_mock.go --with-expecter +type Worktree interface { + Commit(message string, opts *git.CommitOptions) (plumbing.Hash, error) + Remove(path string) (plumbing.Hash, error) + Add(path string) (plumbing.Hash, error) + Filesystem() billy.Filesystem +} + +type worktree struct { + *git.Worktree +} + +//go:generate mockery --name=Repository --output=mocks --inpackage --filename=repository_mock.go --with-expecter +type Repository interface { + PushContext(ctx context.Context, o *git.PushOptions) error +} + +func (w *worktree) Filesystem() billy.Filesystem { + return w.Worktree.Filesystem +} + var _ repository.Repository = (*GoGitRepo)(nil) type GoGitRepo struct { @@ -50,8 +72,8 @@ type GoGitRepo struct { decryptedPassword string opts repository.CloneOptions - repo *git.Repository - tree *git.Worktree + repo Repository + tree Worktree dir string // file path to worktree root (necessary? should use billy) } @@ -101,7 +123,7 @@ func Clone( progress = io.Discard } - repo, worktree, err := clone(ctx, config, opts, decrypted, dir, progress) + repo, tree, err := clone(ctx, config, opts, decrypted, dir, progress) if err != nil { if err := os.RemoveAll(dir); err != nil { return nil, fmt.Errorf("remove temp clone dir after clone failed: %w", err) @@ -112,7 +134,7 @@ func Clone( return &GoGitRepo{ config: config, - tree: worktree, + tree: &worktree{Worktree: tree}, opts: opts, decryptedPassword: string(decrypted), repo: repo, @@ -254,13 +276,13 @@ func (g *GoGitRepo) ReadTree(ctx context.Context, ref string) ([]repository.File treePath = safepath.Clean(treePath) entries := make([]repository.FileTreeEntry, 0, 100) - err := util.Walk(g.tree.Filesystem, treePath, func(path string, info fs.FileInfo, err error) error { + err := util.Walk(g.tree.Filesystem(), treePath, func(path string, info fs.FileInfo, err error) error { // We already have an error, just pass it onwards. if err != nil || // This is the root of the repository (or should pretend to be) safepath.Clean(path) == "" || path == treePath || // This is the Git data - (treePath == "" && strings.HasPrefix(path, ".git/")) { + (treePath == "" && (strings.HasPrefix(path, ".git/") || path == ".git")) { return err } if treePath != "" { @@ -280,9 +302,9 @@ func (g *GoGitRepo) ReadTree(ctx context.Context, ref string) ([]repository.File return err }) if errors.Is(err, fs.ErrNotExist) { - // We intentionally ignore this case, as + // We intentionally ignore this case, as it is expected } else if err != nil { - return nil, fmt.Errorf("failed to walk tree for ref '%s': %w", ref, err) + return nil, fmt.Errorf("walk tree for ref '%s': %w", ref, err) } return entries, nil } @@ -300,30 +322,33 @@ func (g *GoGitRepo) Update(ctx context.Context, path string, ref string, data [] // Create implements repository.Repository. func (g *GoGitRepo) Create(ctx context.Context, path string, ref string, data []byte, message string) error { + // FIXME: this means we would override files return g.Write(ctx, path, ref, data, message) } // Write implements repository.Repository. func (g *GoGitRepo) Write(ctx context.Context, fpath string, ref string, data []byte, message string) error { - fpath = safepath.Join(g.config.Spec.GitHub.Path, fpath) if err := verifyPathWithoutRef(fpath, ref); err != nil { return err } + fpath = safepath.Join(g.config.Spec.GitHub.Path, fpath) + // FIXME: this means that won't export empty folders + // should we create them with a .keep file? // For folders, just create the folder and ignore the commit if safepath.IsDir(fpath) { - return g.tree.Filesystem.MkdirAll(fpath, 0750) + return g.tree.Filesystem().MkdirAll(fpath, 0750) } dir := safepath.Dir(fpath) if dir != "" { - err := g.tree.Filesystem.MkdirAll(dir, 0750) + err := g.tree.Filesystem().MkdirAll(dir, 0750) if err != nil { return err } } - file, err := g.tree.Filesystem.Create(fpath) + file, err := g.tree.Filesystem().Create(fpath) if err != nil { return err } @@ -363,11 +388,16 @@ func (g *GoGitRepo) maybeCommit(ctx context.Context, message string) error { // Delete implements repository.Repository. func (g *GoGitRepo) Delete(ctx context.Context, fpath string, ref string, message string) error { - fpath = safepath.Join(g.config.Spec.GitHub.Path, fpath) if err := verifyPathWithoutRef(fpath, ref); err != nil { return err } + + fpath = safepath.Join(g.config.Spec.GitHub.Path, fpath) if _, err := g.tree.Remove(fpath); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return repository.ErrFileNotFound + } + return err } return g.maybeCommit(ctx, message) @@ -379,11 +409,11 @@ func (g *GoGitRepo) Read(ctx context.Context, path string, ref string) (*reposit return nil, err } readPath := safepath.Join(g.config.Spec.GitHub.Path, path) - stat, err := g.tree.Filesystem.Lstat(readPath) + stat, err := g.tree.Filesystem().Lstat(readPath) if errors.Is(err, fs.ErrNotExist) { return nil, repository.ErrFileNotFound } else if err != nil { - return nil, fmt.Errorf("failed to stat path '%s': %w", readPath, err) + return nil, fmt.Errorf("stat path '%s': %w", readPath, err) } info := &repository.FileInfo{ Path: path, @@ -392,13 +422,13 @@ func (g *GoGitRepo) Read(ctx context.Context, path string, ref string) (*reposit }, } if !stat.IsDir() { - f, err := g.tree.Filesystem.Open(readPath) + f, err := g.tree.Filesystem().Open(readPath) if err != nil { - return nil, err + return nil, fmt.Errorf("open file '%s': %w", readPath, err) } info.Data, err = io.ReadAll(f) if err != nil { - return nil, err + return nil, fmt.Errorf("read file '%s': %w", readPath, err) } } return info, err diff --git a/pkg/registry/apis/provisioning/repository/go-git/wrapper_test.go b/pkg/registry/apis/provisioning/repository/go-git/wrapper_test.go index e4af86ac09b..8accb7f85cf 100644 --- a/pkg/registry/apis/provisioning/repository/go-git/wrapper_test.go +++ b/pkg/registry/apis/provisioning/repository/go-git/wrapper_test.go @@ -1,16 +1,28 @@ package gogit import ( + "bytes" "context" "encoding/json" + "errors" "fmt" + "io" + "io/fs" + "net/http" "os" "path/filepath" + "sort" "testing" "time" + "github.com/go-git/go-billy/v5" + "github.com/go-git/go-billy/v5/memfs" "github.com/go-git/go-git/v5" + plumbing "github.com/go-git/go-git/v5/plumbing" + githttp "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" @@ -99,7 +111,7 @@ func TestReadTree(t *testing.T) { dir := t.TempDir() gitRepo, err := git.PlainInit(dir, false) require.NoError(t, err, "failed to init a new git repository") - worktree, err := gitRepo.Worktree() + tree, err := gitRepo.Worktree() require.NoError(t, err, "failed to get worktree") repo := &GoGitRepo{ @@ -123,8 +135,10 @@ func TestReadTree(t *testing.T) { decryptedPassword: "password", repo: gitRepo, - tree: worktree, - dir: dir, + tree: &worktree{ + Worktree: tree, + }, + dir: dir, } err = os.WriteFile(filepath.Join(dir, "test.txt"), []byte("test"), 0644) @@ -146,3 +160,1213 @@ func TestReadTree(t *testing.T) { require.Len(t, entries, 1, "entries from ReadTree") require.Equal(t, entries[0].Path, "test2.txt", "entry path") } + +func TestGoGitRepo_History(t *testing.T) { + repo := &GoGitRepo{ + config: &v0alpha1.Repository{ + ObjectMeta: v1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "grafana/", + }, + }, + }, + } + + // Test History method + ctx := context.Background() + _, err := repo.History(ctx, "test.txt", "") + require.Error(t, err, "History should return an error as it's not implemented") + require.Contains(t, err.Error(), "history is not yet implemented") +} + +func TestGoGitRepo_Validate(t *testing.T) { + repo := &GoGitRepo{ + config: &v0alpha1.Repository{ + ObjectMeta: v1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "grafana/", + }, + }, + }, + } + + // Test Validate method + errs := repo.Validate() + require.Empty(t, errs, "Validate should return no errors") +} + +func TestGoGitRepo_Webhook(t *testing.T) { + repo := &GoGitRepo{ + config: &v0alpha1.Repository{ + ObjectMeta: v1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "grafana/", + }, + }, + }, + } + + // Test Webhook method + ctx := context.Background() + _, err := repo.Webhook(ctx, nil) + require.Error(t, err, "Webhook should return an error as it's not implemented") + var statusErr *apierrors.StatusError + require.True(t, errors.As(err, &statusErr), "Error should be a StatusError") + require.Equal(t, http.StatusNotImplemented, int(statusErr.ErrStatus.Code)) + require.Contains(t, statusErr.ErrStatus.Message, "history is not yet implemented") +} + +func TestGoGitRepo_Read(t *testing.T) { + // Setup test cases + tests := []struct { + name string + path string + ref string + setupMock func(fs billy.Filesystem) + expectError bool + errorType error + checkResult func(t *testing.T, info *repository.FileInfo) + }{ + { + name: "successfully read file", + path: "test.txt", + ref: "", + setupMock: func(fs billy.Filesystem) { + // Create a test file + f, err := fs.Create("grafana/test.txt") + require.NoError(t, err, "failed to create test file") + _, err = f.Write([]byte("test content")) + require.NoError(t, err, "failed to write test content") + err = f.Close() + require.NoError(t, err, "failed to close test file") + }, + expectError: false, + checkResult: func(t *testing.T, info *repository.FileInfo) { + require.Equal(t, "test.txt", info.Path) + require.Equal(t, "test content", string(info.Data)) + require.NotNil(t, info.Modified) + }, + }, + { + name: "empty path", + path: "", + ref: "", + setupMock: func(fs billy.Filesystem) {}, + expectError: true, + errorType: fmt.Errorf("expected path"), + }, + { + name: "ref not supported", + path: "test.txt", + ref: "main", + setupMock: func(fs billy.Filesystem) {}, + expectError: true, + errorType: fmt.Errorf("ref unsupported"), + }, + { + name: "file not found", + path: "nonexistent.txt", + ref: "", + setupMock: func(fs billy.Filesystem) { + // Don't create the file + }, + expectError: true, + errorType: repository.ErrFileNotFound, + }, + { + name: "read directory", + path: "testdir", + ref: "", + setupMock: func(fs billy.Filesystem) { + // Create a test directory + err := fs.MkdirAll("grafana/testdir", 0755) + require.NoError(t, err, "failed to create test directory") + }, + expectError: false, + checkResult: func(t *testing.T, info *repository.FileInfo) { + require.Equal(t, "testdir", info.Path) + require.Nil(t, info.Data) + require.NotNil(t, info.Modified) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup filesystem and repo + fs := memfs.New() + tt.setupMock(fs) + + // Create a worktree with the filesystem + repo := &GoGitRepo{ + config: &v0alpha1.Repository{ + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "grafana/", + }, + }, + }, + tree: &worktree{ + Worktree: &git.Worktree{ + Filesystem: fs, + }, + }, + } + + // Test Read method + ctx := context.Background() + info, err := repo.Read(ctx, tt.path, tt.ref) + + // Check results + if tt.expectError { + require.Error(t, err) + if tt.errorType != nil { + if errors.Is(tt.errorType, repository.ErrFileNotFound) { + require.ErrorIs(t, err, repository.ErrFileNotFound) + } else { + require.Contains(t, err.Error(), tt.errorType.Error()) + } + } + } else { + require.NoError(t, err) + require.NotNil(t, info) + tt.checkResult(t, info) + } + }) + } +} + +func TestGoGitRepo_Delete(t *testing.T) { + tests := []struct { + name string + path string + ref string + pushOnWrite bool + setupMock func(mockTree *MockWorktree) + expectError bool + errorType error + }{ + { + name: "delete existing file", + path: "testfile.txt", + ref: "", + pushOnWrite: false, + setupMock: func(mockTree *MockWorktree) { + mockTree.On("Remove", "grafana/testfile.txt").Return(plumbing.Hash{}, nil) + }, + expectError: false, + }, + { + name: "delete non-existent file", + path: "nonexistent.txt", + ref: "", + pushOnWrite: false, + setupMock: func(mockTree *MockWorktree) { + mockTree.On("Remove", "grafana/nonexistent.txt").Return(plumbing.Hash{}, fs.ErrNotExist) + }, + expectError: true, + errorType: repository.ErrFileNotFound, + }, + { + name: "delete with other error", + path: "testfile.txt", + ref: "", + pushOnWrite: false, + setupMock: func(mockTree *MockWorktree) { + mockTree.On("Remove", "grafana/testfile.txt").Return(plumbing.Hash{}, fmt.Errorf("some other error")) + }, + expectError: true, + errorType: fmt.Errorf("some other error"), + }, + { + name: "empty path", + path: "", + ref: "", + pushOnWrite: false, + setupMock: func(mockTree *MockWorktree) {}, + expectError: true, + errorType: fmt.Errorf("expected path"), + }, + { + name: "with ref", + path: "testfile.txt", + ref: "main", + pushOnWrite: false, + setupMock: func(mockTree *MockWorktree) { + }, + expectError: true, + errorType: fmt.Errorf("ref unsupported"), + }, + { + name: "delete with push on write enabled", + path: "testfile.txt", + ref: "", + pushOnWrite: true, + setupMock: func(mockTree *MockWorktree) { + mockTree.On("Remove", "grafana/testfile.txt").Return(plumbing.Hash{}, nil) + mockTree.On("Commit", "test delete", mock.MatchedBy(func(opts *git.CommitOptions) bool { + return opts.Author != nil && + opts.Author.Name == "Test User" && + opts.Author.Email == "test@example.com" && + opts.Author.When.After(time.Now().Add(-time.Minute)) && + opts.Author.When.Before(time.Now().Add(time.Minute)) + })).Return(plumbing.Hash{}, nil) + }, + expectError: false, + }, + { + name: "delete with empty commit", + path: "testfile.txt", + ref: "", + pushOnWrite: true, + setupMock: func(mockTree *MockWorktree) { + mockTree.On("Remove", "grafana/testfile.txt").Return(plumbing.Hash{}, nil) + mockTree.On("Commit", "test delete", mock.MatchedBy(func(opts *git.CommitOptions) bool { + return opts.Author != nil && + opts.Author.Name == "Test User" && + opts.Author.Email == "test@example.com" && + opts.Author.When.After(time.Now().Add(-time.Minute)) && + opts.Author.When.Before(time.Now().Add(time.Minute)) + })).Return(plumbing.Hash{}, git.ErrEmptyCommit) + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup filesystem and repo + + mockTree := NewMockWorktree(t) + tt.setupMock(mockTree) + + // Create a worktree with the filesystem + repo := &GoGitRepo{ + config: &v0alpha1.Repository{ + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "grafana/", + }, + }, + }, + tree: mockTree, + opts: repository.CloneOptions{ + PushOnWrites: tt.pushOnWrite, + }, + } + + // Test Delete method + ctx := context.Background() + // Set author signature for the test + ctx = repository.WithAuthorSignature(ctx, repository.CommitSignature{ + Name: "Test User", + Email: "test@example.com", + When: time.Now(), + }) + + err := repo.Delete(ctx, tt.path, tt.ref, "test delete") + + // Check results + if tt.expectError { + require.Error(t, err) + if tt.errorType != nil { + if errors.Is(tt.errorType, repository.ErrFileNotFound) { + require.ErrorIs(t, err, repository.ErrFileNotFound) + } else { + require.Contains(t, err.Error(), tt.errorType.Error()) + } + } + } else { + require.NoError(t, err) + } + + mockTree.AssertExpectations(t) + }) + } +} + +// FIXME: missing coverage for Update / Create because we use Write for both +// when I think it shouldn't be the case as it's inconsistent with the other repository implementations +func TestGoGitRepo_Write(t *testing.T) { + tests := []struct { + name string + path string + ref string + data []byte + pushOnWrite bool + setupMock func(mockTree *MockWorktree) + expectError bool + errorType error + }{ + { + name: "successful write", + path: "test.txt", + ref: "", + data: []byte("test content"), + pushOnWrite: true, + setupMock: func(mockTree *MockWorktree) { + fs := memfs.New() + mockTree.On("Filesystem").Return(fs) + mockTree.On("Add", "grafana/test.txt").Return(plumbing.NewHash("abc123"), nil) + mockTree.On("Commit", "test write", mock.MatchedBy(func(opts *git.CommitOptions) bool { + return opts.Author != nil && + opts.Author.Name == "Test User" && + opts.Author.Email == "test@example.com" && + opts.Author.When.After(time.Now().Add(-time.Minute)) && + opts.Author.When.Before(time.Now().Add(time.Minute)) + })).Return(plumbing.NewHash("def456"), nil) + }, + expectError: false, + }, + { + name: "create folder only", + path: "testdir/", + ref: "", + data: []byte{}, + pushOnWrite: true, + setupMock: func(mockTree *MockWorktree) { + fs := memfs.New() + mockTree.On("Filesystem").Return(fs) + // No Add or Commit calls expected for directory creation + }, + expectError: false, + }, + { + name: "successful write without commit", + path: "test.txt", + ref: "", + data: []byte("test content"), + pushOnWrite: false, + setupMock: func(mockTree *MockWorktree) { + fs := memfs.New() + mockTree.On("Filesystem").Return(fs) + mockTree.On("Add", "grafana/test.txt").Return(plumbing.NewHash("abc123"), nil) + }, + expectError: false, + }, + { + name: "write with directory creation", + path: "dir/test.txt", + ref: "", + data: []byte("test content"), + pushOnWrite: true, + setupMock: func(mockTree *MockWorktree) { + fs := memfs.New() + mockTree.On("Filesystem").Return(fs) + mockTree.On("Add", "grafana/dir/test.txt").Return(plumbing.NewHash("abc123"), nil) + mockTree.On("Commit", "test write", mock.Anything).Return(plumbing.NewHash("def456"), nil) + }, + expectError: false, + }, + { + name: "error on add", + path: "test.txt", + ref: "", + data: []byte("test content"), + pushOnWrite: true, + setupMock: func(mockTree *MockWorktree) { + fs := memfs.New() + mockTree.On("Filesystem").Return(fs) + mockTree.On("Add", "grafana/test.txt").Return(plumbing.NewHash(""), fmt.Errorf("add error")) + }, + expectError: true, + errorType: fmt.Errorf("add error"), + }, + { + name: "error with ref", + path: "test.txt", + ref: "main", + data: []byte("test content"), + pushOnWrite: true, + setupMock: func(mockTree *MockWorktree) { + // No mock setup needed as it should fail before using the mock + }, + expectError: true, + errorType: fmt.Errorf("ref unsupported"), + }, + { + name: "empty path", + path: "", + ref: "", + data: []byte("test content"), + pushOnWrite: true, + setupMock: func(mockTree *MockWorktree) { + // No mock setup needed as it should fail before using the mock + }, + expectError: true, + errorType: fmt.Errorf("expected path"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup filesystem and repo + mockTree := NewMockWorktree(t) + tt.setupMock(mockTree) + + // Create a worktree with the filesystem + repo := &GoGitRepo{ + config: &v0alpha1.Repository{ + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "grafana/", + }, + }, + }, + tree: mockTree, + opts: repository.CloneOptions{ + PushOnWrites: tt.pushOnWrite, + }, + } + + // Test Write method + ctx := context.Background() + // Set author signature for the test + ctx = repository.WithAuthorSignature(ctx, repository.CommitSignature{ + Name: "Test User", + Email: "test@example.com", + When: time.Now(), + }) + + err := repo.Update(ctx, tt.path, tt.ref, tt.data, "test write") + + // Check results + if tt.expectError { + require.Error(t, err) + if tt.errorType != nil { + require.Contains(t, err.Error(), tt.errorType.Error()) + } + } else { + require.NoError(t, err) + } + + mockTree.AssertExpectations(t) + }) + } +} + +func TestGoGitRepo_Test(t *testing.T) { + tests := []struct { + name string + treeInitialized bool + expectedResult bool + }{ + { + name: "tree is initialized", + treeInitialized: true, + expectedResult: true, + }, + { + name: "tree is not initialized", + treeInitialized: false, + expectedResult: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup mock tree + mockTree := NewMockWorktree(t) + + // Create repo with or without initialized tree + repo := &GoGitRepo{ + config: &v0alpha1.Repository{ + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "grafana/", + }, + }, + }, + tree: nil, + } + + if tt.treeInitialized { + repo.tree = mockTree + } + + // Test the Test method + ctx := context.Background() + result, err := repo.Test(ctx) + + // Verify results + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, tt.expectedResult, result.Success) + }) + } +} + +func TestGoGitRepo_Config(t *testing.T) { + // Create a test repository configuration + testConfig := &v0alpha1.Repository{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-repo", + Namespace: "test-namespace", + }, + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "grafana/", + }, + }, + } + + // Create a repository instance with the test configuration + repo := &GoGitRepo{ + config: testConfig, + tree: NewMockWorktree(t), + } + + // Call the Config method + result := repo.Config() + + // Verify the result + require.NotNil(t, result) + require.Equal(t, testConfig, result) + require.Equal(t, "test-repo", result.Name) + require.Equal(t, "test-namespace", result.Namespace) + require.Equal(t, "grafana/", result.Spec.GitHub.Path) +} + +func TestGoGitRepo_Remove(t *testing.T) { + tests := []struct { + name string + setupMock func(t *testing.T) (*GoGitRepo, string) + expectError bool + expectedErrMsg string + }{ + { + name: "successful removal", + setupMock: func(t *testing.T) (*GoGitRepo, string) { + // Create a temporary directory that will be removed + tempDir, err := os.MkdirTemp("", "test-repo-*") + require.NoError(t, err) + + // Create a repository instance + repo := &GoGitRepo{ + dir: tempDir, + config: &v0alpha1.Repository{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-repo", + Namespace: "test-namespace", + }, + }, + } + + return repo, tempDir + }, + expectError: false, + }, + { + name: "directory already removed", + setupMock: func(t *testing.T) (*GoGitRepo, string) { + // Create a temporary directory + tempDir, err := os.MkdirTemp("", "test-repo-*") + require.NoError(t, err) + + // Remove it immediately to simulate it being already gone + err = os.RemoveAll(tempDir) + require.NoError(t, err) + + // Create a repository instance pointing to the removed directory + repo := &GoGitRepo{ + dir: tempDir, + config: &v0alpha1.Repository{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-repo", + Namespace: "test-namespace", + }, + }, + } + + return repo, tempDir + }, + expectError: false, // RemoveAll doesn't error if directory doesn't exist + }, + { + name: "invalid directory path", + setupMock: func(t *testing.T) (*GoGitRepo, string) { + // Create a repository instance with an invalid directory path + // that should cause an error when trying to remove + invalidPath := string([]byte{0}) + + repo := &GoGitRepo{ + dir: invalidPath, + config: &v0alpha1.Repository{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-repo", + Namespace: "test-namespace", + }, + }, + } + + return repo, invalidPath + }, + expectError: true, + expectedErrMsg: "invalid argument", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup the test + repo, _ := tt.setupMock(t) + + // Test the Remove method + ctx := context.Background() + err := repo.Remove(ctx) + + // Verify results + if tt.expectError { + require.Error(t, err) + if tt.expectedErrMsg != "" { + require.Contains(t, err.Error(), tt.expectedErrMsg) + } + } else { + require.NoError(t, err) + // Verify the directory no longer exists + _, statErr := os.Stat(repo.dir) + require.True(t, os.IsNotExist(statErr), "Directory should not exist after removal") + } + }) + } +} + +func TestGoGitRepo_Push(t *testing.T) { + tests := []struct { + name string + setupMock func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) + pushOpts repository.PushOptions + expectError bool + errorType error + }{ + { + name: "successful push", + setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) { + mockRepo := NewMockRepository(t) + mockRepo.On("PushContext", mock.Anything, mock.MatchedBy(func(o *git.PushOptions) bool { + if o.Auth == nil { + return false + } + // Verify we're using basic auth with expected credentials + basicAuth, ok := o.Auth.(*githttp.BasicAuth) + if !ok { + return false + } + return basicAuth.Username == "grafana" && basicAuth.Password == "test-token" + })).Return(nil) + + repo := &GoGitRepo{ + config: &v0alpha1.Repository{ + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "grafana/", + }, + }, + }, + repo: mockRepo, + decryptedPassword: "test-token", + opts: repository.CloneOptions{ + PushOnWrites: true, + }, + } + + return repo, mockRepo, nil + }, + pushOpts: repository.PushOptions{}, + expectError: false, + }, + { + name: "push error", + setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) { + mockRepo := NewMockRepository(t) + mockRepo.On("PushContext", mock.Anything, mock.Anything).Return(fmt.Errorf("network error")) + + repo := &GoGitRepo{ + config: &v0alpha1.Repository{ + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "grafana/", + }, + }, + }, + repo: mockRepo, + decryptedPassword: "test-token", + opts: repository.CloneOptions{ + PushOnWrites: true, + }, + } + + return repo, mockRepo, nil + }, + pushOpts: repository.PushOptions{}, + expectError: true, + errorType: fmt.Errorf("network error"), + }, + { + name: "already up to date", + setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) { + mockRepo := NewMockRepository(t) + mockRepo.On("PushContext", mock.Anything, mock.Anything).Return(git.NoErrAlreadyUpToDate) + + repo := &GoGitRepo{ + config: &v0alpha1.Repository{ + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "grafana/", + }, + }, + }, + repo: mockRepo, + decryptedPassword: "test-token", + opts: repository.CloneOptions{ + PushOnWrites: true, + }, + } + + return repo, mockRepo, nil + }, + pushOpts: repository.PushOptions{}, + expectError: false, + }, + { + name: "push with custom timeout", + setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) { + mockRepo := NewMockRepository(t) + mockRepo.On("PushContext", mock.Anything, mock.Anything).Return(nil) + + repo := &GoGitRepo{ + config: &v0alpha1.Repository{ + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "grafana/", + }, + }, + }, + repo: mockRepo, + decryptedPassword: "test-token", + opts: repository.CloneOptions{ + PushOnWrites: true, + }, + } + + return repo, mockRepo, nil + }, + pushOpts: repository.PushOptions{ + Timeout: 5 * time.Minute, + }, + expectError: false, + }, + { + name: "push with custom progress writer", + setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) { + mockRepo := NewMockRepository(t) + mockRepo.On("PushContext", mock.Anything, mock.MatchedBy(func(o *git.PushOptions) bool { + return o.Progress != nil && o.Progress != io.Discard + })).Return(nil) + + repo := &GoGitRepo{ + config: &v0alpha1.Repository{ + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "grafana/", + }, + }, + }, + repo: mockRepo, + decryptedPassword: "test-token", + opts: repository.CloneOptions{ + PushOnWrites: true, + }, + } + + return repo, mockRepo, nil + }, + pushOpts: repository.PushOptions{ + Progress: &bytes.Buffer{}, + }, + expectError: false, + }, + { + name: "push with BeforeFn success", + setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) { + mockRepo := NewMockRepository(t) + mockRepo.On("PushContext", mock.Anything, mock.Anything).Return(nil) + + repo := &GoGitRepo{ + config: &v0alpha1.Repository{ + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "grafana/", + }, + }, + }, + repo: mockRepo, + decryptedPassword: "test-token", + opts: repository.CloneOptions{ + PushOnWrites: true, + }, + } + + return repo, mockRepo, nil + }, + pushOpts: repository.PushOptions{ + BeforeFn: func() error { + return nil + }, + }, + expectError: false, + }, + { + name: "push with BeforeFn error", + setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) { + // No mock expectations since BeforeFn will fail before PushContext is called + + repo := &GoGitRepo{ + config: &v0alpha1.Repository{ + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "grafana/", + }, + }, + }, + repo: NewMockRepository(t), + decryptedPassword: "test-token", + opts: repository.CloneOptions{ + PushOnWrites: true, + }, + } + + return repo, repo.repo.(*MockRepository), nil + }, + pushOpts: repository.PushOptions{ + BeforeFn: func() error { + return fmt.Errorf("before function failed") + }, + }, + expectError: true, + errorType: fmt.Errorf("before function failed"), + }, + { + name: "push with PushOnWrites=false commits changes", + setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) { + mockRepo := NewMockRepository(t) + mockRepo.On("PushContext", mock.Anything, mock.Anything).Return(nil) + + mockTree := NewMockWorktree(t) + mockTree.On("Commit", "exported from grafana", mock.MatchedBy(func(o *git.CommitOptions) bool { + return o.All == true + })).Return(plumbing.NewHash("abc123"), nil) + + repo := &GoGitRepo{ + config: &v0alpha1.Repository{ + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "grafana/", + }, + }, + }, + repo: mockRepo, + tree: mockTree, + decryptedPassword: "test-token", + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + } + + return repo, mockRepo, mockTree + }, + pushOpts: repository.PushOptions{}, + expectError: false, + }, + { + name: "push with PushOnWrites=false and empty commit", + setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) { + mockRepo := NewMockRepository(t) + mockRepo.On("PushContext", mock.Anything, mock.Anything).Return(nil) + + mockTree := NewMockWorktree(t) + mockTree.On("Commit", "exported from grafana", mock.Anything).Return(plumbing.ZeroHash, git.ErrEmptyCommit) + + repo := &GoGitRepo{ + config: &v0alpha1.Repository{ + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "grafana/", + }, + }, + }, + repo: mockRepo, + tree: mockTree, + decryptedPassword: "test-token", + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + } + + return repo, mockRepo, mockTree + }, + pushOpts: repository.PushOptions{}, + expectError: false, + }, + { + name: "push with PushOnWrites=false and commit error", + setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) { + mockTree := NewMockWorktree(t) + mockTree.On("Commit", "exported from grafana", mock.Anything).Return(plumbing.ZeroHash, fmt.Errorf("commit error")) + + repo := &GoGitRepo{ + config: &v0alpha1.Repository{ + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "grafana/", + }, + }, + }, + repo: NewMockRepository(t), + tree: mockTree, + decryptedPassword: "test-token", + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + } + + return repo, repo.repo.(*MockRepository), mockTree + }, + pushOpts: repository.PushOptions{}, + expectError: true, + errorType: fmt.Errorf("commit error"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup the test + repo, mockRepo, mockTree := tt.setupMock(t) + + // Test the Push method + ctx := context.Background() + err := repo.Push(ctx, tt.pushOpts) + + // Verify results + if tt.expectError { + require.Error(t, err) + if tt.errorType != nil { + require.Contains(t, err.Error(), tt.errorType.Error()) + } + } else { + require.NoError(t, err) + } + + // Verify mock expectations if mocks were created + if mockRepo != nil { + mockRepo.AssertExpectations(t) + } + if mockTree != nil { + mockTree.AssertExpectations(t) + } + }) + } +} +func TestGoGitRepo_ReadTree(t *testing.T) { + tests := []struct { + name string + setupMock func(t *testing.T) *GoGitRepo + ref string + expectError bool + expectedErrMsg string + expectedFiles []repository.FileTreeEntry + }{ + { + name: "successful read with files", + setupMock: func(t *testing.T) *GoGitRepo { + mockFS := memfs.New() + + // Create test files in the mock filesystem + require.NoError(t, mockFS.MkdirAll("grafana/folder1", 0750)) + file1, err := mockFS.Create("grafana/file1.txt") + require.NoError(t, err) + _, err = file1.Write([]byte("test content")) + require.NoError(t, err) + require.NoError(t, file1.Close()) + + file2, err := mockFS.Create("grafana/folder1/file2.txt") + require.NoError(t, err) + _, err = file2.Write([]byte("nested file content")) + require.NoError(t, err) + require.NoError(t, file2.Close()) + + mockTree := NewMockWorktree(t) + mockTree.On("Filesystem").Return(mockFS) + + return &GoGitRepo{ + config: &v0alpha1.Repository{ + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "grafana/", + }, + }, + }, + tree: mockTree, + } + }, + ref: "main", + expectError: false, + expectedFiles: []repository.FileTreeEntry{ + {Path: "file1.txt", Size: 12, Blob: true, Hash: "TODO/12"}, + {Path: "folder1", Size: 0, Blob: false}, + {Path: "folder1/file2.txt", Size: 19, Blob: true, Hash: "TODO/19"}, + }, + }, + { + name: "filesystem error", + setupMock: func(t *testing.T) *GoGitRepo { + mockTree := NewMockWorktree(t) + mockFS := memfs.New() + + // Create a filesystem that will return an error when accessed + mockTree.On("Filesystem").Return(mockFS) + + return &GoGitRepo{ + config: &v0alpha1.Repository{ + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "non-existent-path/", + }, + }, + }, + tree: mockTree, + } + }, + ref: "main", + expectError: false, // ReadTree handles fs.ErrNotExist by returning empty entries + expectedFiles: []repository.FileTreeEntry{}, + }, + { + name: "successful read with empty path", + setupMock: func(t *testing.T) *GoGitRepo { + mockFS := memfs.New() + + // Create test files in the mock filesystem + file1, err := mockFS.Create("file1.txt") + require.NoError(t, err) + _, err = file1.Write([]byte("test content")) + require.NoError(t, err) + require.NoError(t, file1.Close()) + + require.NoError(t, mockFS.MkdirAll("folder1", 0750)) + file2, err := mockFS.Create("folder1/file2.txt") + require.NoError(t, err) + _, err = file2.Write([]byte("nested file content")) + require.NoError(t, err) + require.NoError(t, file2.Close()) + + // Create .git directory which should be ignored + require.NoError(t, mockFS.MkdirAll(".git", 0750)) + gitFile, err := mockFS.Create(".git/config") + require.NoError(t, err) + _, err = gitFile.Write([]byte("git config")) + require.NoError(t, err) + require.NoError(t, gitFile.Close()) + + mockTree := NewMockWorktree(t) + mockTree.On("Filesystem").Return(mockFS) + + return &GoGitRepo{ + config: &v0alpha1.Repository{ + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "", + }, + }, + }, + tree: mockTree, + } + }, + ref: "main", + expectError: false, + expectedFiles: []repository.FileTreeEntry{ + {Path: "file1.txt", Size: 12, Blob: true, Hash: "TODO/12"}, + {Path: "folder1", Size: 0, Blob: false}, + {Path: "folder1/file2.txt", Size: 19, Blob: true, Hash: "TODO/19"}, + }, + }, + { + name: "filesystem error", + setupMock: func(t *testing.T) *GoGitRepo { + mockTree := NewMockWorktree(t) + mockFS := memfs.New() + + // Create a filesystem that will return an error when accessed + mockTree.On("Filesystem").Return(mockFS) + + return &GoGitRepo{ + config: &v0alpha1.Repository{ + Spec: v0alpha1.RepositorySpec{ + GitHub: &v0alpha1.GitHubRepositoryConfig{ + Path: "non-existent-path/", + }, + }, + }, + tree: mockTree, + } + }, + ref: "main", + expectError: false, // ReadTree handles fs.ErrNotExist by returning empty entries + expectedFiles: []repository.FileTreeEntry{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup the test + repo := tt.setupMock(t) + + // Test the ReadTree method + ctx := context.Background() + entries, err := repo.ReadTree(ctx, tt.ref) + + // Verify results + if tt.expectError { + require.Error(t, err) + if tt.expectedErrMsg != "" { + require.Contains(t, err.Error(), tt.expectedErrMsg) + } + } else { + require.NoError(t, err) + + // Sort entries for consistent comparison + sort.Slice(entries, func(i, j int) bool { + return entries[i].Path < entries[j].Path + }) + sort.Slice(tt.expectedFiles, func(i, j int) bool { + return tt.expectedFiles[i].Path < tt.expectedFiles[j].Path + }) + + require.Equal(t, len(tt.expectedFiles), len(entries), "Number of entries should match") + for i, expected := range tt.expectedFiles { + require.Equal(t, expected.Path, entries[i].Path, "Path should match") + require.Equal(t, expected.Size, entries[i].Size, "Size should match") + require.Equal(t, expected.Blob, entries[i].Blob, "Blob flag should match") + if expected.Blob { + require.Equal(t, expected.Hash, entries[i].Hash, "Hash should match") + } + } + } + + // Verify mock expectations + repo.tree.(*MockWorktree).AssertExpectations(t) + }) + } +}