Rewrite of git merge (#1023)

eb/code-1016-2
Marko Gacesa 2024-02-07 09:40:04 +00:00 committed by Harness
parent ab364dcd39
commit e3bf017f78
8 changed files with 1016 additions and 190 deletions

View File

@ -223,6 +223,7 @@ func (c *Controller) Merge(
BaseBranch: pr.TargetBranch, BaseBranch: pr.TargetBranch,
HeadRepoUID: sourceRepo.GitUID, HeadRepoUID: sourceRepo.GitUID,
HeadBranch: pr.SourceBranch, HeadBranch: pr.SourceBranch,
RefType: gitenum.RefTypeUndefined, // update no refs -> no commit will be created
HeadExpectedSHA: in.SourceSHA, HeadExpectedSHA: in.SourceSHA,
}) })
if err != nil { if err != nil {
@ -233,7 +234,7 @@ func (c *Controller) Merge(
if pr.SourceSHA != mergeOutput.HeadSHA { if pr.SourceSHA != mergeOutput.HeadSHA {
return errors.New("source SHA has changed") return errors.New("source SHA has changed")
} }
if mergeOutput.MergeSHA == "" || len(mergeOutput.ConflictFiles) > 0 { if len(mergeOutput.ConflictFiles) > 0 {
pr.MergeCheckStatus = enum.MergeCheckStatusConflict pr.MergeCheckStatus = enum.MergeCheckStatusConflict
pr.MergeBaseSHA = mergeOutput.MergeBaseSHA pr.MergeBaseSHA = mergeOutput.MergeBaseSHA
pr.MergeTargetSHA = &mergeOutput.BaseSHA pr.MergeTargetSHA = &mergeOutput.BaseSHA
@ -275,17 +276,41 @@ func (c *Controller) Merge(
return nil, &types.MergeViolations{RuleViolations: violations}, nil return nil, &types.MergeViolations{RuleViolations: violations}, nil
} }
// TODO: for forking merge title might be different? // commit details: author, committer and message
var mergeTitle string
author := *session.Principal.ToPrincipalInfo() var author *git.Identity
if in.Method == enum.MergeMethodSquash {
// squash commit should show as authored by PR author switch in.Method {
author = pr.Author case enum.MergeMethodMerge:
mergeTitle = fmt.Sprintf("%s (#%d)", pr.Title, pr.Number) author = identityFromPrincipalInfo(*session.Principal.ToPrincipalInfo())
} else { case enum.MergeMethodSquash:
mergeTitle = fmt.Sprintf("Merge branch '%s' of %s (#%d)", pr.SourceBranch, sourceRepo.Path, pr.Number) author = identityFromPrincipalInfo(pr.Author)
case enum.MergeMethodRebase:
author = nil // Not important for the rebase merge: the author info in the commits will be preserved.
} }
var committer *git.Identity
switch in.Method {
case enum.MergeMethodMerge, enum.MergeMethodSquash:
committer = identityFromPrincipalInfo(*bootstrap.NewSystemServiceSession().Principal.ToPrincipalInfo())
case enum.MergeMethodRebase:
committer = identityFromPrincipalInfo(*session.Principal.ToPrincipalInfo())
}
var mergeTitle string
switch in.Method {
case enum.MergeMethodMerge:
mergeTitle = fmt.Sprintf("Merge branch '%s' of %s (#%d)", pr.SourceBranch, sourceRepo.Path, pr.Number)
case enum.MergeMethodSquash:
mergeTitle = fmt.Sprintf("%s (#%d)", pr.Title, pr.Number)
case enum.MergeMethodRebase:
mergeTitle = "" // Not used.
}
// create merge commit(s)
log.Ctx(ctx).Debug().Msgf("all pre-check passed, merge PR") log.Ctx(ctx).Debug().Msgf("all pre-check passed, merge PR")
now := time.Now() now := time.Now()
@ -296,9 +321,9 @@ func (c *Controller) Merge(
HeadBranch: pr.SourceBranch, HeadBranch: pr.SourceBranch,
Title: mergeTitle, Title: mergeTitle,
Message: "", Message: "",
Committer: identityFromPrincipalInfo(*bootstrap.NewSystemServiceSession().Principal.ToPrincipalInfo()), Committer: committer,
CommitterDate: &now, CommitterDate: &now,
Author: identityFromPrincipalInfo(author), Author: author,
AuthorDate: &now, AuthorDate: &now,
RefType: gitenum.RefTypeBranch, RefType: gitenum.RefTypeBranch,
RefName: pr.TargetBranch, RefName: pr.TargetBranch,

View File

@ -38,7 +38,7 @@ func (a Adapter) GetBranch(
} }
ref := GetReferenceFromBranchName(branchName) ref := GetReferenceFromBranchName(branchName)
commit, err := getCommit(ctx, repoPath, ref, "") commit, err := GetCommit(ctx, repoPath, ref, "")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to find the commit for the branch: %w", err) return nil, fmt.Errorf("failed to find the commit for the branch: %w", err)
} }

View File

@ -43,7 +43,7 @@ func (a Adapter) GetLatestCommit(
} }
treePath = cleanTreePath(treePath) treePath = cleanTreePath(treePath)
return getCommit(ctx, repoPath, rev, treePath) return GetCommit(ctx, repoPath, rev, treePath)
} }
func getGiteaCommits( func getGiteaCommits(
@ -400,7 +400,7 @@ func (a Adapter) GetCommit(
return nil, ErrRepositoryPathEmpty return nil, ErrRepositoryPathEmpty
} }
return getCommit(ctx, repoPath, rev, "") return GetCommit(ctx, repoPath, rev, "")
} }
func (a Adapter) GetFullCommitID( func (a Adapter) GetFullCommitID(
@ -564,7 +564,9 @@ func parseLinesToSlice(output []byte) []string {
return slice return slice
} }
func getCommit( // GetCommit returns info about a commit.
// TODO: Move this function outside of the adapter package.
func GetCommit(
ctx context.Context, ctx context.Context,
repoPath string, repoPath string,
rev string, rev string,

View File

@ -121,5 +121,5 @@ func (c commitEntryGetter) Find(
path = "." path = "."
} }
return getCommit(ctx, repoPath, commitSHA, path) return GetCommit(ctx, repoPath, commitSHA, path)
} }

View File

@ -17,15 +17,12 @@ package git
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"os"
"path/filepath"
"strings" "strings"
"time" "time"
"github.com/harness/gitness/errors" "github.com/harness/gitness/errors"
"github.com/harness/gitness/git/enum" "github.com/harness/gitness/git/enum"
"github.com/harness/gitness/git/tempdir" "github.com/harness/gitness/git/merge"
"github.com/harness/gitness/git/types" "github.com/harness/gitness/git/types"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@ -120,242 +117,206 @@ type MergeOutput struct {
// //
//nolint:gocognit,gocyclo,cyclop //nolint:gocognit,gocyclo,cyclop
func (s *Service) Merge(ctx context.Context, params *MergeParams) (MergeOutput, error) { func (s *Service) Merge(ctx context.Context, params *MergeParams) (MergeOutput, error) {
if err := params.Validate(); err != nil { err := params.Validate()
return MergeOutput{}, fmt.Errorf("Merge: params not valid: %w", err) if err != nil {
return MergeOutput{}, fmt.Errorf("params not valid: %w", err)
} }
log := log.Ctx(ctx).With().Str("repo_uid", params.RepoUID).Logger()
repoPath := getFullPathForRepo(s.reposRoot, params.RepoUID) repoPath := getFullPathForRepo(s.reposRoot, params.RepoUID)
baseBranch := "base" // prepare the merge method function
trackingBranch := "tracking"
pr := &types.PullRequest{ mergeMethod, ok := params.Method.Sanitize()
BaseRepoPath: repoPath, if !ok && params.Method != "" {
BaseBranch: params.BaseBranch, return MergeOutput{}, errors.InvalidArgument("Unsupported merge method: %s", params.Method)
HeadBranch: params.HeadBranch,
} }
log.Debug().Msg("create temporary repository") var mergeFunc merge.Func
// Clone base repo. switch mergeMethod {
tmpRepo, err := s.adapter.CreateTemporaryRepoForPR(ctx, s.tmpDir, pr, baseBranch, trackingBranch) case enum.MergeMethodMerge:
if err != nil { mergeFunc = merge.Merge
return MergeOutput{}, fmt.Errorf("Merge: failed to initialize temporary repo: %w", err) case enum.MergeMethodSquash:
mergeFunc = merge.Squash
case enum.MergeMethodRebase:
mergeFunc = merge.Rebase
default:
// should not happen, the call to Sanitize above should handle this case.
panic("unsupported merge method")
} }
defer func() {
rmErr := tempdir.RemoveTemporaryPath(tmpRepo.Path) // set up the target reference
if rmErr != nil {
log.Warn().Msgf("Removing temporary location %s for merge operation was not successful", tmpRepo.Path) var refPath string
var refOldValue string
if params.RefType != enum.RefTypeUndefined {
refPath, err = GetRefPath(params.RefName, params.RefType)
if err != nil {
return MergeOutput{}, fmt.Errorf(
"failed to generate full reference for type '%s' and name '%s' for merge operation: %w",
params.RefType, params.RefName, err)
} }
}()
log.Debug().Msg("get merge base") refOldValue, err = s.adapter.GetFullCommitID(ctx, repoPath, refPath)
if errors.IsNotFound(err) {
refOldValue = types.NilSHA
} else if err != nil {
return MergeOutput{}, fmt.Errorf("failed to resolve %q: %w", refPath, err)
}
}
mergeBaseCommitSHA, _, err := s.adapter.GetMergeBase(ctx, tmpRepo.Path, "origin", baseBranch, trackingBranch) // logger
log := log.Ctx(ctx).With().
Str("repo_uid", params.RepoUID).
Str("head", params.HeadBranch).
Str("base", params.BaseBranch).
Str("method", string(mergeMethod)).
Str("ref", refPath).
Logger()
// find the commit SHAs
baseCommitSHA, err := s.adapter.GetFullCommitID(ctx, repoPath, params.BaseBranch)
if err != nil {
return MergeOutput{}, fmt.Errorf("failed to get merge base branch commit SHA: %w", err)
}
headCommitSHA, err := s.adapter.GetFullCommitID(ctx, repoPath, params.HeadBranch)
if err != nil {
return MergeOutput{}, fmt.Errorf("failed to get merge base branch commit SHA: %w", err)
}
if params.HeadExpectedSHA != "" && params.HeadExpectedSHA != headCommitSHA {
return MergeOutput{}, errors.PreconditionFailed(
"head branch '%s' is on SHA '%s' which doesn't match expected SHA '%s'.",
params.HeadBranch,
headCommitSHA,
params.HeadExpectedSHA)
}
mergeBaseCommitSHA, _, err := s.adapter.GetMergeBase(ctx, repoPath, "origin", baseCommitSHA, headCommitSHA)
if err != nil { if err != nil {
return MergeOutput{}, fmt.Errorf("failed to get merge base: %w", err) return MergeOutput{}, fmt.Errorf("failed to get merge base: %w", err)
} }
if tmpRepo.HeadSHA == mergeBaseCommitSHA { if headCommitSHA == mergeBaseCommitSHA {
return MergeOutput{}, errors.InvalidArgument("no changes between head branch %s and base branch %s", return MergeOutput{}, errors.InvalidArgument("head branch doesn't contain any new commits.")
params.HeadBranch, params.BaseBranch)
} }
if params.HeadExpectedSHA != "" && params.HeadExpectedSHA != tmpRepo.HeadSHA { // find short stat and number of commits
return MergeOutput{}, errors.PreconditionFailed(
"head branch '%s' is on SHA '%s' which doesn't match expected SHA '%s'.",
params.HeadBranch,
tmpRepo.HeadSHA,
params.HeadExpectedSHA)
}
log.Debug().Msg("get diff tree") shortStat, err := s.adapter.DiffShortStat(ctx, repoPath, baseCommitSHA, headCommitSHA, true)
var outbuf, errbuf strings.Builder
// Enable sparse-checkout
sparseCheckoutList, err := s.adapter.GetDiffTree(ctx, tmpRepo.Path, baseBranch, trackingBranch)
if err != nil { if err != nil {
return MergeOutput{}, fmt.Errorf("execution of GetDiffTree failed: %w", err) return MergeOutput{}, errors.Internal(err,
} "failed to find short stat between %s and %s", baseCommitSHA, headCommitSHA)
log.Debug().Msg("prepare sparse-checkout")
infoPath := filepath.Join(tmpRepo.Path, ".git", "info")
if err = os.MkdirAll(infoPath, fileMode700); err != nil {
return MergeOutput{}, fmt.Errorf("unable to create .git/info in tmpRepo.Path: %w", err)
}
sparseCheckoutListPath := filepath.Join(infoPath, "sparse-checkout")
if err = os.WriteFile(sparseCheckoutListPath, []byte(sparseCheckoutList), 0o600); err != nil {
return MergeOutput{},
fmt.Errorf("unable to write .git/info/sparse-checkout file in tmpRepo.Path: %w", err)
}
log.Debug().Msg("get diff stats")
shortStat, err := s.adapter.DiffShortStat(ctx, tmpRepo.Path, tmpRepo.BaseSHA, tmpRepo.HeadSHA, true)
if err != nil {
return MergeOutput{}, fmt.Errorf("execution of DiffShortStat failed: %w", err)
} }
changedFileCount := shortStat.Files changedFileCount := shortStat.Files
log.Debug().Msg("get commit divergene") commitCount, err := merge.CommitCount(ctx, repoPath, baseCommitSHA, headCommitSHA)
divergences, err := s.adapter.GetCommitDivergences(ctx, tmpRepo.Path,
[]types.CommitDivergenceRequest{{From: tmpRepo.HeadSHA, To: tmpRepo.BaseSHA}}, 0)
if err != nil { if err != nil {
return MergeOutput{}, fmt.Errorf("execution of GetCommitDivergences failed: %w", err) return MergeOutput{}, fmt.Errorf("failed to find commit count for merge check: %w", err)
}
commitCount := int(divergences[0].Ahead)
log.Debug().Msg("update git configuration")
// Switch off LFS process (set required, clean and smudge here also)
if err = s.adapter.Config(ctx, tmpRepo.Path, "filter.lfs.process", ""); err != nil {
return MergeOutput{}, err
} }
if err = s.adapter.Config(ctx, tmpRepo.Path, "filter.lfs.required", "false"); err != nil { // handle simple merge check
return MergeOutput{}, err
if params.RefType == enum.RefTypeUndefined {
_, _, conflicts, err := merge.FindConflicts(ctx, repoPath, baseCommitSHA, headCommitSHA)
if err != nil {
return MergeOutput{}, errors.Internal(err,
"Merge check failed to find conflicts between commits %s and %s",
baseCommitSHA, headCommitSHA)
}
log.Debug().Msg("merged check completed")
return MergeOutput{
BaseSHA: baseCommitSHA,
HeadSHA: headCommitSHA,
MergeBaseSHA: mergeBaseCommitSHA,
MergeSHA: "",
CommitCount: commitCount,
ChangedFileCount: changedFileCount,
ConflictFiles: conflicts,
}, nil
} }
if err = s.adapter.Config(ctx, tmpRepo.Path, "filter.lfs.clean", ""); err != nil { // author and committer
return MergeOutput{}, err
}
if err = s.adapter.Config(ctx, tmpRepo.Path, "filter.lfs.smudge", ""); err != nil { now := time.Now().UTC()
return MergeOutput{}, err
}
if err = s.adapter.Config(ctx, tmpRepo.Path, "core.sparseCheckout", "true"); err != nil { committer := types.Signature{Identity: types.Identity(params.Actor), When: now}
return MergeOutput{}, err
}
log.Debug().Msg("read tree")
// Read base branch index
if err = s.adapter.ReadTree(ctx, tmpRepo.Path, "HEAD", io.Discard); err != nil {
return MergeOutput{}, fmt.Errorf("failed to read tree: %w", err)
}
outbuf.Reset()
errbuf.Reset()
committer := params.Actor
if params.Committer != nil { if params.Committer != nil {
committer = *params.Committer committer.Identity = types.Identity(*params.Committer)
} }
committerDate := time.Now().UTC()
if params.CommitterDate != nil { if params.CommitterDate != nil {
committerDate = *params.CommitterDate committer.When = *params.CommitterDate
} }
author := committer author := committer
if params.Author != nil { if params.Author != nil {
author = *params.Author author.Identity = types.Identity(*params.Author)
} }
authorDate := committerDate
if params.AuthorDate != nil { if params.AuthorDate != nil {
authorDate = *params.AuthorDate author.When = *params.AuthorDate
} }
// Because this may call hooks we should pass in the environment // merge message
// TODO: merge specific envars should be set by the adapter impl.
env := append(CreateEnvironmentForPush(ctx, params.WriteParams),
"GIT_AUTHOR_NAME="+author.Name,
"GIT_AUTHOR_EMAIL="+author.Email,
"GIT_AUTHOR_DATE="+authorDate.Format(time.RFC3339),
"GIT_COMMITTER_NAME="+committer.Name,
"GIT_COMMITTER_EMAIL="+committer.Email,
"GIT_COMMITTER_DATE="+committerDate.Format(time.RFC3339),
)
mergeMsg := strings.TrimSpace(params.Title) mergeMsg := strings.TrimSpace(params.Title)
if len(params.Message) > 0 { if len(params.Message) > 0 {
mergeMsg += "\n\n" + strings.TrimSpace(params.Message) mergeMsg += "\n\n" + strings.TrimSpace(params.Message)
} }
if params.Method == "" { // merge
params.Method = enum.MergeMethodMerge
}
log.Debug().Msg("perform merge") mergeCommitSHA, conflicts, err := mergeFunc(
result, err := s.adapter.Merge(
ctx, ctx,
pr, repoPath, s.tmpDir,
params.Method, &author, &committer,
baseBranch,
trackingBranch,
tmpRepo.Path,
mergeMsg, mergeMsg,
&types.Identity{ mergeBaseCommitSHA, baseCommitSHA, headCommitSHA)
Name: author.Name,
Email: author.Email,
},
env...)
if err != nil { if err != nil {
return MergeOutput{}, fmt.Errorf("merge failed: %w", err) return MergeOutput{}, errors.Internal(err, "failed to merge %q to %q in %q using the %q merge method.",
params.HeadBranch, params.BaseBranch, params.RepoUID, mergeMethod)
} }
if len(conflicts) != 0 {
if len(result.ConflictFiles) > 0 {
return MergeOutput{ return MergeOutput{
BaseSHA: tmpRepo.BaseSHA, BaseSHA: baseCommitSHA,
HeadSHA: tmpRepo.HeadSHA, HeadSHA: headCommitSHA,
MergeBaseSHA: mergeBaseCommitSHA, MergeBaseSHA: mergeBaseCommitSHA,
MergeSHA: "", MergeSHA: "",
CommitCount: commitCount, CommitCount: commitCount,
ChangedFileCount: changedFileCount, ChangedFileCount: changedFileCount,
ConflictFiles: result.ConflictFiles, ConflictFiles: conflicts,
}, nil }, nil
} }
log.Debug().Msg("get commit id") // git reference update
mergeCommitSHA, err := s.adapter.GetFullCommitID(ctx, tmpRepo.Path, baseBranch) log.Trace().Msg("merge completed - updating git reference")
err = s.adapter.UpdateRef(
ctx,
params.EnvVars,
repoPath,
refPath,
refOldValue,
mergeCommitSHA,
)
if err != nil { if err != nil {
return MergeOutput{}, fmt.Errorf("failed to get full commit id for the new merge: %w", err) return MergeOutput{},
errors.Internal(err, "failed to update branch %q after merging commits", params.HeadBranch)
} }
if params.RefType == enum.RefTypeUndefined { log.Trace().Msg("merge completed - git reference updated")
log.Debug().Msg("done (merge-check only)")
return MergeOutput{
BaseSHA: tmpRepo.BaseSHA,
HeadSHA: tmpRepo.HeadSHA,
MergeBaseSHA: mergeBaseCommitSHA,
MergeSHA: mergeCommitSHA,
CommitCount: commitCount,
ChangedFileCount: changedFileCount,
ConflictFiles: nil,
}, nil
}
refPath, err := GetRefPath(params.RefName, params.RefType)
if err != nil {
return MergeOutput{}, fmt.Errorf(
"failed to generate full reference for type '%s' and name '%s' for merge operation: %w",
params.RefType, params.RefName, err)
}
pushRef := baseBranch + ":" + refPath
log.Debug().Msg("push to original repo")
if err = s.adapter.Push(ctx, tmpRepo.Path, types.PushOptions{
Remote: "origin",
Branch: pushRef,
Force: params.Force,
Env: env,
}); err != nil {
return MergeOutput{}, fmt.Errorf("failed to push merge commit to ref '%s': %w", refPath, err)
}
log.Debug().Msg("done")
return MergeOutput{ return MergeOutput{
BaseSHA: tmpRepo.BaseSHA, BaseSHA: baseCommitSHA,
HeadSHA: tmpRepo.HeadSHA, HeadSHA: headCommitSHA,
MergeBaseSHA: mergeBaseCommitSHA, MergeBaseSHA: mergeBaseCommitSHA,
MergeSHA: mergeCommitSHA, MergeSHA: mergeCommitSHA,
CommitCount: commitCount, CommitCount: commitCount,

102
git/merge/check.go Normal file
View File

@ -0,0 +1,102 @@
// 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 merge
import (
"bytes"
"context"
"strconv"
"strings"
"github.com/harness/gitness/errors"
"github.com/harness/gitness/git/command"
"github.com/rs/zerolog/log"
)
// FindConflicts checks if two git revisions are mergeable and returns list of conflict files if they are not.
func FindConflicts(
ctx context.Context,
repoPath,
base, head string,
) (mergeable bool, treeSHA string, conflicts []string, err error) {
cmd := command.New("merge-tree",
command.WithFlag("--write-tree"),
command.WithFlag("--name-only"),
command.WithFlag("--no-messages"),
command.WithFlag("--stdin"))
stdin := base + " " + head
stdout := bytes.NewBuffer(nil)
err = cmd.Run(ctx,
command.WithDir(repoPath),
command.WithStdin(strings.NewReader(stdin)),
command.WithStdout(stdout))
if err != nil {
return false, "", nil, errors.Internal(err, "Failed to find conflicts between %s and %s", base, head)
}
output := strings.TrimSpace(stdout.String())
output = strings.TrimSuffix(output, "\000")
lines := strings.Split(output, "\000")
if len(lines) < 2 {
log.Ctx(ctx).Error().Str("output", output).Msg("Unexpected merge-tree output")
return false, "", nil, errors.Internal(nil,
"Failed to find conflicts between %s and %s: Unexpected git output", base, head)
}
status, err := strconv.Atoi(lines[0])
if err != nil {
log.Ctx(ctx).Err(err).Str("output", output).Msg("Unexpected merge status")
return false, "", nil, errors.Internal(nil,
"Failed to find conflicts between %s and %s: Unexpected merge status", base, head)
}
if status < 0 {
return false, "", nil, errors.Internal(nil,
"Failed to find conflicts between %s and %s: Operation blocked. Status=%d", base, head, status)
}
if status == 1 {
return true, lines[1], nil, nil // all good, merge possible, no conflicts found
}
return false, lines[1], lines[2:], nil // conflict found, list of conflicted files returned
}
// CommitCount returns number of commits between the two git revisions.
func CommitCount(
ctx context.Context,
repoPath string,
start, end string,
) (int, error) {
cmd := command.New("rev-list", command.WithFlag("--count"), command.WithArg(start+".."+end))
stdout := bytes.NewBuffer(nil)
if err := cmd.Run(ctx, command.WithDir(repoPath), command.WithStdout(stdout)); err != nil {
return 0, errors.Internal(err, "failed to rev-list in shared repo")
}
commitCount, err := strconv.Atoi(strings.TrimSpace(stdout.String()))
if err != nil {
return 0, errors.Internal(err, "failed to parse commit count from rev-list output in shared repo")
}
return commitCount, nil
}

209
git/merge/merge.go Normal file
View File

@ -0,0 +1,209 @@
// 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 merge
import (
"context"
"fmt"
"github.com/harness/gitness/git/adapter"
"github.com/harness/gitness/git/sharedrepo"
"github.com/harness/gitness/git/types"
)
// Func represents a merge method function. The concrete merge implementation functions must have this signature.
type Func func(
ctx context.Context,
repoPath, tmpDir string,
author, committer *types.Signature,
message string,
mergeBaseSHA, targetSHA, sourceSHA string,
) (mergeSHA string, conflicts []string, err error)
// Merge merges two the commits (targetSHA and sourceSHA) using the Merge method.
func Merge(
ctx context.Context,
repoPath, tmpDir string,
author, committer *types.Signature,
message string,
mergeBaseSHA, targetSHA, sourceSHA string,
) (mergeSHA string, conflicts []string, err error) {
return mergeInternal(ctx,
repoPath, tmpDir,
author, committer,
message,
mergeBaseSHA, targetSHA, sourceSHA,
false)
}
// Squash merges two the commits (targetSHA and sourceSHA) using the Squash method.
func Squash(
ctx context.Context,
repoPath, tmpDir string,
author, committer *types.Signature,
message string,
mergeBaseSHA, targetSHA, sourceSHA string,
) (mergeSHA string, conflicts []string, err error) {
return mergeInternal(ctx,
repoPath, tmpDir,
author, committer,
message,
mergeBaseSHA, targetSHA, sourceSHA,
true)
}
// mergeInternal is internal implementation of merge used for Merge and Squash methods.
func mergeInternal(
ctx context.Context,
repoPath, tmpDir string,
author, committer *types.Signature,
message string,
mergeBaseSHA, targetSHA, sourceSHA string,
squash bool,
) (mergeSHA string, conflicts []string, err error) {
err = runInSharedRepo(ctx, tmpDir, repoPath, func(s *sharedrepo.SharedRepo) error {
var err error
var treeSHA string
treeSHA, conflicts, err = s.MergeTree(ctx, mergeBaseSHA, targetSHA, sourceSHA)
if err != nil {
return fmt.Errorf("merge tree failed: %w", err)
}
if len(conflicts) > 0 {
return nil
}
parents := make([]string, 0, 2)
parents = append(parents, targetSHA)
if !squash {
parents = append(parents, sourceSHA)
}
mergeSHA, err = s.CommitTree(ctx, author, committer, treeSHA, message, false, parents...)
if err != nil {
return fmt.Errorf("commit tree failed: %w", err)
}
return nil
})
if err != nil {
return "", nil, fmt.Errorf("merge method=merge squash=%t: %w", squash, err)
}
return mergeSHA, conflicts, nil
}
// Rebase merges two the commits (targetSHA and sourceSHA) using the Rebase method.
func Rebase(
ctx context.Context,
repoPath, tmpDir string,
_, committer *types.Signature, // commit author isn't used here - it's copied from every commit
_ string, // commit message isn't used here
mergeBaseSHA, targetSHA, sourceSHA string,
) (mergeSHA string, conflicts []string, err error) {
err = runInSharedRepo(ctx, tmpDir, repoPath, func(s *sharedrepo.SharedRepo) error {
sourceSHAs, err := s.CommitSHAList(ctx, mergeBaseSHA, sourceSHA)
if err != nil {
return fmt.Errorf("failed to find commit list in rebase merge: %w", err)
}
lastCommitSHA := targetSHA
for i := len(sourceSHAs) - 1; i >= 0; i-- {
commitSHA := sourceSHAs[i]
var treeSHA string
var commitConflicts []string
commitInfo, err := adapter.GetCommit(ctx, s.Directory(), commitSHA, "")
if err != nil {
return fmt.Errorf("failed to get commit data in rebase merge: %w", err)
}
// rebase merge preserves the commit author (and date) and the commit message, but changes the committer.
author := &commitInfo.Author
message := commitInfo.Title
if commitInfo.Message != "" {
message += "\n\n" + commitInfo.Message
}
treeSHA, commitConflicts, err = s.MergeTree(ctx, mergeBaseSHA, lastCommitSHA, commitSHA)
if err != nil {
return fmt.Errorf("failed to merge tree in rebase merge: %w", err)
}
if len(commitConflicts) > 0 {
_, _, conflicts, err = FindConflicts(ctx, s.Directory(), targetSHA, sourceSHA)
if err != nil {
return fmt.Errorf("failed to find conflicts in rebase merge: %w", err)
}
if len(conflicts) == 0 {
return fmt.Errorf("expected to find conflicts after rebase merge between %s and %s, but couldn't",
mergeBaseSHA, sourceSHA)
}
return nil
}
lastCommitSHA, err = s.CommitTree(ctx, author, committer, treeSHA, message, false, lastCommitSHA)
if err != nil {
return fmt.Errorf("failed to commit tree in rebase merge: %w", err)
}
}
mergeSHA = lastCommitSHA
return nil
})
if err != nil {
return "", nil, fmt.Errorf("merge method=rebase: %w", err)
}
return mergeSHA, conflicts, nil
}
// runInSharedRepo is helper function used to run the provided function inside a shared repository.
func runInSharedRepo(
ctx context.Context,
tmpDir, repoPath string,
fn func(s *sharedrepo.SharedRepo) error,
) error {
s, err := sharedrepo.NewSharedRepo(tmpDir, repoPath)
if err != nil {
return err
}
defer s.Close(ctx)
err = s.InitAsBare(ctx)
if err != nil {
return err
}
err = fn(s)
if err != nil {
return err
}
err = s.MoveObjects(ctx)
if err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,527 @@
// 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 sharedrepo
import (
"bufio"
"bytes"
"context"
"crypto/rand"
"encoding/base32"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/harness/gitness/errors"
"github.com/harness/gitness/git/command"
"github.com/harness/gitness/git/tempdir"
"github.com/harness/gitness/git/types"
"github.com/rs/zerolog/log"
)
type SharedRepo struct {
temporaryPath string
repositoryPath string
}
// NewSharedRepo creates a new temporary bare repository.
func NewSharedRepo(
baseTmpDir string,
repositoryPath string,
) (*SharedRepo, error) {
var buf [5]byte
_, _ = rand.Read(buf[:])
id := base32.StdEncoding.EncodeToString(buf[:])
temporaryPath, err := tempdir.CreateTemporaryPath(baseTmpDir, id)
if err != nil {
return nil, fmt.Errorf("failed to create shared repository directory: %w", err)
}
t := &SharedRepo{
temporaryPath: temporaryPath,
repositoryPath: repositoryPath,
}
return t, nil
}
func (r *SharedRepo) Close(ctx context.Context) {
if err := tempdir.RemoveTemporaryPath(r.temporaryPath); err != nil {
log.Ctx(ctx).Err(err).
Str("path", r.temporaryPath).
Msg("Failed to remove temporary shared directory")
}
}
func (r *SharedRepo) InitAsBare(ctx context.Context) error {
cmd := command.New("init", command.WithFlag("--bare"))
if err := cmd.Run(ctx, command.WithDir(r.temporaryPath)); err != nil {
return fmt.Errorf("failed to initialize bare git repository directory: %w", err)
}
if err := func() error {
alternates := filepath.Join(r.temporaryPath, "objects", "info", "alternates")
f, err := os.OpenFile(alternates, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("failed to create alternates file: %w", err)
}
defer func() { _ = f.Close() }()
data := filepath.Join(r.repositoryPath, "objects")
if _, err = fmt.Fprintln(f, data); err != nil {
return fmt.Errorf("failed to write alternates file: %w", err)
}
return nil
}(); err != nil {
return fmt.Errorf("failed to make the alternates file in shared repository: %w", err)
}
return nil
}
func (r *SharedRepo) Directory() string {
return r.temporaryPath
}
// SetDefaultIndex sets the git index to our HEAD.
func (r *SharedRepo) SetDefaultIndex(ctx context.Context) error {
cmd := command.New("read-tree", command.WithArg("HEAD"))
if err := cmd.Run(ctx, command.WithDir(r.temporaryPath)); err != nil {
return fmt.Errorf("failed to initialize shared repository index to HEAD: %w", err)
}
return nil
}
// SetIndex sets the git index to the provided treeish.
func (r *SharedRepo) SetIndex(ctx context.Context, treeish string) error {
cmd := command.New("read-tree", command.WithArg(treeish))
if err := cmd.Run(ctx, command.WithDir(r.temporaryPath)); err != nil {
return fmt.Errorf("failed to initialize shared repository index to %q: %w", treeish, err)
}
return nil
}
// ClearIndex clears the git index.
func (r *SharedRepo) ClearIndex(ctx context.Context) error {
cmd := command.New("read-tree", command.WithFlag("--empty"))
if err := cmd.Run(ctx, command.WithDir(r.temporaryPath)); err != nil {
return fmt.Errorf("failed to clear shared repository index: %w", err)
}
return nil
}
// LsFiles checks if the given filename arguments are in the index.
func (r *SharedRepo) LsFiles(
ctx context.Context,
filenames ...string,
) ([]string, error) {
cmd := command.New("ls-files", command.WithFlag("-z"), command.WithPostSepArg(filenames...))
stdout := bytes.NewBuffer(nil)
err := cmd.Run(ctx, command.WithDir(r.temporaryPath), command.WithStdout(stdout))
if err != nil {
return nil, fmt.Errorf("failed to list files in shared repository's git index: %w", err)
}
files := make([]string, 0)
for _, line := range bytes.Split(stdout.Bytes(), []byte{'\000'}) {
files = append(files, string(line))
}
return files, nil
}
// RemoveFilesFromIndex removes the given files from the index.
func (r *SharedRepo) RemoveFilesFromIndex(
ctx context.Context,
filenames ...string,
) error {
cmd := command.New("update-index",
command.WithFlag("--remove"),
command.WithFlag("-z"),
command.WithFlag("--index-info"))
stdin := bytes.NewBuffer(nil)
for _, file := range filenames {
if file != "" {
stdin.WriteString("0 0000000000000000000000000000000000000000\t")
stdin.WriteString(file)
stdin.WriteByte('\000')
}
}
if err := cmd.Run(ctx, command.WithDir(r.temporaryPath), command.WithStdin(stdin)); err != nil {
return fmt.Errorf("failed to update-index in shared repo: %w", err)
}
return nil
}
// WriteGitObject writes the provided content to the object db and returns its hash.
func (r *SharedRepo) WriteGitObject(
ctx context.Context,
content io.Reader,
) (string, error) {
cmd := command.New("hash-object",
command.WithFlag("-w"),
command.WithFlag("--stdin"))
stdout := bytes.NewBuffer(nil)
err := cmd.Run(ctx,
command.WithDir(r.temporaryPath),
command.WithStdin(content),
command.WithStdout(stdout))
if err != nil {
return "", fmt.Errorf("failed to hash-object in shared repo: %w", err)
}
return strings.TrimSpace(stdout.String()), nil
}
// ShowFile dumps show file and write to io.Writer.
func (r *SharedRepo) ShowFile(
ctx context.Context,
filePath string,
rev string,
writer io.Writer,
) error {
file := strings.TrimSpace(rev) + ":" + strings.TrimSpace(filePath)
cmd := command.New("show", command.WithArg(file))
if err := cmd.Run(ctx, command.WithDir(r.temporaryPath), command.WithStdout(writer)); err != nil {
return fmt.Errorf("failed to show file in shared repo: %w", err)
}
return nil
}
// AddObjectToIndex adds the provided object hash to the index with the provided mode and path.
func (r *SharedRepo) AddObjectToIndex(
ctx context.Context,
mode string,
objectHash string,
objectPath string,
) error {
cmd := command.New("update-index",
command.WithFlag("--add"),
command.WithFlag("--replace"),
command.WithFlag("--cacheinfo", mode, objectHash, objectPath))
if err := cmd.Run(ctx, command.WithDir(r.temporaryPath)); err != nil {
if matched, _ := regexp.MatchString(".*Invalid path '.*", err.Error()); matched {
return errors.InvalidArgument("invalid path '%s'", objectPath)
}
return fmt.Errorf("failed to add object to index in shared repo (path=%s): %w", objectPath, err)
}
return nil
}
// WriteTree writes the current index as a tree to the object db and returns its hash.
func (r *SharedRepo) WriteTree(ctx context.Context) (string, error) {
cmd := command.New("write-tree")
stdout := bytes.NewBuffer(nil)
if err := cmd.Run(ctx, command.WithDir(r.temporaryPath), command.WithStdout(stdout)); err != nil {
return "", fmt.Errorf("failed to write-tree in shared repo: %w", err)
}
return strings.TrimSpace(stdout.String()), nil
}
// MergeTree merges commits in git index.
func (r *SharedRepo) MergeTree(
ctx context.Context,
commitMergeBase, commitTarget, commitSource string,
) (string, []string, error) {
cmd := command.New("merge-tree",
command.WithFlag("--write-tree"),
command.WithFlag("--name-only"),
command.WithFlag("--no-messages"),
command.WithArg(commitTarget),
command.WithArg(commitSource))
if commitMergeBase != "" {
cmd.Add(command.WithFlag("--merge-base=" + commitMergeBase))
}
stdout := bytes.NewBuffer(nil)
err := cmd.Run(ctx,
command.WithDir(r.temporaryPath),
command.WithStdout(stdout))
// no error: the output is just the tree object SHA
if err == nil {
return strings.TrimSpace(stdout.String()), nil, nil
}
// exit code=1: the output is the tree object SHA, and list of files in conflict.
if cErr := command.AsError(err); cErr != nil && cErr.ExitCode() == 1 {
output := strings.TrimSpace(stdout.String())
lines := strings.Split(output, "\n")
if len(lines) < 2 {
log.Ctx(ctx).Err(err).Str("output", output).Msg("unexpected output of merge-tree in shared repo")
return "", nil, fmt.Errorf("unexpected output of merge-tree in shared repo: %w", err)
}
return lines[0], lines[1:], nil
}
return "", nil, fmt.Errorf("failed to merge-tree in shared repo: %w", err)
}
// CommitTree creates a commit from a given tree for the user with provided message.
func (r *SharedRepo) CommitTree(
ctx context.Context,
author, committer *types.Signature,
treeHash, message string,
signoff bool,
parentCommits ...string,
) (string, error) {
cmd := command.New("commit-tree",
command.WithArg(treeHash),
command.WithAuthorAndDate(
author.Identity.Name,
author.Identity.Email,
author.When,
),
command.WithCommitterAndDate(
committer.Identity.Name,
committer.Identity.Email,
committer.When,
),
)
for _, parentCommit := range parentCommits {
cmd.Add(command.WithFlag("-p", parentCommit))
}
// temporary no signing
cmd.Add(command.WithFlag("--no-gpg-sign"))
messageBytes := new(bytes.Buffer)
_, _ = messageBytes.WriteString(message)
_, _ = messageBytes.WriteString("\n")
if signoff {
// Signed-off-by
_, _ = messageBytes.WriteString("\n")
_, _ = messageBytes.WriteString("Signed-off-by: ")
_, _ = messageBytes.WriteString(fmt.Sprintf("%s <%s>", committer.Identity.Name, committer.Identity.Email))
}
stdout := bytes.NewBuffer(nil)
err := cmd.Run(ctx,
command.WithDir(r.temporaryPath),
command.WithStdout(stdout),
command.WithStdin(messageBytes))
if err != nil {
return "", fmt.Errorf("failed to commit-tree in shared repo: %w", err)
}
return strings.TrimSpace(stdout.String()), nil
}
// CommitSHAList returns list of SHAs of the commits between the two git revisions.
func (r *SharedRepo) CommitSHAList(
ctx context.Context,
start, end string,
) ([]string, error) {
cmd := command.New("rev-list", command.WithArg(start+".."+end))
stdout := bytes.NewBuffer(nil)
if err := cmd.Run(ctx, command.WithDir(r.temporaryPath), command.WithStdout(stdout)); err != nil {
return nil, fmt.Errorf("failed to rev-list in shared repo: %w", err)
}
var commitSHAs []string
scan := bufio.NewScanner(stdout)
for scan.Scan() {
commitSHA := scan.Text()
commitSHAs = append(commitSHAs, commitSHA)
}
if err := scan.Err(); err != nil {
return nil, fmt.Errorf("failed to scan rev-list output in shared repo: %w", err)
}
return commitSHAs, nil
}
// MergeBase returns number of commits between the two git revisions.
func (r *SharedRepo) MergeBase(
ctx context.Context,
rev1, rev2 string,
) (string, error) {
cmd := command.New("merge-base", command.WithArg(rev1), command.WithArg(rev2))
stdout := bytes.NewBuffer(nil)
if err := cmd.Run(ctx, command.WithDir(r.temporaryPath), command.WithStdout(stdout)); err != nil {
return "", fmt.Errorf("failed to merge-base in shared repo: %w", err)
}
return strings.TrimSpace(stdout.String()), nil
}
// MoveObjects moves git object from the shared repository to the original repository.
func (r *SharedRepo) MoveObjects(ctx context.Context) error {
srcDir := path.Join(r.temporaryPath, "objects")
dstDir := path.Join(r.repositoryPath, "objects")
var files []fileEntry
err := filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
relPath, err := filepath.Rel(srcDir, path)
if err != nil {
return fmt.Errorf("failed to get relative path: %w", err)
}
// avoid coping anything in the info/
if strings.HasPrefix(relPath, "info/") {
return nil
}
fileName := filepath.Base(relPath)
files = append(files, fileEntry{
fileName: fileName,
fullPath: path,
relPath: relPath,
priority: filePriority(fileName),
})
return nil
})
if err != nil {
return fmt.Errorf("failed to list files of shared repository directory: %w", err)
}
sort.Slice(files, func(i, j int) bool {
return files[i].priority < files[j].priority // 0 is top priority, 5 is lowest priority
})
for _, f := range files {
dstPath := filepath.Join(dstDir, f.relPath)
err = os.MkdirAll(filepath.Dir(dstPath), os.ModePerm)
if err != nil {
return fmt.Errorf("failed to create directory for git object: %w", err)
}
// Try to move the file
errRename := os.Rename(f.fullPath, dstPath)
if errRename == nil {
log.Ctx(ctx).Debug().
Str("object", f.relPath).
Msg("moved git object")
continue
}
// Try to copy the file
copyError := func() error {
srcFile, err := os.Open(f.fullPath)
if err != nil {
return fmt.Errorf("failed to open source file: %w", err)
}
defer func() { _ = srcFile.Close() }()
dstFile, err := os.Create(dstPath)
if err != nil {
return fmt.Errorf("failed to create target file: %w", err)
}
defer func() { _ = dstFile.Close() }()
_, err = io.Copy(dstFile, srcFile)
if err != nil {
return fmt.Errorf("failed to copy file content: %w", err)
}
return nil
}()
if copyError != nil {
log.Ctx(ctx).Err(copyError).
Str("object", f.relPath).
Str("renameErr", errRename.Error()).
Msg("failed to move or copy git object")
return fmt.Errorf("failed to move or copy git object: %w", copyError)
}
log.Ctx(ctx).Warn().
Str("object", f.relPath).
Str("renameErr", errRename.Error()).
Msg("copied git object")
}
return nil
}
// filePriority is based on https://github.com/git/git/blob/master/tmp-objdir.c#L168
func filePriority(name string) int {
switch {
case !strings.HasPrefix(name, "pack"):
return 0
case strings.HasSuffix(name, ".keep"):
return 1
case strings.HasSuffix(name, ".pack"):
return 2
case strings.HasSuffix(name, ".rev"):
return 3
case strings.HasSuffix(name, ".idx"):
return 4
default:
return 5
}
}
type fileEntry struct {
fileName string
fullPath string
relPath string
priority int
}