drone/git/branch.go
2023-11-15 10:15:32 +00:00

349 lines
9.7 KiB
Go

// Copyright 2023 Harness, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package git
import (
"context"
"fmt"
"strings"
"github.com/harness/gitness/errors"
"github.com/harness/gitness/git/adapter"
"github.com/harness/gitness/git/check"
"github.com/harness/gitness/git/types"
gitea "code.gitea.io/gitea/modules/git"
"github.com/rs/zerolog/log"
)
type BranchSortOption int
const (
BranchSortOptionDefault BranchSortOption = iota
BranchSortOptionName
BranchSortOptionDate
)
var listBranchesRefFields = []types.GitReferenceField{
types.GitReferenceFieldRefName,
types.GitReferenceFieldObjectName,
}
type Branch struct {
Name string
SHA string
Commit *Commit
}
type CreateBranchParams struct {
WriteParams
// BranchName is the name of the branch
BranchName string
// Target is a git reference (branch / tag / commit SHA)
Target string
}
type CreateBranchOutput struct {
Branch Branch
}
type GetBranchParams struct {
ReadParams
// BranchName is the name of the branch
BranchName string
}
type GetBranchOutput struct {
Branch Branch
}
type DeleteBranchParams struct {
WriteParams
// Name is the name of the branch
BranchName string
}
type ListBranchesParams struct {
ReadParams
IncludeCommit bool
Query string
Sort BranchSortOption
Order SortOrder
Page int32
PageSize int32
}
type ListBranchesOutput struct {
Branches []Branch
}
func (s *Service) CreateBranch(ctx context.Context, params *CreateBranchParams) (*CreateBranchOutput, error) {
if err := params.Validate(); err != nil {
return nil, err
}
repoPath := getFullPathForRepo(s.reposRoot, params.RepoUID)
env := CreateEnvironmentForPush(ctx, params.WriteParams)
if err := check.BranchName(params.BranchName); err != nil {
return nil, errors.InvalidArgument(err.Error())
}
repo, err := s.adapter.OpenRepository(ctx, repoPath)
if err != nil {
return nil, fmt.Errorf("failed to open repo: %w", err)
}
if ok, err := repo.IsEmpty(); ok {
if err != nil {
return nil, errors.Internal("failed to check if repository is empty: %v", err)
}
return nil, errors.InvalidArgument("branch cannot be created on empty repository")
}
sharedRepo, err := s.adapter.SharedRepository(s.tmpDir, params.RepoUID, repo.Path)
if err != nil {
return nil, errors.Internal("failed to create new shared repo", err)
}
defer sharedRepo.Close(ctx)
// clone repo (with HEAD branch - target might be anything)
err = sharedRepo.Clone(ctx, "")
if err != nil {
return nil, errors.Internal("failed to clone shared repo with branch '%s'", params.BranchName, err)
}
_, err = sharedRepo.GetBranchCommit(params.BranchName)
// return an error if branch alredy exists (push doesn't fail if it's a noop or fast forward push)
if err == nil {
return nil, errors.Conflict("branch '%s' already exists", params.BranchName)
}
if !gitea.IsErrNotExist(err) {
return nil, errors.Internal("branch creation of '%s' failed: %w", params.BranchName, err)
}
// get target commit (as target could be branch/tag/commit, and tag can't be pushed using source:destination syntax)
targetCommit, err := s.adapter.GetCommit(ctx, sharedRepo.Path(), strings.TrimSpace(params.Target))
if gitea.IsErrNotExist(err) {
return nil, errors.NotFound("target '%s' doesn't exist", params.Target)
}
if err != nil {
return nil, errors.Internal("failed to get commit id for target '%s'", params.Target, err)
}
// push to new branch (all changes should go through push flow for hooks and other safety meassures)
err = sharedRepo.PushCommitToBranch(ctx, targetCommit.SHA, params.BranchName, false, env...)
if err != nil {
return nil, err
}
// get branch
// TODO: get it from shared repo to avoid opening another gitea repo and having to strip here.
gitBranch, err := s.adapter.GetBranch(
ctx,
repoPath,
strings.TrimPrefix(params.BranchName, gitReferenceNamePrefixBranch),
)
if err != nil {
return nil, fmt.Errorf("failed to get git branch '%s': %w", params.BranchName, err)
}
branch, err := mapBranch(gitBranch)
if err != nil {
return nil, fmt.Errorf("failed to map rpc branch %v: %w", gitBranch.Name, err)
}
return &CreateBranchOutput{
Branch: *branch,
}, nil
}
func (s *Service) GetBranch(ctx context.Context, params *GetBranchParams) (*GetBranchOutput, error) {
if params == nil {
return nil, ErrNoParamsProvided
}
repoPath := getFullPathForRepo(s.reposRoot, params.RepoUID)
gitBranch, err := s.adapter.GetBranch(ctx, repoPath, params.BranchName)
if err != nil {
return nil, err
}
branch, err := mapBranch(gitBranch)
if err != nil {
return nil, fmt.Errorf("failed to map rpc branch %v: %w", gitBranch.Name, err)
}
return &GetBranchOutput{
Branch: *branch,
}, nil
}
func (s *Service) DeleteBranch(ctx context.Context, params *DeleteBranchParams) error {
if params == nil {
return ErrNoParamsProvided
}
repoPath := getFullPathForRepo(s.reposRoot, params.RepoUID)
env := CreateEnvironmentForPush(ctx, params.WriteParams)
repo, err := s.adapter.OpenRepository(ctx, repoPath)
if err != nil {
return fmt.Errorf("failed to open repo: %w", err)
}
sharedRepo, err := s.adapter.SharedRepository(s.tmpDir, params.RepoUID, repo.Path)
if err != nil {
return fmt.Errorf("failed to create new shared repo: %w", err)
}
defer sharedRepo.Close(ctx)
// clone repo (technically we don't care about which branch we clone)
err = sharedRepo.Clone(ctx, params.BranchName)
if err != nil {
return fmt.Errorf("failed to clone shared repo with branch '%s': %w", params.BranchName, err)
}
// get latest branch commit before we delete
_, err = sharedRepo.GetBranchCommit(params.BranchName)
if err != nil {
return fmt.Errorf("failed to get gitea commit for branch '%s': %w", params.BranchName, err)
}
// push to remote (all changes should go through push flow for hooks and other safety meassures)
// NOTE: setting sourceRef to empty will delete the remote branch when pushing:
// https://git-scm.com/docs/git-push#Documentation/git-push.txt-ltrefspecgt82308203
err = sharedRepo.PushDeleteBranch(ctx, params.BranchName, true, env...)
if err != nil {
return fmt.Errorf("failed to delete branch '%s' from remote repo: %w", params.BranchName, err)
}
return nil
}
func (s *Service) ListBranches(ctx context.Context, params *ListBranchesParams) (*ListBranchesOutput, error) {
if params == nil {
return nil, ErrNoParamsProvided
}
repoPath := getFullPathForRepo(s.reposRoot, params.RepoUID)
gitBranches, err := s.listBranchesLoadReferenceData(ctx, repoPath, types.BranchFilter{
IncludeCommit: params.IncludeCommit,
Query: params.Query,
Sort: mapBranchesSortOption(params.Sort),
Order: mapToSortOrder(params.Order),
Page: params.Page,
PageSize: params.PageSize,
})
if err != nil {
return nil, err
}
// get commits if needed (single call for perf savings: 1s-4s vs 5s-20s)
if params.IncludeCommit {
commitSHAs := make([]string, len(gitBranches))
for i := range gitBranches {
commitSHAs[i] = gitBranches[i].SHA
}
var gitCommits []types.Commit
gitCommits, err = s.adapter.GetCommits(ctx, repoPath, commitSHAs)
if err != nil {
return nil, fmt.Errorf("failed to get commit: %w", err)
}
for i := range gitCommits {
gitBranches[i].Commit = &gitCommits[i]
}
}
branches := make([]Branch, len(gitBranches))
for i, branch := range gitBranches {
b, err := mapBranch(branch)
if err != nil {
return nil, err
}
branches[i] = *b
}
return &ListBranchesOutput{
Branches: branches,
}, nil
}
func (s *Service) listBranchesLoadReferenceData(
ctx context.Context,
repoPath string,
filter types.BranchFilter,
) ([]*types.Branch, error) {
// TODO: can we be smarter with slice allocation
branches := make([]*types.Branch, 0, 16)
handler := listBranchesWalkReferencesHandler(&branches)
instructor, endsAfter, err := wrapInstructorWithOptionalPagination(
adapter.DefaultInstructor, // branches only have one target type, default instructor is enough
filter.Page,
filter.PageSize,
)
if err != nil {
return nil, errors.InvalidArgument("invalid pagination details: %v", err)
}
opts := &types.WalkReferencesOptions{
Patterns: createReferenceWalkPatternsFromQuery(gitReferenceNamePrefixBranch, filter.Query),
Sort: filter.Sort,
Order: filter.Order,
Fields: listBranchesRefFields,
Instructor: instructor,
// we don't do any post-filtering, restrict git to only return as many elements as pagination needs.
MaxWalkDistance: endsAfter,
}
err = s.adapter.WalkReferences(ctx, repoPath, handler, opts)
if err != nil {
return nil, fmt.Errorf("failed to walk branch references: %w", err)
}
log.Ctx(ctx).Trace().Msgf("git adapter returned %d branches", len(branches))
return branches, nil
}
func listBranchesWalkReferencesHandler(
branches *[]*types.Branch,
) types.WalkReferencesHandler {
return func(e types.WalkReferencesEntry) error {
fullRefName, ok := e[types.GitReferenceFieldRefName]
if !ok {
return fmt.Errorf("entry missing reference name")
}
objectSHA, ok := e[types.GitReferenceFieldObjectName]
if !ok {
return fmt.Errorf("entry missing object sha")
}
branch := &types.Branch{
Name: fullRefName[len(gitReferenceNamePrefixBranch):],
SHA: objectSHA,
}
// TODO: refactor to not use slice pointers?
*branches = append(*branches, branch)
return nil
}
}