package git import ( "context" "errors" "fmt" "os" "strings" "time" "github.com/go-git/go-billy/v5" "github.com/go-git/go-billy/v5/memfs" gogit "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/storage/memory" "github.com/aws/eks-distro-build-tooling/tools/eksDistroBuildToolingOpsTools/pkg/logger" "github.com/aws/eks-distro-build-tooling/tools/eksDistroBuildToolingOpsTools/pkg/retrier" ) const ( gitTimeout = 60 * time.Second maxRetries = 5 backOffPeriod = 5 * time.Second emptyRepoError = "remote repository is empty" ) type GogitClient struct { Auth transport.AuthMethod Client GoGit RepoUrl string RepoDirectory *string InMemory bool Retrier *retrier.Retrier } type Opt func(*GogitClient) func NewClient(opts ...Opt) Client { c := &GogitClient{ Client: &goGit{}, Retrier: retrier.NewWithMaxRetries(maxRetries, backOffPeriod), } for _, opt := range opts { opt(c) } return c } func WithAuth(auth transport.AuthMethod) Opt { return func(c *GogitClient) { c.Auth = auth } } func WithRepositoryUrl(repoUrl string) Opt { return func(c *GogitClient) { c.RepoUrl = repoUrl } } func WithRepositoryDirectory(repoDir string) Opt { return func(c *GogitClient) { c.RepoDirectory = &repoDir } } func WithInMemoryFilesystem() Opt { return func(c *GogitClient) { c.InMemory = true } } func (g *GogitClient) Clone(ctx context.Context) error { if g.RepoDirectory != nil && !g.InMemory { _, err := g.Client.Clone(ctx, *g.RepoDirectory, g.RepoUrl, g.Auth) if err != nil && strings.Contains(err.Error(), emptyRepoError) { return &RepositoryIsEmptyError{ Repository: *g.RepoDirectory, } } return nil } _, err := g.Client.CloneInMemory(ctx, g.RepoUrl, g.Auth) if err != nil && strings.Contains(err.Error(), emptyRepoError) { return &RepositoryIsEmptyError{ Repository: g.RepoUrl, } } return err } func (g *GogitClient) Add(filename string) error { logger.V(3).Info("Opening directory", "directory", g.RepoDirectory) r, err := g.Client.OpenRepo() if err != nil { return err } logger.V(3).Info("Opening working tree") w, err := g.Client.OpenWorktree(r) if err != nil { return err } logger.V(3).Info("Tracking specified files", "file", filename) err = g.Client.AddGlob(filename, w) return err } func (g *GogitClient) Remove(filename string) error { logger.V(3).Info("Opening directory", "directory", g.RepoDirectory) r, err := g.Client.OpenRepo() if err != nil { return err } logger.V(3).Info("Opening working tree") w, err := g.Client.OpenWorktree(r) if err != nil { return err } logger.V(3).Info("Removing specified files", "file", filename) _, err = g.Client.Remove(filename, w) return err } type CommitOpt func(signature *object.Signature) func WithUser(user string) CommitOpt { return func (o *object.Signature) { o.Name = user } } func WithEmail(email string) CommitOpt { return func (o *object.Signature) { o.Email = email } } func (g *GogitClient) Commit(message string, opts ...CommitOpt) error { logger.V(3).Info("Opening directory", "directory", g.RepoDirectory) r, err := g.Client.OpenRepo() if err != nil { logger.Info("Failed while attempting to open repo") return err } logger.V(3).Info("Opening working tree") w, err := g.Client.OpenWorktree(r) if err != nil { return err } logger.V(3).Info("Generating Commit object...") commitSignature := &object.Signature{ When: time.Now(), } for _, opt := range opts { opt(commitSignature) } commit, err := g.Client.Commit(message, commitSignature, w) if err != nil { return err } logger.V(3).Info("Committing Object to local repo", "repo", g.RepoDirectory) finalizedCommit, err := g.Client.CommitObject(r, commit) if err != nil { return err } logger.Info("Finalized commit and committed to local repository", "hash", finalizedCommit.Hash) return err } func (g *GogitClient) Push(ctx context.Context) error { logger.V(3).Info("Pushing to remote", "repo", g.RepoDirectory) r, err := g.Client.OpenRepo() if err != nil { return fmt.Errorf("err pushing: %v", err) } err = g.Client.PushWithContext(ctx, r, g.Auth) if err != nil { return fmt.Errorf("pushing: %v", err) } return err } func (g *GogitClient) Pull(ctx context.Context, branch string) error { logger.V(3).Info("Pulling from remote", "repo", g.RepoDirectory, "remote", gogit.DefaultRemoteName) r, err := g.Client.OpenRepo() if err != nil { return fmt.Errorf("pulling from remote: %v", err) } w, err := g.Client.OpenWorktree(r) if err != nil { return fmt.Errorf("pulling from remote: %v", err) } branchRef := plumbing.NewBranchReferenceName(branch) err = g.Client.PullWithContext(ctx, w, g.Auth, branchRef) if errors.Is(err, gogit.NoErrAlreadyUpToDate) { logger.V(3).Info("Local repo already up-to-date", "repo", g.RepoDirectory, "remote", gogit.DefaultRemoteName) return &RepositoryUpToDateError{} } if err != nil { return fmt.Errorf("pulling from remote: %v", err) } ref, err := g.Client.Head(r) if err != nil { return fmt.Errorf("pulling from remote: %v", err) } commit, err := g.Client.CommitObject(r, ref.Hash()) if err != nil { return fmt.Errorf("accessing latest commit after pulling from remote: %v", err) } logger.V(3).Info("Successfully pulled from remote", "repo", g.RepoDirectory, "remote", gogit.DefaultRemoteName, "latest commit", commit.Hash) return nil } func (g *GogitClient) Init() error { r, err := g.Client.Init(*g.RepoDirectory) if err != nil { return err } if _, err = g.Client.Create(r, g.RepoUrl); err != nil { return fmt.Errorf("initializing repository: %v", err) } return nil } func (g *GogitClient) Branch(name string) error { r, err := g.Client.OpenRepo() if err != nil { return fmt.Errorf("creating branch %s: %v", name, err) } localBranchRef := plumbing.NewBranchReferenceName(name) branchOpts := &config.Branch{ Name: name, Remote: gogit.DefaultRemoteName, Merge: localBranchRef, Rebase: "true", } err = g.Client.CreateBranch(r, branchOpts) branchExistsLocally := errors.Is(err, gogit.ErrBranchExists) if err != nil && !branchExistsLocally { return fmt.Errorf("creating branch %s: %v", name, err) } if branchExistsLocally { logger.V(3).Info("Branch already exists locally", "branch", name) } if !branchExistsLocally { logger.V(3).Info("Branch does not exist locally", "branch", name) headref, err := g.Client.Head(r) if err != nil { return fmt.Errorf("creating branch %s: %v", name, err) } h := headref.Hash() err = g.Client.SetRepositoryReference(r, plumbing.NewHashReference(localBranchRef, h)) if err != nil { return fmt.Errorf("creating branch %s: %v", name, err) } } w, err := g.Client.OpenWorktree(r) if err != nil { return fmt.Errorf("creating branch %s: %v", name, err) } err = g.Client.Checkout(w, &gogit.CheckoutOptions{ Branch: plumbing.ReferenceName(localBranchRef.String()), Force: true, }) if err != nil { return fmt.Errorf("creating branch %s: %v", name, err) } err = g.pullIfRemoteExists(r, w, name, localBranchRef) if err != nil { return fmt.Errorf("creating branch %s: %v", name, err) } return nil } func (g *GogitClient) ValidateRemoteExists(ctx context.Context) error { logger.V(3).Info("Validating git setup", "repoUrl", g.RepoUrl) remote := g.Client.NewRemote(g.RepoUrl, gogit.DefaultRemoteName) // Check if we are able to make a connection to the remote by attempting to list refs _, err := g.Client.ListWithContext(ctx, remote, g.Auth) if err != nil { return fmt.Errorf("connecting with remote %v for repository: %v", gogit.DefaultRemoteName, err) } return nil } func (g *GogitClient) pullIfRemoteExists(r *gogit.Repository, w *gogit.Worktree, branchName string, localBranchRef plumbing.ReferenceName) error { err := g.Retrier.Retry(func() error { remoteExists, err := g.remoteBranchExists(r, localBranchRef) if err != nil { return fmt.Errorf("checking if remote branch exists %s: %v", branchName, err) } if remoteExists { err = g.Client.PullWithContext(context.Background(), w, g.Auth, localBranchRef) if err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) && !errors.Is(err, gogit.ErrRemoteNotFound) { return fmt.Errorf("pulling from remote when checking out existing branch %s: %v", branchName, err) } } return nil }) return err } func (g *GogitClient) remoteBranchExists(r *gogit.Repository, localBranchRef plumbing.ReferenceName) (bool, error) { reflist, err := g.Client.ListRemotes(r, g.Auth) if err != nil { if strings.Contains(err.Error(), emptyRepoError) { return false, nil } return false, fmt.Errorf("listing remotes: %v", err) } lb := localBranchRef.String() for _, ref := range reflist { if ref.Name().String() == lb { return true, nil } } return false, nil } type GoGit interface { AddGlob(f string, w *gogit.Worktree) error Checkout(w *gogit.Worktree, opts *gogit.CheckoutOptions) error Clone(ctx context.Context, dir string, repoUrl string, auth transport.AuthMethod) (*gogit.Repository, error) CloneInMemory(ctx context.Context, repourl string, auth transport.AuthMethod) (*gogit.Repository, error) Commit(m string, sig *object.Signature, w *gogit.Worktree) (plumbing.Hash, error) CommitObject(r *gogit.Repository, h plumbing.Hash) (*object.Commit, error) Create(r *gogit.Repository, url string) (*gogit.Remote, error) CreateBranch(r *gogit.Repository, config *config.Branch) error Head(r *gogit.Repository) (*plumbing.Reference, error) NewRemote(url, remoteName string) *gogit.Remote Init(dir string) (*gogit.Repository, error) OpenRepo() (*gogit.Repository, error) OpenWorktree(r *gogit.Repository) (*gogit.Worktree, error) PushWithContext(ctx context.Context, r *gogit.Repository, auth transport.AuthMethod) error PullWithContext(ctx context.Context, w *gogit.Worktree, auth transport.AuthMethod, ref plumbing.ReferenceName) error ListRemotes(r *gogit.Repository, auth transport.AuthMethod) ([]*plumbing.Reference, error) ListWithContext(ctx context.Context, r *gogit.Remote, auth transport.AuthMethod) ([]*plumbing.Reference, error) Remove(f string, w *gogit.Worktree) (plumbing.Hash, error) SetRepositoryReference(r *gogit.Repository, p *plumbing.Reference) error WithRepositoryDirectory(dir string) } type goGit struct{ storer *memory.Storage worktreeFilesystem billy.Filesystem repositoryDirectory string } func (gg *goGit) WithRepositoryDirectory(dir string) { gg.repositoryDirectory = dir } func (gg *goGit) CloneInMemory(ctx context.Context, repourl string, auth transport.AuthMethod) (*gogit.Repository, error) { ctx, cancel := context.WithTimeout(ctx, gitTimeout) defer cancel() if gg.storer == nil { gg.storer = memory.NewStorage() } return gogit.CloneContext(ctx, gg.storer, nil, &gogit.CloneOptions{ Auth: auth, URL: repourl, Progress: os.Stdout, }) } func (gg *goGit) Clone(ctx context.Context, dir string, repourl string, auth transport.AuthMethod) (*gogit.Repository, error) { ctx, cancel := context.WithTimeout(ctx, gitTimeout) defer cancel() return gogit.PlainCloneContext(ctx, dir, false, &gogit.CloneOptions{ Auth: auth, URL: repourl, Progress: os.Stdout, }) } func (gg *goGit) OpenRepo() (*gogit.Repository, error) { if gg.storer == nil && gg.repositoryDirectory != "" { return gogit.PlainOpen(gg.repositoryDirectory) } if gg.worktreeFilesystem == nil { gg.worktreeFilesystem = memfs.New() } return gogit.Open(gg.storer, gg.worktreeFilesystem) } func (gg *goGit) OpenWorktree(r *gogit.Repository) (*gogit.Worktree, error) { return r.Worktree() } func (gg *goGit) AddGlob(f string, w *gogit.Worktree) error { return w.AddGlob(f) } func (gg *goGit) Commit(m string, sig *object.Signature, w *gogit.Worktree) (plumbing.Hash, error) { return w.Commit(m, &gogit.CommitOptions{ Author: sig, }) } func (gg *goGit) CommitObject(r *gogit.Repository, h plumbing.Hash) (*object.Commit, error) { return r.CommitObject(h) } func (gg *goGit) PushWithContext(ctx context.Context, r *gogit.Repository, auth transport.AuthMethod) error { ctx, cancel := context.WithTimeout(ctx, gitTimeout) defer cancel() return r.PushContext(ctx, &gogit.PushOptions{ Auth: auth, }) } func (gg *goGit) PullWithContext(ctx context.Context, w *gogit.Worktree, auth transport.AuthMethod, ref plumbing.ReferenceName) error { ctx, cancel := context.WithTimeout(ctx, gitTimeout) defer cancel() return w.PullContext(ctx, &gogit.PullOptions{RemoteName: gogit.DefaultRemoteName, Auth: auth, ReferenceName: ref}) } func (gg *goGit) Head(r *gogit.Repository) (*plumbing.Reference, error) { return r.Head() } func (gg *goGit) Init(dir string) (*gogit.Repository, error) { return gogit.PlainInit(dir, false) } func (ggc *goGit) NewRemote(url, remoteName string) *gogit.Remote { return gogit.NewRemote(memory.NewStorage(), &config.RemoteConfig{ Name: remoteName, URLs: []string{url}, }) } func (gg *goGit) Checkout(worktree *gogit.Worktree, opts *gogit.CheckoutOptions) error { return worktree.Checkout(opts) } func (gg *goGit) Create(r *gogit.Repository, url string) (*gogit.Remote, error) { return r.CreateRemote(&config.RemoteConfig{ Name: gogit.DefaultRemoteName, URLs: []string{url}, }) } func (gg *goGit) CreateBranch(repo *gogit.Repository, config *config.Branch) error { return repo.CreateBranch(config) } func (gg *goGit) ListRemotes(r *gogit.Repository, auth transport.AuthMethod) ([]*plumbing.Reference, error) { remote, err := r.Remote(gogit.DefaultRemoteName) if err != nil { if errors.Is(err, gogit.ErrRemoteNotFound) { return []*plumbing.Reference{}, nil } return nil, err } refList, err := remote.List(&gogit.ListOptions{Auth: auth}) if err != nil { return nil, err } return refList, nil } func (gg *goGit) Remove(f string, w *gogit.Worktree) (plumbing.Hash, error) { return w.Remove(f) } func (gg *goGit) ListWithContext(ctx context.Context, r *gogit.Remote, auth transport.AuthMethod) ([]*plumbing.Reference, error) { refList, err := r.ListContext(ctx, &gogit.ListOptions{Auth: auth}) if err != nil { return nil, err } return refList, nil } func (gg *goGit) SetRepositoryReference(r *gogit.Repository, p *plumbing.Reference) error { return r.Storer.SetReference(p) }