diff options
author | James Fargher <proglottis@gmail.com> | 2021-02-18 05:20:23 +0300 |
---|---|---|
committer | James Fargher <proglottis@gmail.com> | 2021-02-18 05:20:23 +0300 |
commit | a467322dcb6865f83d49ae15b0597329bc95cef5 (patch) | |
tree | a6772306fc9faebc21476ba3135ac4ae689b9d3e | |
parent | a53df10474d4a29b0562f973448e129b205c62b1 (diff) | |
parent | 0ff1d165de464430f09cd8b00cf54c9f71273980 (diff) |
Merge branch 'tc-user-cherry-pick-go' into 'master'
Port UserCherryPick to Go
Closes #3071
See merge request gitlab-org/gitaly!2709
-rw-r--r-- | cmd/gitaly-git2go/cherry_pick.go | 113 | ||||
-rw-r--r-- | cmd/gitaly-git2go/cherry_pick_test.go | 193 | ||||
-rw-r--r-- | cmd/gitaly-git2go/main.go | 15 | ||||
-rw-r--r-- | cmd/gitaly-git2go/revert.go | 2 | ||||
-rw-r--r-- | cmd/gitaly-git2go/revert_test.go | 4 | ||||
-rw-r--r-- | internal/git2go/cherry_pick.go | 33 | ||||
-rw-r--r-- | internal/git2go/command.go | 6 | ||||
-rw-r--r-- | internal/git2go/gob.go | 40 | ||||
-rw-r--r-- | internal/git2go/merge.go | 5 | ||||
-rw-r--r-- | internal/git2go/revert.go | 30 | ||||
-rw-r--r-- | internal/gitaly/service/operations/cherry_pick.go | 103 | ||||
-rw-r--r-- | internal/gitaly/service/operations/cherry_pick_test.go | 174 | ||||
-rw-r--r-- | internal/gitaly/service/operations/revert.go | 25 | ||||
-rw-r--r-- | internal/metadata/featureflag/feature_flags.go | 3 |
14 files changed, 665 insertions, 81 deletions
diff --git a/cmd/gitaly-git2go/cherry_pick.go b/cmd/gitaly-git2go/cherry_pick.go new file mode 100644 index 000000000..b0aad7f3a --- /dev/null +++ b/cmd/gitaly-git2go/cherry_pick.go @@ -0,0 +1,113 @@ +// +build static,system_libgit2 + +package main + +import ( + "context" + "encoding/gob" + "errors" + "flag" + "fmt" + "io" + "time" + + git "github.com/libgit2/git2go/v30" + "gitlab.com/gitlab-org/gitaly/internal/git2go" +) + +type cherryPickSubcommand struct{} + +func (cmd *cherryPickSubcommand) Flags() *flag.FlagSet { + return flag.NewFlagSet("cherry-pick", flag.ExitOnError) +} + +func (cmd *cherryPickSubcommand) Run(ctx context.Context, r io.Reader, w io.Writer) error { + var request git2go.CherryPickCommand + if err := gob.NewDecoder(r).Decode(&request); err != nil { + return err + } + + commitID, err := cmd.cherryPick(ctx, &request) + return gob.NewEncoder(w).Encode(git2go.Result{ + CommitID: commitID, + Error: git2go.SerializableError(err), + }) +} + +func (cmd *cherryPickSubcommand) verify(ctx context.Context, r *git2go.CherryPickCommand) error { + if r.Repository == "" { + return errors.New("missing repository") + } + if r.AuthorName == "" { + return errors.New("missing author name") + } + if r.AuthorMail == "" { + return errors.New("missing author mail") + } + if r.Message == "" { + return errors.New("missing message") + } + if r.Ours == "" { + return errors.New("missing ours") + } + if r.Commit == "" { + return errors.New("missing commit") + } + + return nil +} + +func (cmd *cherryPickSubcommand) cherryPick(ctx context.Context, r *git2go.CherryPickCommand) (string, error) { + if err := cmd.verify(ctx, r); err != nil { + return "", err + } + + if r.AuthorDate.IsZero() { + r.AuthorDate = time.Now() + } + + repo, err := git.OpenRepository(r.Repository) + if err != nil { + return "", fmt.Errorf("could not open repository: %w", err) + } + defer repo.Free() + + ours, err := lookupCommit(repo, r.Ours) + if err != nil { + return "", fmt.Errorf("ours commit lookup: %w", err) + } + + pick, err := lookupCommit(repo, r.Commit) + if err != nil { + return "", fmt.Errorf("commit lookup: %w", err) + } + + opts, err := git.DefaultCherrypickOptions() + if err != nil { + return "", fmt.Errorf("could not get default cherry-pick options: %w", err) + } + opts.Mainline = r.Mainline + + index, err := repo.CherrypickCommit(pick, ours, opts) + if err != nil { + return "", fmt.Errorf("could not cherry-pick commit: %w", err) + } + + if index.HasConflicts() { + return "", git2go.HasConflictsError{} + } + + tree, err := index.WriteTreeTo(repo) + if err != nil { + return "", fmt.Errorf("could not write tree: %w", err) + } + + committer := git.Signature(git2go.NewSignature(r.AuthorName, r.AuthorMail, r.AuthorDate)) + + commit, err := repo.CreateCommitFromIds("", &committer, &committer, r.Message, tree, ours.Id()) + if err != nil { + return "", fmt.Errorf("could not create cherry-pick commit: %w", err) + } + + return commit.String(), nil +} diff --git a/cmd/gitaly-git2go/cherry_pick_test.go b/cmd/gitaly-git2go/cherry_pick_test.go new file mode 100644 index 000000000..bda4c16ed --- /dev/null +++ b/cmd/gitaly-git2go/cherry_pick_test.go @@ -0,0 +1,193 @@ +// +build static,system_libgit2 + +package main + +import ( + "errors" + "testing" + "time" + + git "github.com/libgit2/git2go/v30" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + cmdtesthelper "gitlab.com/gitlab-org/gitaly/cmd/gitaly-git2go/testhelper" + "gitlab.com/gitlab-org/gitaly/internal/git2go" + "gitlab.com/gitlab-org/gitaly/internal/gitaly/config" + "gitlab.com/gitlab-org/gitaly/internal/testhelper" +) + +func TestCherryPick_validation(t *testing.T) { + _, repoPath, cleanup := testhelper.NewTestRepo(t) + defer cleanup() + + testcases := []struct { + desc string + request git2go.CherryPickCommand + expectedErr string + }{ + { + desc: "no arguments", + expectedErr: "cherry-pick: missing repository", + }, + { + desc: "missing repository", + request: git2go.CherryPickCommand{AuthorName: "Foo", AuthorMail: "foo@example.com", Message: "Foo", Ours: "HEAD", Commit: "HEAD"}, + expectedErr: "cherry-pick: missing repository", + }, + { + desc: "missing author name", + request: git2go.CherryPickCommand{Repository: repoPath, AuthorMail: "foo@example.com", Message: "Foo", Ours: "HEAD", Commit: "HEAD"}, + expectedErr: "cherry-pick: missing author name", + }, + { + desc: "missing author mail", + request: git2go.CherryPickCommand{Repository: repoPath, AuthorName: "Foo", Message: "Foo", Ours: "HEAD", Commit: "HEAD"}, + expectedErr: "cherry-pick: missing author mail", + }, + { + desc: "missing message", + request: git2go.CherryPickCommand{Repository: repoPath, AuthorName: "Foo", AuthorMail: "foo@example.com", Ours: "HEAD", Commit: "HEAD"}, + expectedErr: "cherry-pick: missing message", + }, + { + desc: "missing ours", + request: git2go.CherryPickCommand{Repository: repoPath, AuthorName: "Foo", AuthorMail: "foo@example.com", Message: "Foo", Commit: "HEAD"}, + expectedErr: "cherry-pick: missing ours", + }, + { + desc: "missing commit", + request: git2go.CherryPickCommand{Repository: repoPath, AuthorName: "Foo", AuthorMail: "foo@example.com", Message: "Foo", Ours: "HEAD"}, + expectedErr: "cherry-pick: missing commit", + }, + } + for _, tc := range testcases { + t.Run(tc.desc, func(t *testing.T) { + ctx, cancel := testhelper.Context() + defer cancel() + + _, err := tc.request.Run(ctx, config.Config) + require.EqualError(t, err, tc.expectedErr) + }) + } +} + +func TestCherryPick(t *testing.T) { + testcases := []struct { + desc string + base map[string]string + ours map[string]string + commit map[string]string + expected map[string]string + expectedCommitID string + expectedErr error + expectedErrMsg string + }{ + { + desc: "trivial cherry-pick succeeds", + base: map[string]string{ + "file": "foo", + }, + ours: map[string]string{ + "file": "foo", + }, + commit: map[string]string{ + "file": "foobar", + }, + expected: map[string]string{ + "file": "foobar", + }, + expectedCommitID: "a6b964c97f96f6e479f602633a43bc83c84e6688", + }, + { + desc: "conflicting cherry-pick fails", + base: map[string]string{ + "file": "foo", + }, + ours: map[string]string{ + "file": "fooqux", + }, + commit: map[string]string{ + "file": "foobar", + }, + expectedErr: git2go.HasConflictsError{}, + expectedErrMsg: "cherry-pick: could not apply due to conflicts", + }, + { + desc: "fails on nonexistent ours commit", + expectedErrMsg: "cherry-pick: ours commit lookup: could not lookup reference \"nonexistent\": revspec 'nonexistent' not found", + }, + { + desc: "fails on nonexistent cherry-pick commit", + ours: map[string]string{ + "file": "fooqux", + }, + expectedErrMsg: "cherry-pick: commit lookup: could not lookup reference \"nonexistent\": revspec 'nonexistent' not found", + }, + } + for _, tc := range testcases { + _, repoPath, cleanup := testhelper.NewTestRepo(t) + defer cleanup() + + base := cmdtesthelper.BuildCommit(t, repoPath, []*git.Oid{nil}, tc.base) + + var ours, commit = "nonexistent", "nonexistent" + if len(tc.ours) > 0 { + ours = cmdtesthelper.BuildCommit(t, repoPath, []*git.Oid{base}, tc.ours).String() + } + if len(tc.commit) > 0 { + commit = cmdtesthelper.BuildCommit(t, repoPath, []*git.Oid{base}, tc.commit).String() + } + + authorDate := time.Date(2021, 1, 17, 14, 45, 51, 0, time.FixedZone("UTC+2", +2*60*60)) + + t.Run(tc.desc, func(t *testing.T) { + ctx, cancel := testhelper.Context() + defer cancel() + + response, err := git2go.CherryPickCommand{ + Repository: repoPath, + AuthorName: "Foo", + AuthorMail: "foo@example.com", + AuthorDate: authorDate, + Message: "Foo", + Ours: ours, + Commit: commit, + }.Run(ctx, config.Config) + + if tc.expectedErrMsg != "" { + require.EqualError(t, err, tc.expectedErrMsg) + + if tc.expectedErr != nil { + require.True(t, errors.Is(err, tc.expectedErr)) + } + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expectedCommitID, response) + + repo, err := git.OpenRepository(repoPath) + require.NoError(t, err) + defer repo.Free() + + commitOid, err := git.NewOid(response) + require.NoError(t, err) + + commit, err := repo.LookupCommit(commitOid) + require.NoError(t, err) + + tree, err := commit.Tree() + require.NoError(t, err) + require.Len(t, tc.expected, int(tree.EntryCount())) + + for name, contents := range tc.expected { + entry := tree.EntryByName(name) + require.NotNil(t, entry) + + blob, err := repo.LookupBlob(entry.Id) + require.NoError(t, err) + require.Equal(t, []byte(contents), blob.Contents()) + } + }) + } +} diff --git a/cmd/gitaly-git2go/main.go b/cmd/gitaly-git2go/main.go index 82dad85a9..ab7e1dfe2 100644 --- a/cmd/gitaly-git2go/main.go +++ b/cmd/gitaly-git2go/main.go @@ -18,13 +18,14 @@ type subcmd interface { } var subcommands = map[string]subcmd{ - "apply": &applySubcommand{}, - "commit": commitSubcommand{}, - "conflicts": &conflicts.Subcommand{}, - "merge": &mergeSubcommand{}, - "revert": &revertSubcommand{}, - "resolve": &resolveSubcommand{}, - "submodule": &submoduleSubcommand{}, + "apply": &applySubcommand{}, + "cherry-pick": &cherryPickSubcommand{}, + "commit": commitSubcommand{}, + "conflicts": &conflicts.Subcommand{}, + "merge": &mergeSubcommand{}, + "revert": &revertSubcommand{}, + "resolve": &resolveSubcommand{}, + "submodule": &submoduleSubcommand{}, } const programName = "gitaly-git2go" diff --git a/cmd/gitaly-git2go/revert.go b/cmd/gitaly-git2go/revert.go index 8ef9dcffc..d3ba942f7 100644 --- a/cmd/gitaly-git2go/revert.go +++ b/cmd/gitaly-git2go/revert.go @@ -82,7 +82,7 @@ func (cmd *revertSubcommand) revert(ctx context.Context, request *git2go.RevertC defer index.Free() if index.HasConflicts() { - return "", git2go.RevertConflictError{} + return "", git2go.HasConflictsError{} } tree, err := index.WriteTreeTo(repo) diff --git a/cmd/gitaly-git2go/revert_test.go b/cmd/gitaly-git2go/revert_test.go index 0010aab0b..06e35ce55 100644 --- a/cmd/gitaly-git2go/revert_test.go +++ b/cmd/gitaly-git2go/revert_test.go @@ -122,8 +122,8 @@ func TestRevert_trees(t *testing.T) { return oursOid.String(), revertOid.String() }, - expectedErr: "revert: could not revert due to conflicts", - expectedErrAs: &git2go.RevertConflictError{}, + expectedErr: "revert: could not apply due to conflicts", + expectedErrAs: &git2go.HasConflictsError{}, }, { desc: "nonexistent ours fails", diff --git a/internal/git2go/cherry_pick.go b/internal/git2go/cherry_pick.go new file mode 100644 index 000000000..71c7c6f06 --- /dev/null +++ b/internal/git2go/cherry_pick.go @@ -0,0 +1,33 @@ +package git2go + +import ( + "context" + "time" + + "gitlab.com/gitlab-org/gitaly/internal/gitaly/config" +) + +// CherryPickCommand contains parameters to perform a cherry pick. +type CherryPickCommand struct { + // Repository is the path where to execute the cherry pick. + Repository string + // AuthorName is the author name for the resulting commit. + AuthorName string + // AuthorMail is the author mail for the resulting commit. + AuthorMail string + // AuthorDate is the author date of revert commit. + AuthorDate time.Time + // Message is the message to be used for the resulting commit. + Message string + // Ours is the commit that the revert is applied to. + Ours string + // Commit is the commit that is to be picked. + Commit string + // Mainline is the parent to be considered the mainline + Mainline uint +} + +// Run performs a cherry pick via gitaly-git2go. +func (m CherryPickCommand) Run(ctx context.Context, cfg config.Cfg) (string, error) { + return runWithGob(ctx, cfg, "cherry-pick", m) +} diff --git a/internal/git2go/command.go b/internal/git2go/command.go index c65665de1..c64b1c899 100644 --- a/internal/git2go/command.go +++ b/internal/git2go/command.go @@ -5,6 +5,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "os/exec" @@ -15,6 +16,11 @@ import ( "gitlab.com/gitlab-org/gitaly/internal/gitaly/config" ) +var ( + // ErrInvalidArgument is returned in case the merge arguments are invalid. + ErrInvalidArgument = errors.New("invalid parameters") +) + func binaryPathFromCfg(cfg config.Cfg) string { return filepath.Join(cfg.BinDir, "gitaly-git2go") } diff --git a/internal/git2go/gob.go b/internal/git2go/gob.go index b400f815b..aa98b7964 100644 --- a/internal/git2go/gob.go +++ b/internal/git2go/gob.go @@ -1,9 +1,14 @@ package git2go import ( + "bytes" + "context" "encoding/gob" "errors" + "fmt" "reflect" + + "gitlab.com/gitlab-org/gitaly/internal/gitaly/config" ) func init() { @@ -24,7 +29,7 @@ var registeredTypes = map[interface{}]struct{}{ FileExistsError(""): {}, FileNotFoundError(""): {}, InvalidArgumentError(""): {}, - RevertConflictError{}: {}, + HasConflictsError{}: {}, } // Result is the serialized result. @@ -47,6 +52,14 @@ func (err wrapError) Error() string { return err.Message } func (err wrapError) Unwrap() error { return err.Err } +// HasConflictsError is used when a change, for example a revert, could not be +// applied due to a conflict. +type HasConflictsError struct{} + +func (err HasConflictsError) Error() string { + return "could not apply due to conflicts" +} + // SerializableError returns an error that is Gob serializable. // Registered types are serialized directly. Unregistered types // are transformed in to an opaque error using their error message. @@ -69,3 +82,28 @@ func SerializableError(err error) error { return err } + +// runWithGob runs the specified gitaly-git2go cmd with the request gob-encoded +// as input and returns the commit ID as string or an error. +func runWithGob(ctx context.Context, cfg config.Cfg, cmd string, request interface{}) (string, error) { + input := &bytes.Buffer{} + if err := gob.NewEncoder(input).Encode(request); err != nil { + return "", fmt.Errorf("%s: %w", cmd, err) + } + + output, err := run(ctx, binaryPathFromCfg(cfg), input, cmd) + if err != nil { + return "", fmt.Errorf("%s: %w", cmd, err) + } + + var result Result + if err := gob.NewDecoder(output).Decode(&result); err != nil { + return "", fmt.Errorf("%s: %w", cmd, err) + } + + if result.Error != nil { + return "", fmt.Errorf("%s: %w", cmd, result.Error) + } + + return result.CommitID, nil +} diff --git a/internal/git2go/merge.go b/internal/git2go/merge.go index e92856810..aca2035f7 100644 --- a/internal/git2go/merge.go +++ b/internal/git2go/merge.go @@ -16,11 +16,6 @@ const ( MergeRecursionLimit = 20 ) -var ( - // ErrInvalidArgument is returned in case the merge arguments are invalid. - ErrInvalidArgument = errors.New("invalid parameters") -) - // MergeCommand contains parameters to perform a merge. type MergeCommand struct { // Repository is the path to execute merge in. diff --git a/internal/git2go/revert.go b/internal/git2go/revert.go index c752abb2d..8e7af30ba 100644 --- a/internal/git2go/revert.go +++ b/internal/git2go/revert.go @@ -1,21 +1,12 @@ package git2go import ( - "bytes" "context" - "encoding/gob" - "fmt" "time" "gitlab.com/gitlab-org/gitaly/internal/gitaly/config" ) -type RevertConflictError struct{} - -func (err RevertConflictError) Error() string { - return "could not revert due to conflicts" -} - type RevertCommand struct { // Repository is the path to execute the revert in. Repository string `json:"repository"` @@ -36,24 +27,5 @@ type RevertCommand struct { } func (r RevertCommand) Run(ctx context.Context, cfg config.Cfg) (string, error) { - input := &bytes.Buffer{} - if err := gob.NewEncoder(input).Encode(r); err != nil { - return "", fmt.Errorf("revert: %w", err) - } - - output, err := run(ctx, binaryPathFromCfg(cfg), input, "revert") - if err != nil { - return "", fmt.Errorf("revert: %w", err) - } - - var result Result - if err := gob.NewDecoder(output).Decode(&result); err != nil { - return "", fmt.Errorf("revert: %w", err) - } - - if result.Error != nil { - return "", fmt.Errorf("revert: %w", result.Error) - } - - return result.CommitID, nil + return runWithGob(ctx, cfg, "revert", r) } diff --git a/internal/gitaly/service/operations/cherry_pick.go b/internal/gitaly/service/operations/cherry_pick.go index 3c406abfd..ac0f9c74c 100644 --- a/internal/gitaly/service/operations/cherry_pick.go +++ b/internal/gitaly/service/operations/cherry_pick.go @@ -2,8 +2,16 @@ package operations import ( "context" + "errors" + "fmt" + "gitlab.com/gitlab-org/gitaly/internal/git" + "gitlab.com/gitlab-org/gitaly/internal/git/localrepo" + "gitlab.com/gitlab-org/gitaly/internal/git2go" "gitlab.com/gitlab-org/gitaly/internal/gitaly/rubyserver" + "gitlab.com/gitlab-org/gitaly/internal/helper" + "gitlab.com/gitlab-org/gitaly/internal/helper/text" + "gitlab.com/gitlab-org/gitaly/internal/metadata/featureflag" "gitlab.com/gitlab-org/gitaly/proto/go/gitalypb" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -14,6 +22,10 @@ func (s *Server) UserCherryPick(ctx context.Context, req *gitalypb.UserCherryPic return nil, status.Errorf(codes.InvalidArgument, "UserCherryPick: %v", err) } + if featureflag.IsEnabled(ctx, featureflag.GoUserCherryPick) { + return s.userCherryPick(ctx, req) + } + client, err := s.ruby.OperationServiceClient(ctx) if err != nil { return nil, err @@ -26,3 +38,94 @@ func (s *Server) UserCherryPick(ctx context.Context, req *gitalypb.UserCherryPic return client.UserCherryPick(clientCtx, req) } + +func (s *Server) userCherryPick(ctx context.Context, req *gitalypb.UserCherryPickRequest) (*gitalypb.UserCherryPickResponse, error) { + startRevision, err := s.fetchStartRevision(ctx, req) + if err != nil { + return nil, err + } + + localRepo := localrepo.New(s.gitCmdFactory, req.Repository, s.cfg) + repoHadBranches, err := localRepo.HasBranches(ctx) + if err != nil { + return nil, err + } + + repoPath, err := s.locator.GetPath(req.Repository) + if err != nil { + return nil, err + } + + var mainline uint + if len(req.Commit.ParentIds) > 1 { + mainline = 1 + } + + newrev, err := git2go.CherryPickCommand{ + Repository: repoPath, + AuthorName: string(req.User.Name), + AuthorMail: string(req.User.Email), + Message: string(req.Message), + Commit: req.Commit.Id, + Ours: startRevision, + Mainline: mainline, + }.Run(ctx, s.cfg) + if err != nil { + switch { + case errors.As(err, &git2go.HasConflictsError{}): + return &gitalypb.UserCherryPickResponse{ + CreateTreeError: err.Error(), + CreateTreeErrorCode: gitalypb.UserCherryPickResponse_CONFLICT, + }, nil + case errors.Is(err, git2go.ErrInvalidArgument): + return nil, helper.ErrInvalidArgument(err) + default: + return nil, helper.ErrInternalf("cherry-pick command: %w", err) + } + } + + branch := "refs/heads/" + text.ChompBytes(req.BranchName) + + branchCreated := false + oldrev, err := localRepo.ResolveRevision(ctx, git.Revision(fmt.Sprintf("%s^{commit}", branch))) + if errors.Is(err, git.ErrReferenceNotFound) { + branchCreated = true + oldrev = git.ZeroOID + } else if err != nil { + return nil, helper.ErrInvalidArgumentf("resolve ref: %w", err) + } + + if req.DryRun { + newrev = startRevision + } + + if !branchCreated { + ancestor, err := s.isAncestor(ctx, req.Repository, oldrev.String(), newrev) + if err != nil { + return nil, err + } + if !ancestor { + return &gitalypb.UserCherryPickResponse{ + CommitError: "Branch diverged", + }, nil + } + } + + if err := s.updateReferenceWithHooks(ctx, req.Repository, req.User, branch, newrev, oldrev.String()); err != nil { + if errors.As(err, &preReceiveError{}) { + return &gitalypb.UserCherryPickResponse{ + PreReceiveError: err.Error(), + }, err + } + + return nil, fmt.Errorf("update reference with hooks: %w", err) + } + + return &gitalypb.UserCherryPickResponse{ + BranchUpdate: &gitalypb.OperationBranchUpdate{ + CommitId: newrev, + BranchCreated: branchCreated, + RepoCreated: !repoHadBranches, + }, + }, nil +} diff --git a/internal/gitaly/service/operations/cherry_pick_test.go b/internal/gitaly/service/operations/cherry_pick_test.go index 1d714c489..59b7085c3 100644 --- a/internal/gitaly/service/operations/cherry_pick_test.go +++ b/internal/gitaly/service/operations/cherry_pick_test.go @@ -2,6 +2,7 @@ package operations import ( "context" + "fmt" "testing" "github.com/golang/protobuf/ptypes/timestamp" @@ -9,16 +10,18 @@ import ( "gitlab.com/gitlab-org/gitaly/internal/git" "gitlab.com/gitlab-org/gitaly/internal/git/localrepo" "gitlab.com/gitlab-org/gitaly/internal/gitaly/config" + "gitlab.com/gitlab-org/gitaly/internal/metadata/featureflag" "gitlab.com/gitlab-org/gitaly/internal/testhelper" "gitlab.com/gitlab-org/gitaly/proto/go/gitalypb" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" ) -func TestSuccessfulUserCherryPickRequest(t *testing.T) { - ctxOuter, cancel := testhelper.Context() - defer cancel() +func TestServer_UserCherryPick_successful(t *testing.T) { + testWithFeature(t, featureflag.GoUserCherryPick, testServerUserCherryPickSuccessful) +} +func testServerUserCherryPickSuccessful(t *testing.T, ctxOuter context.Context) { serverSocketPath, stop := runOperationServiceServer(t) defer stop() @@ -153,6 +156,12 @@ func TestSuccessfulUserCherryPickRequest(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.desc, func(t *testing.T) { md := testhelper.GitalyServersMetadata(t, serverSocketPath) + + mdFromCtx, ok := metadata.FromOutgoingContext(ctxOuter) + if ok { + md = metadata.Join(md, mdFromCtx) + } + ctx := metadata.NewOutgoingContext(ctxOuter, md) response, err := client.UserCherryPick(ctx, testCase.request) @@ -170,8 +179,7 @@ func TestSuccessfulUserCherryPickRequest(t *testing.T) { require.Empty(t, response.CreateTreeErrorCode) if testCase.request.DryRun { - require.Equal(t, masterHeadCommit.Subject, headCommit.Subject) - require.Equal(t, masterHeadCommit.Id, headCommit.Id) + testhelper.ProtoEqual(t, masterHeadCommit, headCommit) } else { require.Equal(t, testCase.request.Message, headCommit.Subject) require.Equal(t, masterHeadCommit.Id, headCommit.ParentIds[0]) @@ -180,14 +188,11 @@ func TestSuccessfulUserCherryPickRequest(t *testing.T) { } } -func TestSuccessfulGitHooksForUserCherryPickRequest(t *testing.T) { - ctx, cancel := testhelper.Context() - defer cancel() - - testSuccessfulGitHooksForUserCherryPickRequest(t, ctx) +func TestServer_UserCherryPick_successful_git_hooks(t *testing.T) { + testWithFeature(t, featureflag.GoUserCherryPick, testServerUserCherryPickSuccessfulGitHooks) } -func testSuccessfulGitHooksForUserCherryPickRequest(t *testing.T, ctxOuter context.Context) { +func testServerUserCherryPickSuccessfulGitHooks(t *testing.T, ctxOuter context.Context) { serverSocketPath, stop := runOperationServiceServer(t) defer stop() @@ -232,7 +237,11 @@ func testSuccessfulGitHooksForUserCherryPickRequest(t *testing.T, ctxOuter conte } } -func TestUserCherryPick_stableID(t *testing.T) { +func TestServer_UserCherryPick_stableID(t *testing.T) { + testWithFeature(t, featureflag.GoUserCherryPick, testServerUserCherryPickStableID) +} + +func testServerUserCherryPickStableID(t *testing.T, ctx context.Context) { serverSocketPath, stop := runOperationServiceServer(t) defer stop() @@ -243,9 +252,6 @@ func TestUserCherryPick_stableID(t *testing.T) { defer cleanup() repo := localrepo.New(git.NewExecCommandFactory(config.Config), repoProto, config.Config) - ctx, cancel := testhelper.Context() - defer cancel() - destinationBranch := "cherry-picking-dst" testhelper.MustRunCommand(t, nil, "git", "-C", repoPath, "branch", destinationBranch, "master") @@ -297,10 +303,11 @@ func TestUserCherryPick_stableID(t *testing.T) { }, pickedCommit) } -func TestFailedUserCherryPickRequestDueToValidations(t *testing.T) { - ctxOuter, cancel := testhelper.Context() - defer cancel() +func TestServer_UserCherryPick_failed_validations(t *testing.T) { + testWithFeature(t, featureflag.GoUserCherryPick, testServerUserCherryPickFailedValidations) +} +func testServerUserCherryPickFailedValidations(t *testing.T, ctxOuter context.Context) { serverSocketPath, stop := runOperationServiceServer(t) defer stop() @@ -378,10 +385,11 @@ func TestFailedUserCherryPickRequestDueToValidations(t *testing.T) { } } -func TestFailedUserCherryPickRequestDueToPreReceiveError(t *testing.T) { - ctxOuter, cancel := testhelper.Context() - defer cancel() +func TestServer_UserCherryPick_failed_with_PreReceiveError(t *testing.T) { + testWithFeature(t, featureflag.GoUserCherryPick, testServerUserCherryPickFailedWithPreReceiveError) +} +func testServerUserCherryPickFailedWithPreReceiveError(t *testing.T, ctxOuter context.Context) { serverSocketPath, stop := runOperationServiceServer(t) defer stop() @@ -423,10 +431,11 @@ func TestFailedUserCherryPickRequestDueToPreReceiveError(t *testing.T) { } } -func TestFailedUserCherryPickRequestDueToCreateTreeError(t *testing.T) { - ctxOuter, cancel := testhelper.Context() - defer cancel() +func TestServer_UserCherryPick_failed_with_CreateTreeError(t *testing.T) { + testWithFeature(t, featureflag.GoUserCherryPick, testServerUserCherryPickFailedWithCreateTreeError) +} +func testServerUserCherryPickFailedWithCreateTreeError(t *testing.T, ctxOuter context.Context) { serverSocketPath, stop := runOperationServiceServer(t) defer stop() @@ -461,10 +470,11 @@ func TestFailedUserCherryPickRequestDueToCreateTreeError(t *testing.T) { require.Equal(t, gitalypb.UserCherryPickResponse_EMPTY, response.CreateTreeErrorCode) } -func TestFailedUserCherryPickRequestDueToCommitError(t *testing.T) { - ctxOuter, cancel := testhelper.Context() - defer cancel() +func TestServer_UserCherryPick_failed_with_CommitError(t *testing.T) { + testWithFeature(t, featureflag.GoUserCherryPick, testServerUserCherryPickFailedWithCommitError) +} +func testServerUserCherryPickFailedWithCommitError(t *testing.T, ctxOuter context.Context) { serverSocketPath, stop := runOperationServiceServer(t) defer stop() @@ -499,3 +509,113 @@ func TestFailedUserCherryPickRequestDueToCommitError(t *testing.T) { require.NoError(t, err) require.Equal(t, "Branch diverged", response.CommitError) } + +func TestServer_UserCherryPick_failed_with_conflict(t *testing.T) { + testWithFeature(t, featureflag.GoUserCherryPick, testServerUserCherryPickFailedWithConflict) +} + +func testServerUserCherryPickFailedWithConflict(t *testing.T, ctxOuter context.Context) { + serverSocketPath, stop := runOperationServiceServer(t) + defer stop() + + client, conn := newOperationClient(t, serverSocketPath) + defer conn.Close() + + repoProto, repoPath, cleanup := testhelper.NewTestRepo(t) + defer cleanup() + repo := localrepo.New(git.NewExecCommandFactory(config.Config), repoProto, config.Config) + + destinationBranch := "cherry-picking-dst" + testhelper.MustRunCommand(t, nil, "git", "-C", repoPath, "branch", destinationBranch, "conflict_branch_a") + + // This commit cannot be applied to the destinationBranch above + cherryPickedCommit, err := repo.ReadCommit(ctxOuter, git.Revision("f0f390655872bb2772c85a0128b2fbc2d88670cb")) + require.NoError(t, err) + + request := &gitalypb.UserCherryPickRequest{ + Repository: repoProto, + User: testhelper.TestUser, + Commit: cherryPickedCommit, + BranchName: []byte(destinationBranch), + Message: []byte("Cherry-picking " + cherryPickedCommit.Id), + } + + md := testhelper.GitalyServersMetadata(t, serverSocketPath) + ctx := testhelper.MergeOutgoingMetadata(ctxOuter, md) + + response, err := client.UserCherryPick(ctx, request) + require.NoError(t, err) + require.NotEmpty(t, response.CreateTreeError) + require.Equal(t, gitalypb.UserCherryPickResponse_CONFLICT, response.CreateTreeErrorCode) +} + +func TestServer_UserCherryPick_successful_with_given_commits(t *testing.T) { + testWithFeature(t, featureflag.GoUserCherryPick, testServerUserCherryPickSuccessfulWithGivenCommits) +} + +func testServerUserCherryPickSuccessfulWithGivenCommits(t *testing.T, ctxOuter context.Context) { + serverSocketPath, stop := runOperationServiceServer(t) + defer stop() + + client, conn := newOperationClient(t, serverSocketPath) + defer conn.Close() + + repoProto, repoPath, cleanup := testhelper.NewTestRepo(t) + defer cleanup() + repo := localrepo.New(git.NewExecCommandFactory(config.Config), repoProto, config.Config) + + testCases := []struct { + desc string + startRevision git.Revision + cherryRevision git.Revision + }{ + { + desc: "merge commit", + startRevision: "281d3a76f31c812dbf48abce82ccf6860adedd81", + cherryRevision: "6907208d755b60ebeacb2e9dfea74c92c3449a1f", + }, + } + + for i, testCase := range testCases { + t.Run(testCase.desc, func(t *testing.T) { + md := testhelper.GitalyServersMetadata(t, serverSocketPath) + + mdFromCtx, ok := metadata.FromOutgoingContext(ctxOuter) + if ok { + md = metadata.Join(md, mdFromCtx) + } + + ctx := metadata.NewOutgoingContext(ctxOuter, md) + + destinationBranch := fmt.Sprintf("cherry-picking-%d", i) + + testhelper.MustRunCommand(t, nil, "git", "-C", repoPath, "branch", destinationBranch, testCase.startRevision.String()) + + commit, err := repo.ReadCommit(ctxOuter, testCase.cherryRevision) + require.NoError(t, err) + + request := &gitalypb.UserCherryPickRequest{ + Repository: repoProto, + User: testhelper.TestUser, + Commit: commit, + BranchName: []byte(destinationBranch), + Message: []byte("Cherry-picking " + testCase.cherryRevision.String()), + } + + response, err := client.UserCherryPick(ctx, request) + require.NoError(t, err) + + newHead, err := repo.ReadCommit(ctx, git.Revision(destinationBranch)) + require.NoError(t, err) + + expectedResponse := &gitalypb.UserCherryPickResponse{ + BranchUpdate: &gitalypb.OperationBranchUpdate{CommitId: newHead.Id}, + } + + testhelper.ProtoEqual(t, expectedResponse, response) + + require.Equal(t, request.Message, newHead.Subject) + require.Equal(t, testCase.startRevision.String(), newHead.ParentIds[0]) + }) + } +} diff --git a/internal/gitaly/service/operations/revert.go b/internal/gitaly/service/operations/revert.go index 8f8780a7d..bde78398e 100644 --- a/internal/gitaly/service/operations/revert.go +++ b/internal/gitaly/service/operations/revert.go @@ -65,7 +65,7 @@ func (s *Server) UserRevert(ctx context.Context, req *gitalypb.UserRevertRequest Mainline: mainline, }.Run(ctx, s.cfg) if err != nil { - if errors.As(err, &git2go.RevertConflictError{}) { + if errors.As(err, &git2go.HasConflictsError{}) { return &gitalypb.UserRevertResponse{ CreateTreeError: err.Error(), CreateTreeErrorCode: gitalypb.UserRevertResponse_CONFLICT, @@ -138,15 +138,22 @@ func (s *Server) rubyUserRevert(ctx context.Context, req *gitalypb.UserRevertReq return client.UserRevert(clientCtx, req) } -func (s *Server) fetchStartRevision(ctx context.Context, req *gitalypb.UserRevertRequest) (string, error) { - startBranchName := req.StartBranchName +type requestFetchingStartRevision interface { + GetRepository() *gitalypb.Repository + GetBranchName() []byte + GetStartRepository() *gitalypb.Repository + GetStartBranchName() []byte +} + +func (s *Server) fetchStartRevision(ctx context.Context, req requestFetchingStartRevision) (string, error) { + startBranchName := req.GetStartBranchName() if len(startBranchName) == 0 { - startBranchName = req.BranchName + startBranchName = req.GetBranchName() } - startRepository := req.StartRepository + startRepository := req.GetStartRepository() if startRepository == nil { - startRepository = req.Repository + startRepository = req.GetRepository() } remote, err := remoterepo.New(ctx, startRepository, s.conns) @@ -158,13 +165,13 @@ func (s *Server) fetchStartRevision(ctx context.Context, req *gitalypb.UserRever return "", helper.ErrInvalidArgumentf("resolve start ref: %w", err) } - if req.StartRepository == nil { + if req.GetStartRepository() == nil { return startRevision.String(), nil } - _, err = localrepo.New(s.gitCmdFactory, req.Repository, s.cfg).ResolveRevision(ctx, startRevision.Revision()+"^{commit}") + _, err = localrepo.New(s.gitCmdFactory, req.GetRepository(), s.cfg).ResolveRevision(ctx, startRevision.Revision()+"^{commit}") if errors.Is(err, git.ErrReferenceNotFound) { - if err := s.fetchRemoteObject(ctx, req.Repository, req.StartRepository, startRevision.String()); err != nil { + if err := s.fetchRemoteObject(ctx, req.GetRepository(), req.GetStartRepository(), startRevision.String()); err != nil { return "", helper.ErrInternalf("fetch start: %w", err) } } else if err != nil { diff --git a/internal/metadata/featureflag/feature_flags.go b/internal/metadata/featureflag/feature_flags.go index ecfc7d07a..096d0ddf3 100644 --- a/internal/metadata/featureflag/feature_flags.go +++ b/internal/metadata/featureflag/feature_flags.go @@ -17,6 +17,8 @@ var ( LogCommandStats = FeatureFlag{Name: "log_command_stats", OnByDefault: false} // GoUserFFBranch enables the Go implementation of UserFFBranch GoUserFFBranch = FeatureFlag{Name: "go_user_ff_branch", OnByDefault: true} + // GoUserCherryPick enables the Go implementation of UserCherryPick + GoUserCherryPick = FeatureFlag{Name: "go_user_cherry_pick", OnByDefault: false} // GoUserUpdateBranch enables the Go implementation of UserUpdateBranch GoUserUpdateBranch = FeatureFlag{Name: "go_user_update_branch", OnByDefault: false} // GoUserCommitFiles enables the Go implementation of UserCommitFiles @@ -103,6 +105,7 @@ var All = []FeatureFlag{ LogCommandStats, ReferenceTransactions, GoUserFFBranch, + GoUserCherryPick, GoUserUpdateBranch, GoUserCommitFiles, GoResolveConflicts, |