diff options
-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-- | internal/git2go/cherry_pick.go | 33 | ||||
-rw-r--r-- | internal/gitaly/service/operations/cherry_pick.go | 96 |
5 files changed, 442 insertions, 8 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/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/gitaly/service/operations/cherry_pick.go b/internal/gitaly/service/operations/cherry_pick.go index f8e59f675..ac0f9c74c 100644 --- a/internal/gitaly/service/operations/cherry_pick.go +++ b/internal/gitaly/service/operations/cherry_pick.go @@ -2,8 +2,15 @@ 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" @@ -33,5 +40,92 @@ func (s *Server) UserCherryPick(ctx context.Context, req *gitalypb.UserCherryPic } func (s *Server) userCherryPick(ctx context.Context, req *gitalypb.UserCherryPickRequest) (*gitalypb.UserCherryPickResponse, error) { - return nil, nil + 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 } |