internal/gitaly/service/conflicts/resolve_conflicts.go (343 lines of code) (raw):
package conflicts
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"path/filepath"
"sort"
"strings"
"gitlab.com/gitlab-org/gitaly/v16/internal/git"
"gitlab.com/gitlab-org/gitaly/v16/internal/git/conflict"
"gitlab.com/gitlab-org/gitaly/v16/internal/git/localrepo"
"gitlab.com/gitlab-org/gitaly/v16/internal/git/remoterepo"
"gitlab.com/gitlab-org/gitaly/v16/internal/gitaly/storage"
"gitlab.com/gitlab-org/gitaly/v16/internal/structerr"
"gitlab.com/gitlab-org/gitaly/v16/proto/go/gitalypb"
)
func (s *server) ResolveConflicts(stream gitalypb.ConflictsService_ResolveConflictsServer) error {
firstRequest, err := stream.Recv()
if err != nil {
return err
}
header := firstRequest.GetHeader()
if header == nil {
return structerr.NewInvalidArgument("empty ResolveConflictsRequestHeader")
}
if err = validateResolveConflictsHeader(stream.Context(), s.locator, header); err != nil {
return structerr.NewInvalidArgument("%w", err)
}
err = s.resolveConflicts(header, stream)
return s.handleResolveConflictsErr(err, stream)
}
func (s *server) handleResolveConflictsErr(err error, stream gitalypb.ConflictsService_ResolveConflictsServer) error {
var errStr string // normalized error message
if err != nil {
errStr = strings.TrimPrefix(err.Error(), "resolve: ") // remove subcommand artifact
errStr = strings.TrimSpace(errStr) // remove newline artifacts
// only send back resolution errors that match expected pattern
for _, p := range []string{
"Missing resolution for section ID:",
"Resolved content has no changes for file",
"Missing resolutions for the following files:",
} {
if strings.HasPrefix(errStr, p) {
// log the error since the interceptor won't catch this
// error due to the unique way the RPC is defined to
// handle resolution errors
s.logger.
WithError(err).
ErrorContext(stream.Context(), "ResolveConflicts: unable to resolve conflict")
return stream.SendAndClose(&gitalypb.ResolveConflictsResponse{
ResolutionError: errStr,
})
}
}
return err
}
return stream.SendAndClose(&gitalypb.ResolveConflictsResponse{})
}
func validateResolveConflictsHeader(ctx context.Context, locator storage.Locator, header *gitalypb.ResolveConflictsRequestHeader) error {
if header.GetOurCommitOid() == "" {
return errors.New("empty OurCommitOid")
}
if err := locator.ValidateRepository(ctx, header.GetRepository()); err != nil {
return err
}
if header.GetTargetRepository() == nil {
return errors.New("empty TargetRepository")
}
if header.GetTheirCommitOid() == "" {
return errors.New("empty TheirCommitOid")
}
if header.GetSourceBranch() == nil {
return errors.New("empty SourceBranch")
}
if header.GetTargetBranch() == nil {
return errors.New("empty TargetBranch")
}
if header.GetCommitMessage() == nil {
return errors.New("empty CommitMessage")
}
if header.GetUser() == nil {
return errors.New("empty User")
}
return nil
}
func (s *server) resolveConflicts(header *gitalypb.ResolveConflictsRequestHeader, stream gitalypb.ConflictsService_ResolveConflictsServer) error {
authorSignature, err := git.SignatureFromRequest(header)
if err != nil {
return structerr.NewInvalidArgument("%w", err)
}
b := bytes.NewBuffer(nil)
for {
req, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return err
}
if _, err := b.Write(req.GetFilesJson()); err != nil {
return err
}
}
var checkKeys []map[string]interface{}
if err := json.Unmarshal(b.Bytes(), &checkKeys); err != nil {
return err
}
for _, ck := range checkKeys {
_, sectionExists := ck["sections"]
_, contentExists := ck["content"]
if !sectionExists && !contentExists {
return structerr.NewInvalidArgument("missing sections or content for a resolution")
}
}
var resolutions []conflict.Resolution
if err := json.Unmarshal(b.Bytes(), &resolutions); err != nil {
return err
}
ctx := stream.Context()
targetRepo, err := remoterepo.New(ctx, header.GetTargetRepository(), s.pool)
if err != nil {
return err
}
quarantineDir, quarantineRepo, err := s.quarantinedRepo(ctx, header.GetRepository())
if err != nil {
return err
}
if err := s.repoWithBranchCommit(ctx,
quarantineRepo,
targetRepo,
header.GetTargetBranch(),
); err != nil {
return err
}
objectHash, err := quarantineRepo.ObjectHash(ctx)
if err != nil {
return fmt.Errorf("detecting object hash: %w", err)
}
if objectHash.ValidateHex(header.GetOurCommitOid()) != nil ||
objectHash.ValidateHex(header.GetTheirCommitOid()) != nil {
return errors.New("Rugged::InvalidError: unable to parse OID - contains invalid characters")
}
commitOID, err := s.resolveConflictsWithGit(
ctx,
header.GetOurCommitOid(),
header.GetTheirCommitOid(),
quarantineRepo,
resolutions,
authorSignature,
header.GetCommitMessage(),
)
if err != nil {
return err
}
if err := s.updater.UpdateReference(
ctx,
header.GetRepository(),
header.GetUser(),
quarantineDir,
git.ReferenceName("refs/heads/"+string(header.GetSourceBranch())),
commitOID,
git.ObjectID(header.GetOurCommitOid()),
); err != nil {
return err
}
return nil
}
func (s *server) resolveConflictsWithGit(
ctx context.Context,
ours, theirs string,
repo *localrepo.Repo,
resolutions []conflict.Resolution,
author git.Signature,
commitMessage []byte,
) (git.ObjectID, error) {
treeOID, err := repo.MergeTree(ctx, ours, theirs, localrepo.WithAllowUnrelatedHistories())
var mergeConflictErr *localrepo.MergeTreeConflictError
if errors.As(err, &mergeConflictErr) {
conflictedFiles := mergeConflictErr.ConflictedFiles()
checkedConflictedFiles := make(map[string]bool)
for _, conflictedFile := range conflictedFiles {
checkedConflictedFiles[conflictedFile] = false
}
tree, err := repo.ReadTree(ctx, treeOID.Revision(), localrepo.WithRecursive())
if err != nil {
return "", structerr.NewInternal("getting tree: %w", err)
}
objectReader, cancel, err := s.catfileCache.ObjectReader(ctx, repo)
if err != nil {
return "", structerr.NewInternal("getting objectreader: %w", err)
}
defer cancel()
for _, resolution := range resolutions {
path := resolution.NewPath
if _, ok := checkedConflictedFiles[path]; !ok {
// Note: this emulates the Ruby error that occurs when
// there are no conflicts for a resolution
return "", errors.New("NoMethodError: undefined method `resolve_lines' for nil:NilClass")
}
// We mark the file as checked, any remaining files, which don't have a resolution
// associated, will throw an error.
checkedConflictedFiles[path] = true
conflictedBlob, err := tree.Get(path)
if err != nil {
return "", structerr.NewInternal("path not found in merged-tree: %w", err)
}
if conflictedBlob.Type != localrepo.Blob {
return "", structerr.NewInternal("entry should be of type blob").
WithMetadataItems(
structerr.MetadataItem{Key: "path", Value: path},
structerr.MetadataItem{Key: "type", Value: conflictedBlob.Type},
)
}
// We first read the object completely to see if the content is similar
// to the content in the resolution.
if resolution.Content != "" {
object, err := objectReader.Object(ctx, conflictedBlob.OID.Revision())
if err != nil {
return "", structerr.NewInternal("retrieving object: %w", err)
}
content, err := io.ReadAll(object)
if err != nil {
return "", structerr.NewInternal("reading object: %w", err)
}
// Git2Go conflict markers have filenames and git-merge-tree(1) has commit OIDs.
// Rails uses the older form, so to check if the content is the same, we need to
// adhere to this.
//
// Should be fixed with: https://gitlab.com/gitlab-org/git/-/issues/168
content = bytes.ReplaceAll(content, []byte(ours), []byte(resolution.OldPath))
content = bytes.ReplaceAll(content, []byte(theirs), []byte(resolution.NewPath))
if bytes.Equal([]byte(resolution.Content), content) {
// This is to keep the error consistent with git2go implementation
return "", structerr.NewInvalidArgument("Resolved content has no changes for file %s", path)
}
}
object, err := objectReader.Object(ctx, git.Revision(fmt.Sprintf("%s:%s", ours, resolution.OldPath)))
if err != nil {
return "", structerr.NewInternal("retrieving object: %w", err)
}
// Rails expects files ending with newlines to retain them post conflict, but
// git swallows ending newlines. So we manually append them if necessary.
needsNewLine := false
oursContent, err := io.ReadAll(object)
if err != nil {
return "", structerr.NewInternal("reading object: %w", err)
}
if len(oursContent) > 0 {
needsNewLine = oursContent[len(oursContent)-1] == '\n'
}
object, err = objectReader.Object(ctx, conflictedBlob.OID.Revision())
if err != nil {
return "", structerr.NewInternal("retrieving object: %w", err)
}
resolvedContent, err := conflict.Resolve(object, git.ObjectID(ours), git.ObjectID(theirs), path, resolution, needsNewLine)
if err != nil {
// If there are delimeters still present in the conflict resolution
// this means the client hasn't addressed all conflicts.
if errors.Is(err, conflict.ErrUnexpectedDelimiter) {
return "", structerr.NewInvalidArgument("%w", err)
}
return "", structerr.NewInternal("%w", err)
}
blobOID, err := repo.WriteBlob(ctx, resolvedContent, localrepo.WriteBlobConfig{
Path: filepath.Base(path),
})
if err != nil {
return "", structerr.NewInternal("writing blob: %w", err)
}
err = tree.Add(path, localrepo.TreeEntry{
OID: blobOID,
Mode: conflictedBlob.Mode,
Path: filepath.Base(path),
Type: localrepo.Blob,
}, localrepo.WithOverwriteFile())
if err != nil {
return "", structerr.NewInternal("add to tree: %w", err)
}
}
for conflictedFile, checked := range checkedConflictedFiles {
if !checked {
return "", fmt.Errorf("Missing resolutions for the following files: %s", conflictedFile) // this is to stay consistent with rugged-rails error
}
}
err = tree.Write(ctx, repo)
if err != nil {
return "", structerr.NewInternal("write tree: %w", err)
}
treeOID = tree.OID
} else if err != nil {
return "", structerr.NewInternal("merge-tree: %w", err)
}
commitOID, err := repo.WriteCommit(ctx, localrepo.WriteCommitConfig{
Parents: []git.ObjectID{git.ObjectID(ours), git.ObjectID(theirs)},
CommitterDate: author.When,
CommitterEmail: author.Email,
CommitterName: author.Name,
AuthorDate: author.When,
AuthorEmail: author.Email,
AuthorName: author.Name,
Message: string(commitMessage),
TreeID: treeOID,
})
if err != nil {
return "", structerr.NewInternal("writing commit: %w", err)
}
return commitOID, nil
}
func sameRepo(left, right storage.Repository) bool {
lgaod := left.GetGitAlternateObjectDirectories()
rgaod := right.GetGitAlternateObjectDirectories()
if len(lgaod) != len(rgaod) {
return false
}
sort.Strings(lgaod)
sort.Strings(rgaod)
for i := 0; i < len(lgaod); i++ {
if lgaod[i] != rgaod[i] {
return false
}
}
if left.GetGitObjectDirectory() != right.GetGitObjectDirectory() {
return false
}
if left.GetRelativePath() != right.GetRelativePath() {
return false
}
if left.GetStorageName() != right.GetStorageName() {
return false
}
return true
}
// repoWithCommit ensures that the source repo contains the same commit we
// hope to merge with from the target branch, else it will be fetched from the
// target repo. This is necessary since all merge/resolve logic occurs on the
// same filesystem
func (s *server) repoWithBranchCommit(ctx context.Context, sourceRepo *localrepo.Repo, targetRepo *remoterepo.Repo, targetBranch []byte) error {
const peelCommit = "^{commit}"
targetRevision := "refs/heads/" + git.Revision(string(targetBranch)) + peelCommit
if sameRepo(sourceRepo, targetRepo) {
_, err := sourceRepo.ResolveRevision(ctx, targetRevision)
return err
}
oid, err := targetRepo.ResolveRevision(ctx, targetRevision)
if err != nil {
return fmt.Errorf("could not resolve target revision %q: %w", targetRevision, err)
}
ok, err := sourceRepo.HasRevision(ctx, git.Revision(oid)+peelCommit)
if err != nil {
return err
}
if ok {
// target branch commit already exists in source repo; nothing
// to do
return nil
}
if err := sourceRepo.FetchInternal(
ctx,
targetRepo.Repository,
[]string{oid.String()},
localrepo.FetchOpts{Tags: localrepo.FetchOptsTagsNone},
); err != nil {
return fmt.Errorf("could not fetch target commit: %w", err)
}
return nil
}