diff options
author | Patrick Steinhardt <psteinhardt@gitlab.com> | 2023-08-16 10:01:19 +0300 |
---|---|---|
committer | Patrick Steinhardt <psteinhardt@gitlab.com> | 2023-08-16 10:01:19 +0300 |
commit | bdf67f3a5687a6be53eb33ce42f78df3ea011cc9 (patch) | |
tree | ce70487f19f3ee3c4e0dc88269939d1550a40c1b | |
parent | 0f25c6473acdcbefa4b668096108c07e5bb9626f (diff) | |
parent | a7f9db080b1eee2e5a986acec9d1228243144359 (diff) |
Merge branch 'xx/user-rebase-pure-git' into 'master'
operations: Implement UserRebaseConfirmable in pure git
Closes #4579
See merge request https://gitlab.com/gitlab-org/gitaly/-/merge_requests/6176
Merged-by: Patrick Steinhardt <psteinhardt@gitlab.com>
Approved-by: Patrick Steinhardt <psteinhardt@gitlab.com>
Reviewed-by: Patrick Steinhardt <psteinhardt@gitlab.com>
Reviewed-by: Xing Xin <moweng.xx@alibaba-inc.com>
Reviewed-by: John Cai <jcai@gitlab.com>
Co-authored-by: Xing Xin <xingxin.xx@bytedance.com>
-rw-r--r-- | internal/featureflag/ff_user_rebase_confirmable_pure_git.go | 10 | ||||
-rw-r--r-- | internal/git/localrepo/rebase.go | 249 | ||||
-rw-r--r-- | internal/git/localrepo/rebase_test.go | 1019 | ||||
-rw-r--r-- | internal/git/signature.go | 27 | ||||
-rw-r--r-- | internal/git/signature_test.go | 57 | ||||
-rw-r--r-- | internal/git2go/signature.go | 28 | ||||
-rw-r--r-- | internal/gitaly/service/operations/rebase_confirmable.go (renamed from internal/gitaly/service/operations/rebase.go) | 203 | ||||
-rw-r--r-- | internal/gitaly/service/operations/rebase_confirmable_test.go (renamed from internal/gitaly/service/operations/rebase_test.go) | 341 | ||||
-rw-r--r-- | internal/gitaly/service/operations/rebase_to_ref.go | 116 | ||||
-rw-r--r-- | internal/gitaly/service/operations/rebase_to_ref_test.go | 237 |
10 files changed, 1898 insertions, 389 deletions
diff --git a/internal/featureflag/ff_user_rebase_confirmable_pure_git.go b/internal/featureflag/ff_user_rebase_confirmable_pure_git.go new file mode 100644 index 000000000..4ee2df0a8 --- /dev/null +++ b/internal/featureflag/ff_user_rebase_confirmable_pure_git.go @@ -0,0 +1,10 @@ +package featureflag + +// UserRebaseConfirmablePureGit will enable the UserRebaseConfirmable RPC to +// use a pure git implemented rebase instead of git2go. +var UserRebaseConfirmablePureGit = NewFeatureFlag( + "user_rebase_confirmable_pure_git", + "v16.3.0", + "https://gitlab.com/gitlab-org/gitaly/-/issues/5493", + false, +) diff --git a/internal/git/localrepo/rebase.go b/internal/git/localrepo/rebase.go new file mode 100644 index 000000000..fc93001ac --- /dev/null +++ b/internal/git/localrepo/rebase.go @@ -0,0 +1,249 @@ +package localrepo + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "strings" + "time" + + "gitlab.com/gitlab-org/gitaly/v16/internal/git" + "gitlab.com/gitlab-org/gitaly/v16/internal/helper/text" + "gitlab.com/gitlab-org/gitaly/v16/internal/structerr" + "gitlab.com/gitlab-org/gitaly/v16/proto/go/gitalypb" +) + +type rebaseConfig struct { + committer *git.Signature +} + +// RebaseOption is a function that sets a config in rebaseConfig. +type RebaseOption func(*rebaseConfig) + +// RebaseWithCommitter provides a signature to be used as the committer for +// generated commits during rebase. +func RebaseWithCommitter(committer git.Signature) RebaseOption { + return func(options *rebaseConfig) { + options.committer = &committer + } +} + +// Rebase implements a basic support for rebase using git-merge-tree(1), it +// follows what git-rebase(1) does but omits the abundant options. +// Our rebase roughly follows the core logic of git-rebase itself. Specifically, +// we check if fast-forward is possible firstly, if not, we +// 1. generate a *pick* only todo_list using git-rev-list +// 2. process the todo_list using git-merge-tree based cherry-pick +func (repo *Repo) Rebase(ctx context.Context, upstream, branch string, options ...RebaseOption) (git.ObjectID, error) { + var config rebaseConfig + + for _, option := range options { + option(&config) + } + + upstreamOID, err := repo.ResolveRevision(ctx, git.Revision(upstream+"^{commit}")) + if err != nil { + return "", structerr.NewInvalidArgument("resolving upstream commit: %w", err).WithMetadata("revision", upstream) + } + + branchOID, err := repo.ResolveRevision(ctx, git.Revision(branch+"^{commit}")) + if err != nil { + return "", structerr.NewInvalidArgument("resolving branch commit: %w", err).WithMetadata("revision", branch) + } + + var stdout bytes.Buffer + if err := repo.ExecAndWait(ctx, + git.Command{ + Name: "merge-base", + Args: []string{upstreamOID.String(), branchOID.String()}, + }, + git.WithStdout(&stdout), + ); err != nil { + return "", structerr.NewInternal("get merge-base: %w", err) + } + + // fast-forward if possible + mergeBase := text.ChompBytes(stdout.Bytes()) + if mergeBase == upstreamOID.String() { + return branchOID, nil + } + if mergeBase == branchOID.String() { + return upstreamOID, nil + } + + newOID, err := repo.rebaseUsingMergeTree(ctx, config, upstreamOID, branchOID) + if err != nil { + return "", structerr.NewInternal("rebase using merge-tree: %w", err) + } + + return newOID, nil +} + +func (repo *Repo) rebaseUsingMergeTree(ctx context.Context, cfg rebaseConfig, upstreamOID, branchOID git.ObjectID) (git.ObjectID, error) { + objectHash, err := repo.ObjectHash(ctx) + if err != nil { + return "", structerr.NewInternal("getting object hash %w", err) + } + + // Flags of git-rev-list to get the pick-only todo_list for a rebase. + // Currently we drop clean cherry-picks and merge commits, which is + // also what git2go does. + // The flags are inferred from https://github.com/git/git/blob/v2.41.0/sequencer.c#L5704-L5714 + flags := []git.Option{ + git.Flag{Name: "--cherry-pick"}, + git.Flag{Name: "--right-only"}, + git.Flag{Name: "--no-merges"}, + git.Flag{Name: "--topo-order"}, + git.Flag{Name: "--reverse"}, + } + + var stderr bytes.Buffer + cmd, err := repo.Exec(ctx, git.Command{ + Name: "rev-list", + Flags: flags, + // The notation "<upstream>...<branch>" is used to calculate the symmetric + // difference between upstream and branch. It will return the commits that + // are reachable exclusively from either side but not both. Combined with + // the provided --right-only flag, the result should be only commits which + // exist on the branch that is to be rebased. + Args: []string{fmt.Sprintf("%s...%s", upstreamOID, branchOID)}, + }, git.WithStderr(&stderr)) + if err != nil { + return "", structerr.NewInternal("start git rev-list: %w", err) + } + + var todoList []string + scanner := bufio.NewScanner(cmd) + for scanner.Scan() { + todoList = append(todoList, strings.TrimSpace(scanner.Text())) + } + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("scanning rev-list output: %w", err) + } + if err := cmd.Wait(); err != nil { + return "", structerr.NewInternal("git rev-list: %w", err).WithMetadata("stderr", stderr.String()) + } + + upstreamCommit, err := repo.ReadCommit(ctx, git.Revision(upstreamOID)) + if err != nil { + return "", fmt.Errorf("reading upstream commit: %w", err) + } + + oursCommitOID := upstreamOID + oursTreeOID := git.ObjectID(upstreamCommit.TreeId) + for _, todoItem := range todoList { + theirsCommit, err := repo.ReadCommit(ctx, git.Revision(todoItem)) + if err != nil { + return "", fmt.Errorf("reading todo list commit: %w", err) + } + + opts := []MergeTreeOption{WithAllowUnrelatedHistories()} + if len(theirsCommit.ParentIds) > 0 { + opts = append(opts, WithMergeBase(git.Revision(theirsCommit.ParentIds[0]))) + } + + newTreeOID, err := repo.MergeTree(ctx, oursCommitOID.String(), theirsCommit.Id, opts...) + if err != nil { + var conflictErr *MergeTreeConflictError + if errors.As(err, &conflictErr) { + return newTreeOID, &RebaseConflictError{ + Commit: theirsCommit.Id, + ConflictError: conflictErr, + } + } + return "", fmt.Errorf("merging todo list commit: %w", err) + } + + // When no tree changes detected, we need to further check + // 1. if the commit itself introduces no changes, pick it anyway. + // 2. if the commit is not empty to start and is not clean cherry-picks of any + // upstream commit, but become empty after rebasing, we just ignore it. + // Refer to https://git-scm.com/docs/git-rebase#Documentation/git-rebase.txt---emptydropkeepask + if newTreeOID == oursTreeOID { + if len(theirsCommit.ParentIds) == 0 { + if theirsCommit.TreeId != objectHash.EmptyTreeOID.String() { + continue + } + } else { + theirsParentCommit, err := repo.ReadCommit(ctx, git.Revision(theirsCommit.ParentIds[0])) + if err != nil { + return "", fmt.Errorf("reading parent commit: %w", err) + } + + if theirsCommit.TreeId != theirsParentCommit.TreeId { + continue + } + } + } + + author := getSignatureFromCommitAuthor(theirsCommit.GetAuthor()) + committer := cfg.committer + if committer == nil { + committer = getSignatureFromCommitAuthor(theirsCommit.GetCommitter()) + } + + newCommitOID, err := repo.WriteCommit(ctx, WriteCommitConfig{ + Parents: []git.ObjectID{oursCommitOID}, + AuthorName: author.Name, + AuthorEmail: author.Email, + AuthorDate: author.When, + CommitterName: committer.Name, + CommitterEmail: committer.Email, + CommitterDate: committer.When, + Message: string(theirsCommit.GetBody()), + TreeID: newTreeOID, + }) + if err != nil { + return "", fmt.Errorf("write commit: %w", err) + } + oursCommitOID = newCommitOID + oursTreeOID = newTreeOID + } + + return oursCommitOID, nil +} + +// RebaseConflictError encapsulates any conflicting file info and messages that occur +// when a git-merge-tree based rebase fails. +type RebaseConflictError struct { + Commit string + ConflictError *MergeTreeConflictError +} + +// Error returns the error string for a rebase conflict error. It is especially designed +// to keep compatible with git2go rebase. +func (c *RebaseConflictError) Error() string { + return fmt.Sprintf("rebase: commit %q: there are conflicting files", c.Commit) +} + +// getSignatureFromCommitAuthor gets a Signature from gitalypb.CommitAuthor. It translates +// CommitAuthor.Timezone into time offsets which can applied to time.Time so that we won't +// lose any timezone info. +func getSignatureFromCommitAuthor(author *gitalypb.CommitAuthor) (signature *git.Signature) { + signature = &git.Signature{ + Name: string(author.GetName()), + Email: string(author.GetEmail()), + When: time.Now().In(time.UTC), + } + + if timestamp := author.GetDate(); timestamp != nil { + signature.When = timestamp.AsTime().In(time.UTC) + } + + // care about timezone + timezone := author.GetTimezone() + if len(timezone) != 5 { + return + } + + loc, err := time.Parse("-0700", string(timezone)) + if err != nil { + return + } + + signature.When = signature.When.In(loc.Location()) + + return +} diff --git a/internal/git/localrepo/rebase_test.go b/internal/git/localrepo/rebase_test.go new file mode 100644 index 000000000..e680c4caf --- /dev/null +++ b/internal/git/localrepo/rebase_test.go @@ -0,0 +1,1019 @@ +package localrepo + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + "gitlab.com/gitlab-org/gitaly/v16/internal/featureflag" + "gitlab.com/gitlab-org/gitaly/v16/internal/git" + "gitlab.com/gitlab-org/gitaly/v16/internal/git/gittest" + "gitlab.com/gitlab-org/gitaly/v16/internal/structerr" + "gitlab.com/gitlab-org/gitaly/v16/internal/testhelper" + "gitlab.com/gitlab-org/gitaly/v16/internal/testhelper/testcfg" + "gitlab.com/gitlab-org/gitaly/v16/proto/go/gitalypb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestRebase(t *testing.T) { + t.Parallel() + + testhelper.NewFeatureSets(featureflag.GPGSigning).Run(t, testRebase) +} + +func testRebase(t *testing.T, ctx context.Context) { + t.Parallel() + + cfg := testcfg.Build(t) + + defaultCommitter := git.Signature{ + Name: gittest.DefaultCommitterName, + Email: gittest.DefaultCommitterMail, + When: gittest.DefaultCommitTime, + } + + type setupData struct { + upstream string + branch string + + expectedCommitsAhead int + expectedTreeEntries []gittest.TreeEntry + expectedErr error + } + + testCases := []struct { + desc string + setup func(t *testing.T, repoPath string) setupData + }{ + { + desc: "Single commit rebase", + setup: func(t *testing.T, repoPath string) setupData { + // Commit r1 should be picked when rebasing: + // + // l1 l2 + // o---o upstream + // \ + // \ + // \ + // o branch + // r1 + l1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l1: add foo"), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo"}, + ), + ) + l2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l2: add bar"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "bar", Mode: "100644", Content: "bar"}, + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo"}, + ), + ) + r1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r1: edit foo"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited"}, + ), + ) + + return setupData{ + upstream: l2.String(), + branch: r1.String(), + expectedCommitsAhead: 1, + expectedTreeEntries: []gittest.TreeEntry{ + { + Mode: "100644", + Path: "bar", + Content: "bar", + }, + { + Mode: "100644", + Path: "foo", + Content: "foo edited", + }, + }, + } + }, + }, + { + desc: "Multiple commits rebase", + setup: func(t *testing.T, repoPath string) setupData { + // Commit r1, r2, r3 should be picked when rebasing: + // + // l1 l2 + // o---o upstream + // \ + // \ + // \ + // o---o---o branch + // r1 r2 r3 + l1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l1: add foo"), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo"}, + ), + ) + l2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l2: add bar"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "bar", Mode: "100644", Content: "bar"}, + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo"}, + ), + ) + r1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r1: edit foo"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited"}, + ), + ) + r2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r2: edit foo again"), + gittest.WithParents(r1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited\nfoo edited again"}, + ), + ) + r3 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r3: add baz"), + gittest.WithParents(r2), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "baz", Mode: "100644", Content: "baz"}, + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited\nfoo edited again"}, + ), + ) + + return setupData{ + upstream: l2.String(), + branch: r3.String(), + expectedCommitsAhead: 3, + expectedTreeEntries: []gittest.TreeEntry{ + { + Mode: "100644", + Path: "bar", + Content: "bar", + }, + { + Mode: "100644", + Path: "baz", + Content: "baz", + }, + { + Mode: "100644", + Path: "foo", + Content: "foo edited\nfoo edited again", + }, + }, + } + }, + }, + { + desc: "Branch zero commits behind", + setup: func(t *testing.T, repoPath string) setupData { + // Fast forward to l3 when rebasing l3 to l2: + // + // o---o---o + // l1 l2 l3 + l1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l1: add foo"), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo"}, + ), + ) + l2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l2: add bar"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "bar", Mode: "100644", Content: "bar"}, + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo"}, + ), + ) + l3 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l3: edit foo"), + gittest.WithParents(l2), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "bar", Mode: "100644", Content: "bar"}, + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited"}, + ), + ) + + return setupData{ + upstream: l2.String(), + branch: l3.String(), + expectedCommitsAhead: 1, + expectedTreeEntries: []gittest.TreeEntry{ + { + Mode: "100644", + Path: "bar", + Content: "bar", + }, + { + Mode: "100644", + Path: "foo", + Content: "foo edited", + }, + }, + } + }, + }, + { + desc: "Branch zero commits ahead", + setup: func(t *testing.T, repoPath string) setupData { + // Fast forward to l3 when rebasing l2 to l3: + // + // o---o---o + // l1 l2 l3 + l1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l1: add foo"), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo"}, + ), + ) + l2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l2: add bar"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "bar", Mode: "100644", Content: "bar"}, + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo"}, + ), + ) + l3 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l3: edit foo"), + gittest.WithParents(l2), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "bar", Mode: "100644", Content: "bar"}, + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited"}, + ), + ) + + return setupData{ + upstream: l3.String(), + branch: l2.String(), + expectedCommitsAhead: 0, + expectedTreeEntries: []gittest.TreeEntry{ + { + Mode: "100644", + Path: "bar", + Content: "bar", + }, + { + Mode: "100644", + Path: "foo", + Content: "foo edited", + }, + }, + } + }, + }, + { + desc: "Partially merged branch detected by git-rev-list", + setup: func(t *testing.T, repoPath string) setupData { + // Commit r1 should be filtered out by git-rev-list because it introduces the + // same change as l2. Only commit r2 should be picked: + // + // l1 l2 + // o---o upstream + // \ + // \ + // \ + // o---o branch + // r1 r2 + l1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l1: add foo"), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo"}, + ), + ) + l2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l2: edit foo"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited"}, + ), + ) + r1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r1: edit foo"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited"}, + ), + ) + r2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r2: edit foo again"), + gittest.WithParents(r1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited\nfoo edited again"}, + ), + ) + + return setupData{ + upstream: l2.String(), + branch: r2.String(), + expectedCommitsAhead: 1, + expectedTreeEntries: []gittest.TreeEntry{ + { + Mode: "100644", + Path: "foo", + Content: "foo edited\nfoo edited again", + }, + }, + } + }, + }, + { + desc: "Partially merged branch detected by git-merge-tree", + setup: func(t *testing.T, repoPath string) setupData { + // Commit r1 can not be filtered out by git-rev-list because the changes it + // introduces is a subset but not the same as l2, so it should be filtered + // out by git-merge-tree. Only commit r2 should be picked: + // + // l1 l2 + // o---o upstream + // \ + // \ + // \ + // o---o branch + // r1 r2 + l1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l1: add foo"), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo"}, + ), + ) + l2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l2: add bar and edit foo"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "bar", Mode: "100644", Content: "bar"}, + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited"}, + ), + ) + r1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r1: edit foo"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited"}, + ), + ) + r2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r2: edit foo again"), + gittest.WithParents(r1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited\nfoo edited again"}, + ), + ) + + return setupData{ + upstream: l2.String(), + branch: r2.String(), + expectedCommitsAhead: 1, + expectedTreeEntries: []gittest.TreeEntry{ + { + Mode: "100644", + Path: "bar", + Content: "bar", + }, + { + Mode: "100644", + Path: "foo", + Content: "foo edited\nfoo edited again", + }, + }, + } + }, + }, + { + desc: "Rebase commit with no parents", + setup: func(t *testing.T, repoPath string) setupData { + // Commit r1 should be picked when rebasing but it has no parents, we need to + // enable --allow-unrelated-histories for git-merge-tree: + // + // l1 l2 + // o---o upstream + // \ + // \ + // \ + // o---o branch + // r1 r2 + l1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l1: add foo"), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo"}, + ), + ) + l2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l2: edit foo"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited"}, + ), + ) + r1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r1: add bar"), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "bar", Mode: "100644", Content: "bar"}, + ), + ) + r2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r2: merge l1"), + gittest.WithParents(r1, l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "bar", Mode: "100644", Content: "bar"}, + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo"}, + ), + ) + + return setupData{ + upstream: l2.String(), + branch: r2.String(), + expectedCommitsAhead: 1, + expectedTreeEntries: []gittest.TreeEntry{ + { + Mode: "100644", + Path: "bar", + Content: "bar", + }, + { + Mode: "100644", + Path: "foo", + Content: "foo edited", + }, + }, + } + }, + }, + { + desc: "Rebase commit with no parents and its changes already applied", + setup: func(t *testing.T, repoPath string) setupData { + // Commit r1 should be ignored when rebasing because its changes have already + // been applied: + // + // l1 l2 + // o---o upstream + // \ + // \ + // \ + // o---o branch + // r1 r2 + l1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l1: add foo"), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo"}, + ), + ) + l2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l2: add bar and edit foo"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "bar", Mode: "100644", Content: "bar"}, + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited"}, + ), + ) + r1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r1: add bar"), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "bar", Mode: "100644", Content: "bar"}, + ), + ) + r2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r2: merge l1"), + gittest.WithParents(r1, l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "bar", Mode: "100644", Content: "bar"}, + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited"}, + ), + ) + + return setupData{ + upstream: l2.String(), + branch: r2.String(), + expectedCommitsAhead: 0, + expectedTreeEntries: []gittest.TreeEntry{ + { + Mode: "100644", + Path: "bar", + Content: "bar", + }, + { + Mode: "100644", + Path: "foo", + Content: "foo edited", + }, + }, + } + }, + }, + { + desc: "Rebase commit with no parents and points to empty tree", + setup: func(t *testing.T, repoPath string) setupData { + // Commit r1 should be picked because itself is empty: + // + // l1 l2 + // o---o upstream + // \ + // \ + // \ + // o---o branch + // r1 r2 + l1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l1: add foo"), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo"}, + ), + ) + l2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l2: add bar and edit foo"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "bar", Mode: "100644", Content: "bar"}, + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited"}, + ), + ) + r1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r1: empty"), + ) + r2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r2: merge l1"), + gittest.WithParents(r1, l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "bar", Mode: "100644", Content: "bar"}, + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited"}, + ), + ) + + return setupData{ + upstream: l2.String(), + branch: r2.String(), + expectedCommitsAhead: 1, + expectedTreeEntries: []gittest.TreeEntry{ + { + Mode: "100644", + Path: "bar", + Content: "bar", + }, + { + Mode: "100644", + Path: "foo", + Content: "foo edited", + }, + }, + } + }, + }, + { + desc: "Keep originally empty commit", + setup: func(t *testing.T, repoPath string) setupData { + // Commit r1, r2 should be picked. Commit r2 is an empty commit originally, it + // should not be filtered out: + // + // l1 l2 + // o---o upstream + // \ + // \ + // \ + // o---o branch + // r1 r2 + l1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l1: add foo"), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo"}, + ), + ) + l2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l2: add bar"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "bar", Mode: "100644", Content: "bar"}, + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo"}, + ), + ) + r1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r1: edit foo"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited"}, + ), + ) + r2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r2: empty commit"), + gittest.WithParents(r1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited"}, + ), + ) + + return setupData{ + upstream: l2.String(), + branch: r2.String(), + expectedCommitsAhead: 2, + expectedTreeEntries: []gittest.TreeEntry{ + { + Mode: "100644", + Path: "bar", + Content: "bar", + }, + { + Mode: "100644", + Path: "foo", + Content: "foo edited", + }, + }, + } + }, + }, + { + desc: "All changes applied", + setup: func(t *testing.T, repoPath string) setupData { + // Commit r1 should ignored because all its changes is a subset of l2: + // + // l1 l2 + // o---o upstream + // \ + // \ + // \ + // o branch + // r1 + l1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l1: add foo"), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo"}, + ), + ) + l2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l2: add bar and edit foo"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "bar", Mode: "100644", Content: "bar"}, + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited"}, + ), + ) + r1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r1: edit foo"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited"}, + ), + ) + + return setupData{ + upstream: l2.String(), + branch: r1.String(), + expectedCommitsAhead: 0, + expectedTreeEntries: []gittest.TreeEntry{ + { + Mode: "100644", + Path: "bar", + Content: "bar", + }, + { + Mode: "100644", + Path: "foo", + Content: "foo edited", + }, + }, + } + }, + }, + { + desc: "With merge commit ignored", + setup: func(t *testing.T, repoPath string) setupData { + // Commit r2 should be ignored because it is a merge commit. Only r1 should be + // picked: + // + // l1 l2 l3 + // o---o---o upstream + // \ \ + // \ \ + // \ \ + // o---o branch + // r1 r2 + l1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l1: add foo"), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo"}, + ), + ) + l2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l2: add bar"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "bar", Mode: "100644", Content: "bar"}, + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo"}, + ), + ) + l3 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l3: edit bar"), + gittest.WithParents(l2), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "bar", Mode: "100644", Content: "bar edited"}, + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo"}, + ), + ) + r1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r1: edit foo"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited"}, + ), + ) + r2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r2: merge l2"), + gittest.WithParents(r1, l2), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "bar", Mode: "100644", Content: "bar"}, + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo edited"}, + ), + ) + + return setupData{ + upstream: l3.String(), + branch: r2.String(), + expectedCommitsAhead: 1, + expectedTreeEntries: []gittest.TreeEntry{ + { + Mode: "100644", + Path: "bar", + Content: "bar edited", + }, + { + Mode: "100644", + Path: "foo", + Content: "foo edited", + }, + }, + } + }, + }, + { + desc: "Rebase with criss-cross commit history", + setup: func(t *testing.T, repoPath string) setupData { + // We set up the following history with a criss-cross merge so that the + // merge base becomes ambiguous: + // + // l1 l2 l3 + // o---o---o upstream + // / \ \ / + // base o \ X + // \ \ / \ + // o---o---o branch + // r1 r2 r3 + base := gittest.WriteCommit(t, cfg, repoPath, gittest.WithMessage("base")) + l1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l1: add left"), + gittest.WithParents(base), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "left", Mode: "100644", Content: "l1"}, + ), + ) + r1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r1: add right"), + gittest.WithParents(base), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "right", Mode: "100644", Content: "r1"}, + ), + ) + l2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l2: edit left"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "left", Mode: "100644", Content: "l1\nl2"}, + ), + ) + r2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r2: merge l1"), + gittest.WithParents(r1, l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "left", Mode: "100644", Content: "l1"}, + gittest.TreeEntry{Path: "right", Mode: "100644", Content: "r1"}, + ), + ) + // Criss-cross merges. + l3 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l3: merge r2"), + gittest.WithParents(l2, r2), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "left", Mode: "100644", Content: "l1\nl2"}, + gittest.TreeEntry{Path: "right", Mode: "100644", Content: "r1"}, + ), + ) + r3 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r3: merge l2"), + gittest.WithParents(r2, l2), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "left", Mode: "100644", Content: "l1\nl2"}, + gittest.TreeEntry{Path: "right", Mode: "100644", Content: "r1"}, + ), + ) + + return setupData{ + upstream: l3.String(), + branch: r3.String(), + expectedCommitsAhead: 0, + expectedTreeEntries: []gittest.TreeEntry{ + { + Mode: "100644", + Path: "left", + Content: "l1\nl2", + }, + { + Mode: "100644", + Path: "right", + Content: "r1", + }, + }, + } + }, + }, + { + desc: "Rebase with conflict", + setup: func(t *testing.T, repoPath string) setupData { + // Commit l2 and r1 has content conflicts: + // + // l1 l2 + // o---o upstream + // \ + // \ + // \ + // o branch + // r1 + blob0 := gittest.WriteBlob(t, cfg, repoPath, []byte("foo")) + l1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l1: add foo"), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", OID: blob0}, + ), + ) + blob1 := gittest.WriteBlob(t, cfg, repoPath, []byte("foo edited by upstream")) + l2 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l2: edit foo"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", OID: blob1}, + ), + ) + blob2 := gittest.WriteBlob(t, cfg, repoPath, []byte("foo edited by branch")) + r1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("r1: edit foo"), + gittest.WithParents(l1), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", OID: blob2}, + ), + ) + + return setupData{ + upstream: l2.String(), + branch: r1.String(), + expectedErr: structerr.NewInternal("rebase using merge-tree: %w", &RebaseConflictError{ + Commit: r1.String(), + ConflictError: &MergeTreeConflictError{ + ConflictingFileInfo: []ConflictingFileInfo{ + { + FileName: "foo", + OID: blob0, + Stage: MergeStageAncestor, + Mode: 0o100644, + }, + { + FileName: "foo", + OID: blob1, + Stage: MergeStageOurs, + Mode: 0o100644, + }, + { + FileName: "foo", + OID: blob2, + Stage: MergeStageTheirs, + Mode: 0o100644, + }, + }, + ConflictInfoMessage: []ConflictInfoMessage{ + { + Paths: []string{"foo"}, + Type: "Auto-merging", + Message: "Auto-merging foo\n", + }, + { + Paths: []string{"foo"}, + Type: "CONFLICT (contents)", + Message: "CONFLICT (content): Merge conflict in foo\n", + }, + }, + }, + }), + } + }, + }, + { + desc: "Orphaned branch", + setup: func(t *testing.T, repoPath string) setupData { + // Commit l1 and r1 has no related histories, so we can not rebase r1 + // onto l1: + // + // l1 + // o upstream + // + // o branch + // r1 + l1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l1: add foo"), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "foo", Mode: "100644", Content: "foo"}, + ), + ) + r1 := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("l2: add bar"), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "bar", Mode: "100644", Content: "bar"}, + ), + ) + + return setupData{ + upstream: l1.String(), + branch: r1.String(), + expectedErr: structerr.NewInternal("get merge-base: exit status 1"), + } + }, + }, + } + + for _, tc := range testCases { + tc := tc + + t.Run(tc.desc, func(t *testing.T) { + t.Parallel() + + repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{ + SkipCreationViaService: true, + }) + repo := NewTestRepo(t, cfg, repoProto) + + data := tc.setup(t, repoPath) + + rebaseResult, err := repo.Rebase(ctx, data.upstream, data.branch, RebaseWithCommitter(defaultCommitter)) + + if data.expectedErr != nil { + testhelper.RequireGrpcError(t, data.expectedErr, err) + return + } + + require.NoError(t, err) + gittest.RequireTree(t, cfg, repoPath, string(rebaseResult), data.expectedTreeEntries) + + upstreamRevision := git.Revision(fmt.Sprintf("%s~%d", rebaseResult.String(), data.expectedCommitsAhead)) + upstreamCommit, err := repo.ReadCommit(ctx, upstreamRevision) + require.NoError(t, err) + require.Equal(t, data.upstream, upstreamCommit.Id) + }) + } +} + +func TestParseTimezoneFromCommitAuthor(t *testing.T) { + t.Parallel() + + const seconds = 1234567890 + + testCases := []struct { + desc string + timezone []byte + expectedWhen time.Time + }{ + { + desc: "valid timezone with positive offsets", + timezone: []byte("+0800"), + expectedWhen: time.Unix(seconds, 0).In(time.FixedZone("", 8*60*60)), + }, + { + desc: "valid timezone with negative offsets", + timezone: []byte("-0100"), + expectedWhen: time.Unix(seconds, 0).In(time.FixedZone("", -60*60)), + }, + { + desc: "invalid timezone length", + timezone: []byte("0100"), + expectedWhen: time.Unix(seconds, 0).In(time.UTC), + }, + { + desc: "invalid timezone", + timezone: []byte("aaaaa"), + expectedWhen: time.Unix(seconds, 0).In(time.UTC), + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + signature := getSignatureFromCommitAuthor(&gitalypb.CommitAuthor{ + Name: []byte(gittest.DefaultCommitterName), + Email: []byte(gittest.DefaultCommitterMail), + Date: ×tamppb.Timestamp{Seconds: seconds}, + Timezone: tc.timezone, + }) + require.Equal(t, tc.expectedWhen, signature.When) + }) + } +} diff --git a/internal/git/signature.go b/internal/git/signature.go new file mode 100644 index 000000000..666587b08 --- /dev/null +++ b/internal/git/signature.go @@ -0,0 +1,27 @@ +package git + +import ( + "strings" + "time" +) + +var signatureSanitizer = strings.NewReplacer("\n", "", "<", "", ">", "") + +// Signature represents a commits signature. +type Signature struct { + // Name of the author or the committer. + Name string + // Email of the author or the committer. + Email string + // When is the time of the commit. + When time.Time +} + +// NewSignature creates a new sanitized signature. +func NewSignature(name, email string, when time.Time) Signature { + return Signature{ + Name: signatureSanitizer.Replace(name), + Email: signatureSanitizer.Replace(email), + When: when.Truncate(time.Second), + } +} diff --git a/internal/git/signature_test.go b/internal/git/signature_test.go new file mode 100644 index 000000000..de5f99908 --- /dev/null +++ b/internal/git/signature_test.go @@ -0,0 +1,57 @@ +package git + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestNewSignature(t *testing.T) { + expectedSignature := Signature{ + Name: "foo", + Email: "foo@example.com", + When: time.Unix(1234567890, 0).In(time.UTC), + } + + for _, tt := range []struct { + name string + userName string + userEmail string + when time.Time + expected Signature + }{ + { + name: "valid params", + userName: "foo", + userEmail: "foo@example.com", + when: time.Unix(1234567890, 0).In(time.UTC), + expected: expectedSignature, + }, + { + name: "special characters in username are replaced", + userName: "<foo>\n", + userEmail: "foo@example.com", + when: time.Unix(1234567890, 0).In(time.UTC), + expected: expectedSignature, + }, + { + name: "special characters in email are replaced", + userName: "foo", + userEmail: "<foo@example.com>\n", + when: time.Unix(1234567890, 0).In(time.UTC), + expected: expectedSignature, + }, + { + name: "time is truncated to seconds", + userName: "foo", + userEmail: "foo@example.com", + when: time.Unix(1234567890, 123).In(time.UTC), + expected: expectedSignature, + }, + } { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, NewSignature(tt.userName, tt.userEmail, tt.when)) + }) + } +} diff --git a/internal/git2go/signature.go b/internal/git2go/signature.go index 79306c051..1d66ed5e0 100644 --- a/internal/git2go/signature.go +++ b/internal/git2go/signature.go @@ -1,27 +1,9 @@ package git2go -import ( - "strings" - "time" -) +import "gitlab.com/gitlab-org/gitaly/v16/internal/git" -var signatureSanitizer = strings.NewReplacer("\n", "", "<", "", ">", "") +// Signature represents a commits signature, synced from internal/git. +type Signature = git.Signature -// Signature represents a commits signature. -type Signature struct { - // Name of the author or the committer. - Name string - // Email of the author or the committer. - Email string - // When is the time of the commit. - When time.Time -} - -// NewSignature creates a new sanitized signature. -func NewSignature(name, email string, when time.Time) Signature { - return Signature{ - Name: signatureSanitizer.Replace(name), - Email: signatureSanitizer.Replace(email), - When: when.Truncate(time.Second), - } -} +// NewSignature creates a new sanitized signature, syned from internal/git. +var NewSignature = git.NewSignature diff --git a/internal/gitaly/service/operations/rebase.go b/internal/gitaly/service/operations/rebase_confirmable.go index 0a6619e71..340e161b5 100644 --- a/internal/gitaly/service/operations/rebase.go +++ b/internal/gitaly/service/operations/rebase_confirmable.go @@ -1,13 +1,14 @@ package operations import ( - "context" "errors" "fmt" "strings" "time" + "gitlab.com/gitlab-org/gitaly/v16/internal/featureflag" "gitlab.com/gitlab-org/gitaly/v16/internal/git" + "gitlab.com/gitlab-org/gitaly/v16/internal/git/localrepo" "gitlab.com/gitlab-org/gitaly/v16/internal/git2go" "gitlab.com/gitlab-org/gitaly/v16/internal/gitaly/hook/updateref" "gitlab.com/gitlab-org/gitaly/v16/internal/gitaly/storage" @@ -38,11 +39,6 @@ func (s *Server) UserRebaseConfirmable(stream gitalypb.OperationService_UserReba return structerr.NewInternal("creating repo quarantine: %w", err) } - repoPath, err := quarantineRepo.Path() - if err != nil { - return err - } - branch := git.NewReferenceNameFromBranchName(string(header.Branch)) oldrev, err := git.ObjectHashSHA1.FromHex(header.BranchSha) if err != nil { @@ -55,42 +51,82 @@ func (s *Server) UserRebaseConfirmable(stream gitalypb.OperationService_UserReba return structerr.NewInternal("%w", err) } - committer := git2go.NewSignature(string(header.User.Name), string(header.User.Email), time.Now()) + committer := git.NewSignature(string(header.User.Name), string(header.User.Email), time.Now()) if header.Timestamp != nil { committer.When = header.Timestamp.AsTime() } - newrev, err := s.git2goExecutor.Rebase(ctx, quarantineRepo, git2go.RebaseCommand{ - Repository: repoPath, - Committer: committer, - CommitID: oldrev, - UpstreamCommitID: startRevision, - SkipEmptyCommits: true, - }) - if err != nil { - var conflictErr git2go.ConflictingFilesError - if errors.As(err, &conflictErr) { - conflictingFiles := make([][]byte, 0, len(conflictErr.ConflictingFiles)) - for _, conflictingFile := range conflictErr.ConflictingFiles { - conflictingFiles = append(conflictingFiles, []byte(conflictingFile)) + var newrev git.ObjectID + if featureflag.UserRebaseConfirmablePureGit.IsEnabled(ctx) { + newrev, err = quarantineRepo.Rebase( + ctx, + startRevision.String(), + oldrev.String(), + localrepo.RebaseWithCommitter(committer), + ) + if err != nil { + var conflictErr *localrepo.RebaseConflictError + if errors.As(err, &conflictErr) { + conflictingFilesFromErr := conflictErr.ConflictError.ConflictedFiles() + conflictingFiles := make([][]byte, 0, len(conflictingFilesFromErr)) + for _, conflictingFile := range conflictingFilesFromErr { + conflictingFiles = append(conflictingFiles, []byte(conflictingFile)) + } + return structerr.NewFailedPrecondition("rebasing commits: %w", conflictErr).WithDetail( + &gitalypb.UserRebaseConfirmableError{ + Error: &gitalypb.UserRebaseConfirmableError_RebaseConflict{ + RebaseConflict: &gitalypb.MergeConflictError{ + ConflictingFiles: conflictingFiles, + ConflictingCommitIds: []string{ + startRevision.String(), + oldrev.String(), + }, + }, + }, + }, + ) } - return structerr.NewFailedPrecondition("rebasing commits: %w", err).WithDetail( - &gitalypb.UserRebaseConfirmableError{ - Error: &gitalypb.UserRebaseConfirmableError_RebaseConflict{ - RebaseConflict: &gitalypb.MergeConflictError{ - ConflictingFiles: conflictingFiles, - ConflictingCommitIds: []string{ - startRevision.String(), - oldrev.String(), + return structerr.NewInternal("rebasing commits: %w", err) + } + } else { + repoPath, err := quarantineRepo.Path() + if err != nil { + return err + } + + newrev, err = s.git2goExecutor.Rebase(ctx, quarantineRepo, git2go.RebaseCommand{ + Repository: repoPath, + Committer: committer, + CommitID: oldrev, + UpstreamCommitID: startRevision, + SkipEmptyCommits: true, + }) + if err != nil { + var conflictErr git2go.ConflictingFilesError + if errors.As(err, &conflictErr) { + conflictingFiles := make([][]byte, 0, len(conflictErr.ConflictingFiles)) + for _, conflictingFile := range conflictErr.ConflictingFiles { + conflictingFiles = append(conflictingFiles, []byte(conflictingFile)) + } + + return structerr.NewFailedPrecondition("rebasing commits: %w", err).WithDetail( + &gitalypb.UserRebaseConfirmableError{ + Error: &gitalypb.UserRebaseConfirmableError_RebaseConflict{ + RebaseConflict: &gitalypb.MergeConflictError{ + ConflictingFiles: conflictingFiles, + ConflictingCommitIds: []string{ + startRevision.String(), + oldrev.String(), + }, }, }, }, - }, - ) - } + ) + } - return structerr.NewInternal("rebasing commits: %w", err) + return structerr.NewInternal("rebasing commits: %w", err) + } } if err := stream.Send(&gitalypb.UserRebaseConfirmableResponse{ @@ -232,106 +268,3 @@ func validateUserRebaseToRefRequest(locator storage.Locator, in *gitalypb.UserRe return nil } - -// UserRebaseToRef overwrites the given TargetRef with the result of rebasing -// SourceSHA on top of FirstParentRef, and returns the SHA of the HEAD of -// TargetRef. -func (s *Server) UserRebaseToRef(ctx context.Context, request *gitalypb.UserRebaseToRefRequest) (*gitalypb.UserRebaseToRefResponse, error) { - if err := validateUserRebaseToRefRequest(s.locator, request); err != nil { - return nil, structerr.NewInvalidArgument("%w", err) - } - - quarantineDir, quarantineRepo, err := s.quarantinedRepo(ctx, request.Repository) - if err != nil { - return nil, structerr.NewInternal("creating repo quarantine: %w", err) - } - - repoPath, err := quarantineRepo.Path() - if err != nil { - return nil, err - } - - oid, err := quarantineRepo.ResolveRevision(ctx, git.Revision(request.FirstParentRef)) - if err != nil { - return nil, structerr.NewInvalidArgument("invalid FirstParentRef") - } - - sourceOID, err := quarantineRepo.ResolveRevision(ctx, git.Revision(request.SourceSha)) - if err != nil { - return nil, structerr.NewInvalidArgument("invalid SourceSha") - } - - // Resolve the current state of the target reference. We do not care whether it - // exists or not, but what we do want to assert is that the target reference doesn't - // change while we compute the merge commit as a small protection against races. - objectHash, err := quarantineRepo.ObjectHash(ctx) - if err != nil { - return nil, structerr.NewInternal("detecting object hash: %w", err) - } - - var oldTargetOID git.ObjectID - if expectedOldOID := request.GetExpectedOldOid(); expectedOldOID != "" { - oldTargetOID, err = objectHash.FromHex(expectedOldOID) - if err != nil { - return nil, structerr.NewInvalidArgument("invalid expected old object ID: %w", err).WithMetadata("old_object_id", expectedOldOID) - } - - oldTargetOID, err = quarantineRepo.ResolveRevision( - ctx, git.Revision(fmt.Sprintf("%s^{object}", oldTargetOID)), - ) - if err != nil { - return nil, structerr.NewInvalidArgument("cannot resolve expected old object ID: %w", err). - WithMetadata("old_object_id", expectedOldOID) - } - } else if targetRef, err := quarantineRepo.GetReference(ctx, git.ReferenceName(request.TargetRef)); err == nil { - if targetRef.IsSymbolic { - return nil, structerr.NewFailedPrecondition("target reference is symbolic: %q", request.TargetRef) - } - - oid, err := objectHash.FromHex(targetRef.Target) - if err != nil { - return nil, structerr.NewInternal("invalid target revision: %w", err) - } - - oldTargetOID = oid - } else if errors.Is(err, git.ErrReferenceNotFound) { - oldTargetOID = objectHash.ZeroOID - } else { - return nil, structerr.NewInternal("could not read target reference: %w", err) - } - - committer := git2go.NewSignature(string(request.User.Name), string(request.User.Email), time.Now()) - if request.Timestamp != nil { - committer.When = request.Timestamp.AsTime() - } - - rebasedOID, err := s.git2goExecutor.Rebase(ctx, quarantineRepo, git2go.RebaseCommand{ - Repository: repoPath, - Committer: committer, - CommitID: sourceOID, - UpstreamCommitID: oid, - SkipEmptyCommits: true, - }) - if err != nil { - var conflictErr git2go.ConflictingFilesError - if errors.As(err, &conflictErr) { - return nil, structerr.NewFailedPrecondition("failed to rebase %s on %s while preparing %s due to conflict", - sourceOID, oid, string(request.TargetRef)) - } - - return nil, structerr.NewInternal("rebasing commits: %w", err) - } - - if err := quarantineDir.Migrate(); err != nil { - return nil, structerr.NewInternal("migrating quarantined objects: %w", err) - } - - repo := s.localrepo(request.GetRepository()) - if err := repo.UpdateRef(ctx, git.ReferenceName(request.TargetRef), rebasedOID, oldTargetOID); err != nil { - return nil, structerr.NewFailedPrecondition("could not update %s. Please refresh and try again", string(request.TargetRef)) - } - - return &gitalypb.UserRebaseToRefResponse{ - CommitId: rebasedOID.String(), - }, nil -} diff --git a/internal/gitaly/service/operations/rebase_test.go b/internal/gitaly/service/operations/rebase_confirmable_test.go index 16770a933..5e1ff1b21 100644 --- a/internal/gitaly/service/operations/rebase_test.go +++ b/internal/gitaly/service/operations/rebase_confirmable_test.go @@ -3,12 +3,13 @@ package operations import ( - "errors" + "context" "fmt" "io" "testing" "github.com/stretchr/testify/require" + "gitlab.com/gitlab-org/gitaly/v16/internal/featureflag" "gitlab.com/gitlab-org/gitaly/v16/internal/git" "gitlab.com/gitlab-org/gitaly/v16/internal/git/gittest" "gitlab.com/gitlab-org/gitaly/v16/internal/git/localrepo" @@ -30,7 +31,14 @@ var rebaseBranchName = "many_files" func TestUserRebaseConfirmable_successful(t *testing.T) { t.Parallel() - ctx := testhelper.Context(t) + testhelper.NewFeatureSets( + featureflag.GPGSigning, + featureflag.UserRebaseConfirmablePureGit, + ).Run(t, testUserRebaseConfirmableSuccessful) +} + +func testUserRebaseConfirmableSuccessful(t *testing.T, ctx context.Context) { + t.Parallel() ctx, cfg, repoProto, repoPath, client := setupOperationsService(t, ctx) @@ -91,7 +99,14 @@ func TestUserRebaseConfirmable_successful(t *testing.T) { func TestUserRebaseConfirmable_skipEmptyCommits(t *testing.T) { t.Parallel() - ctx := testhelper.Context(t) + testhelper.NewFeatureSets( + featureflag.GPGSigning, + featureflag.UserRebaseConfirmablePureGit, + ).Run(t, testUserRebaseConfirmableSkipEmptyCommits) +} + +func testUserRebaseConfirmableSkipEmptyCommits(t *testing.T, ctx context.Context) { + t.Parallel() ctx, cfg, repoProto, repoPath, client := setupOperationsService(t, ctx) @@ -190,7 +205,15 @@ func TestUserRebaseConfirmable_skipEmptyCommits(t *testing.T) { func TestUserRebaseConfirmable_transaction(t *testing.T) { t.Parallel() - ctx := testhelper.Context(t) + testhelper.NewFeatureSets( + featureflag.GPGSigning, + featureflag.UserRebaseConfirmablePureGit, + ).Run(t, testUserRebaseConfirmableTransaction) +} + +func testUserRebaseConfirmableTransaction(t *testing.T, ctx context.Context) { + t.Parallel() + txManager := transaction.NewTrackingManager() ctx, cfg, repoProto, repoPath, client := setupOperationsService( @@ -277,7 +300,15 @@ func TestUserRebaseConfirmable_transaction(t *testing.T) { func TestUserRebaseConfirmable_stableCommitIDs(t *testing.T) { t.Parallel() - ctx := testhelper.Context(t) + + testhelper.NewFeatureSets( + featureflag.GPGSigning, + featureflag.UserRebaseConfirmablePureGit, + ).Run(t, testUserRebaseConfirmableStableCommitIDs) +} + +func testUserRebaseConfirmableStableCommitIDs(t *testing.T, ctx context.Context) { + t.Parallel() ctx, cfg, repoProto, repoPath, client := setupOperationsService(t, ctx) cfg.Gitlab.URL = setupAndStartGitlabServer(t, gittest.GlID, "project-1", cfg) @@ -347,7 +378,15 @@ func TestUserRebaseConfirmable_stableCommitIDs(t *testing.T) { func TestUserRebaseConfirmable_inputValidation(t *testing.T) { t.Parallel() - ctx := testhelper.Context(t) + + testhelper.NewFeatureSets( + featureflag.GPGSigning, + featureflag.UserRebaseConfirmablePureGit, + ).Run(t, testUserRebaseConfirmableInputValidation) +} + +func testUserRebaseConfirmableInputValidation(t *testing.T, ctx context.Context) { + t.Parallel() ctx, cfg, repo, repoPath, client := setupOperationsService(t, ctx) @@ -408,7 +447,15 @@ func TestUserRebaseConfirmable_inputValidation(t *testing.T) { func TestUserRebaseConfirmable_abortViaClose(t *testing.T) { t.Parallel() - ctx := testhelper.Context(t) + + testhelper.NewFeatureSets( + featureflag.GPGSigning, + featureflag.UserRebaseConfirmablePureGit, + ).Run(t, testUserRebaseConfirmableAbortViaClose) +} + +func testUserRebaseConfirmableAbortViaClose(t *testing.T, ctx context.Context) { + t.Parallel() ctx, cfg, _, _, client := setupOperationsService(t, ctx) @@ -487,7 +534,15 @@ func TestUserRebaseConfirmable_abortViaClose(t *testing.T) { func TestUserRebaseConfirmable_abortViaApply(t *testing.T) { t.Parallel() - ctx := testhelper.Context(t) + + testhelper.NewFeatureSets( + featureflag.GPGSigning, + featureflag.UserRebaseConfirmablePureGit, + ).Run(t, testUserRebaseConfirmableAbortViaApply) +} + +func testUserRebaseConfirmableAbortViaApply(t *testing.T, ctx context.Context) { + t.Parallel() ctx, cfg, repoProto, repoPath, client := setupOperationsService(t, ctx) @@ -530,7 +585,16 @@ func TestUserRebaseConfirmable_abortViaApply(t *testing.T) { func TestUserRebaseConfirmable_preReceiveError(t *testing.T) { t.Parallel() - ctx, cfg, repoProto, repoPath, client := setupOperationsService(t, testhelper.Context(t)) + testhelper.NewFeatureSets( + featureflag.GPGSigning, + featureflag.UserRebaseConfirmablePureGit, + ).Run(t, testUserRebaseConfirmablePreReceiveError) +} + +func testUserRebaseConfirmablePreReceiveError(t *testing.T, ctx context.Context) { + t.Parallel() + + ctx, cfg, repoProto, repoPath, client := setupOperationsService(t, ctx) repo := localrepo.NewTestRepo(t, cfg, repoProto) repoCopyProto, _ := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{ @@ -590,7 +654,16 @@ func TestUserRebaseConfirmable_preReceiveError(t *testing.T) { func TestUserRebaseConfirmable_gitError(t *testing.T) { t.Parallel() - ctx, cfg, repoProto, repoPath, client := setupOperationsService(t, testhelper.Context(t)) + testhelper.NewFeatureSets( + featureflag.GPGSigning, + featureflag.UserRebaseConfirmablePureGit, + ).Run(t, testUserRebaseConfirmableGitError) +} + +func testUserRebaseConfirmableGitError(t *testing.T, ctx context.Context) { + t.Parallel() + + ctx, cfg, repoProto, repoPath, client := setupOperationsService(t, ctx) repoCopyProto, _ := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{ Seed: gittest.SeedGitLabTest, @@ -631,7 +704,15 @@ func TestUserRebaseConfirmable_gitError(t *testing.T) { func TestUserRebaseConfirmable_deletedFileInLocalRepo(t *testing.T) { t.Parallel() - ctx := testhelper.Context(t) + + testhelper.NewFeatureSets( + featureflag.GPGSigning, + featureflag.UserRebaseConfirmablePureGit, + ).Run(t, testUserRebaseConfirmableDeletedFileInLocalRepo) +} + +func testUserRebaseConfirmableDeletedFileInLocalRepo(t *testing.T, ctx context.Context) { + t.Parallel() ctx, cfg, client := setupOperationsServiceWithoutRepo(t, ctx) @@ -702,7 +783,15 @@ func TestUserRebaseConfirmable_deletedFileInLocalRepo(t *testing.T) { func TestUserRebaseConfirmable_deletedFileInRemoteRepo(t *testing.T) { t.Parallel() - ctx := testhelper.Context(t) + testhelper.NewFeatureSets( + featureflag.GPGSigning, + featureflag.UserRebaseConfirmablePureGit, + ).Run(t, testUserRebaseConfirmableDeletedFileInRemoteRepo) +} + +func testUserRebaseConfirmableDeletedFileInRemoteRepo(t *testing.T, ctx context.Context) { + t.Parallel() + ctx, cfg, client := setupOperationsServiceWithoutRepo(t, ctx) localRepoProto, localRepoPath := gittest.CreateRepository(t, ctx, cfg) @@ -765,7 +854,15 @@ func TestUserRebaseConfirmable_deletedFileInRemoteRepo(t *testing.T) { func TestUserRebaseConfirmable_failedWithCode(t *testing.T) { t.Parallel() - ctx := testhelper.Context(t) + + testhelper.NewFeatureSets( + featureflag.GPGSigning, + featureflag.UserRebaseConfirmablePureGit, + ).Run(t, testUserRebaseConfirmableFailedWithCode) +} + +func testUserRebaseConfirmableFailedWithCode(t *testing.T, ctx context.Context) { + t.Parallel() ctx, cfg, repoProto, repoPath, client := setupOperationsService(t, ctx) @@ -844,221 +941,3 @@ func buildUserRebaseConfirmableApplyRequest(apply bool) *gitalypb.UserRebaseConf }, } } - -func TestUserRebaseToRef_successful(t *testing.T) { - t.Parallel() - - ctx, cfg, client := setupOperationsServiceWithoutRepo(t, testhelper.Context(t)) - repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg) - repo := localrepo.NewTestRepo(t, cfg, repoProto) - - mergeBaseOID := gittest.WriteCommit(t, cfg, repoPath, gittest.WithMessage("first commit")) - - sourceOID := gittest.WriteCommit(t, cfg, repoPath, - gittest.WithMessage("commit source SHA"), - gittest.WithParents(mergeBaseOID), - gittest.WithTree(gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ - {Path: "file", Mode: "100644", OID: gittest.WriteBlob(t, cfg, repoPath, []byte("source blob"))}, - })), - ) - - firstParentRef := "refs/heads/main" - firstParentRefBlobID := gittest.WriteBlob(t, cfg, repoPath, []byte("first parent ref blob")) - firstParentRefTreeID := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ - {Path: "other-file", Mode: "100644", OID: firstParentRefBlobID}, - }) - firstParentRefOID := gittest.WriteCommit(t, cfg, repoPath, - gittest.WithReference(firstParentRef), - gittest.WithMessage("first parent ref commit"), - gittest.WithParents(mergeBaseOID), - gittest.WithTree(firstParentRefTreeID), - ) - - targetRef := "refs/merge-requests/1234/train" - - request := &gitalypb.UserRebaseToRefRequest{ - Repository: repoProto, - User: gittest.TestUser, - SourceSha: sourceOID.String(), - FirstParentRef: []byte(firstParentRef), - TargetRef: []byte(targetRef), - Timestamp: ×tamppb.Timestamp{Seconds: 100000000}, - } - - response, err := client.UserRebaseToRef(ctx, request) - require.NoError(t, err, "rebase error") - - rebasedCommitID := gittest.ResolveRevision(t, cfg, repoPath, response.CommitId) - require.NotEqual(t, rebasedCommitID, firstParentRefOID.String(), "no rebase occurred") - - currentTargetRefOID := gittest.ResolveRevision(t, cfg, repoPath, targetRef) - require.Equal(t, currentTargetRefOID.String(), response.CommitId, "target ref does not point to rebased commit") - - _, err = repo.ReadCommit(ctx, git.Revision(response.CommitId)) - require.NoError(t, err, "rebased commit is unreadable") - - currentParentRefOID := gittest.ResolveRevision(t, cfg, repoPath, firstParentRef) - require.Equal(t, currentParentRefOID, firstParentRefOID, "first parent ref got mutated") -} - -func TestUserRebaseToRef_failure(t *testing.T) { - t.Parallel() - - ctx, cfg, client := setupOperationsServiceWithoutRepo(t, testhelper.Context(t)) - repo, repoPath := gittest.CreateRepository(t, ctx, cfg) - - mergeBaseOID := gittest.WriteCommit(t, cfg, repoPath, gittest.WithMessage("first commit")) - - validTargetRef := []byte("refs/merge-requests/x/merge") - validTargetRefOID := gittest.WriteCommit(t, cfg, repoPath, gittest.WithMessage("commit to target ref")) - validSourceOID := gittest.WriteCommit(t, cfg, repoPath, gittest.WithMessage("commit source SHA"), gittest.WithParents(mergeBaseOID)) - validSourceSha := validSourceOID.String() - validFirstParentRef := []byte("refs/heads/main") - gittest.WriteCommit(t, cfg, repoPath, gittest.WithMessage("first parent ref commit"), gittest.WithReference(string(validFirstParentRef)), gittest.WithParents(mergeBaseOID)) - - testCases := []struct { - desc string - repo *gitalypb.Repository - user *gitalypb.User - targetRef []byte - sourceSha string - firstParentRef []byte - expectedOldOID string - expectedError error - }{ - { - desc: "empty repository", - user: gittest.TestUser, - targetRef: validTargetRef, - sourceSha: validSourceSha, - firstParentRef: validFirstParentRef, - expectedError: structerr.NewInvalidArgument("%w", storage.ErrRepositoryNotSet), - }, - { - desc: "empty user", - repo: repo, - sourceSha: validSourceSha, - targetRef: validTargetRef, - firstParentRef: validFirstParentRef, - expectedError: structerr.NewInvalidArgument("%w", errors.New("empty User")), - }, - { - desc: "empty source SHA", - repo: repo, - user: gittest.TestUser, - firstParentRef: validFirstParentRef, - targetRef: validTargetRef, - expectedError: structerr.NewInvalidArgument("%w", errors.New("empty SourceSha")), - }, - { - desc: "non-existing source SHA commit", - repo: repo, - user: gittest.TestUser, - targetRef: validTargetRef, - sourceSha: "f001", - firstParentRef: validFirstParentRef, - expectedError: structerr.NewInvalidArgument("%w", errors.New("invalid SourceSha")), - }, - { - desc: "empty first parent ref", - repo: repo, - user: gittest.TestUser, - targetRef: validTargetRef, - sourceSha: validSourceSha, - expectedError: structerr.NewInvalidArgument("%w", errors.New("empty FirstParentRef")), - }, - { - desc: "invalid target ref", - repo: repo, - user: gittest.TestUser, - targetRef: []byte("refs/heads/branch"), - sourceSha: validSourceSha, - firstParentRef: validFirstParentRef, - expectedError: structerr.NewInvalidArgument("%w", errors.New("invalid TargetRef")), - }, - { - desc: "non-existing first parent ref", - repo: repo, - user: gittest.TestUser, - targetRef: validTargetRef, - sourceSha: validSourceSha, - firstParentRef: []byte("refs/heads/branch"), - expectedError: structerr.NewInvalidArgument("invalid FirstParentRef"), - }, - { - desc: "target_ref not at expected_old_oid", - repo: repo, - user: gittest.TestUser, - targetRef: validTargetRef, - sourceSha: validSourceSha, - firstParentRef: validFirstParentRef, - expectedOldOID: validSourceSha, // arbitrary valid SHA - expectedError: structerr.NewFailedPrecondition("could not update %s. Please refresh and try again", validTargetRef), - }, - } - - for _, tc := range testCases { - tc := tc - - t.Run(tc.desc, func(t *testing.T) { - t.Parallel() - // reset target ref between tests - gittest.WriteRef(t, cfg, repoPath, git.ReferenceName(validTargetRef), validTargetRefOID) - - request := &gitalypb.UserRebaseToRefRequest{ - Repository: tc.repo, - User: tc.user, - TargetRef: tc.targetRef, - SourceSha: tc.sourceSha, - FirstParentRef: tc.firstParentRef, - ExpectedOldOid: tc.expectedOldOID, - } - _, err := client.UserRebaseToRef(ctx, request) - testhelper.RequireGrpcError(t, err, tc.expectedError) - }) - } -} - -func TestUserRebaseToRef_conflict(t *testing.T) { - t.Parallel() - - ctx, cfg, client := setupOperationsServiceWithoutRepo(t, testhelper.Context(t)) - repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg) - - mergeBaseOID := gittest.WriteCommit(t, cfg, repoPath, gittest.WithMessage("first commit")) - - firstParentRef := "refs/heads/main" - firstParentRefBlobID := gittest.WriteBlob(t, cfg, repoPath, []byte("first parent ref blob")) - firstParentRefTreeID := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ - {Path: "file", Mode: "100644", OID: firstParentRefBlobID}, - }) - firstParentRefOID := gittest.WriteCommit(t, cfg, repoPath, gittest.WithMessage("first parent ref commit"), gittest.WithTree(firstParentRefTreeID), gittest.WithReference(firstParentRef), gittest.WithParents(mergeBaseOID)) - - sourceBlobID := gittest.WriteBlob(t, cfg, repoPath, []byte("source blob")) - - sourceOID := gittest.WriteCommit(t, cfg, repoPath, - gittest.WithMessage("source commit"), - gittest.WithParents(mergeBaseOID), - gittest.WithTree(gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ - {Path: "file", Mode: "100644", OID: sourceBlobID}, - })), - ) - - targetRef := "refs/merge-requests/9999/merge" - targetRefOID := gittest.WriteCommit(t, cfg, repoPath, gittest.WithReference(targetRef)) - - request := &gitalypb.UserRebaseToRefRequest{ - Repository: repoProto, - User: gittest.TestUser, - TargetRef: []byte(targetRef), - SourceSha: sourceOID.String(), - FirstParentRef: []byte(firstParentRef), - } - response, err := client.UserRebaseToRef(ctx, request) - - require.Nil(t, response) - testhelper.RequireGrpcError(t, structerr.NewFailedPrecondition(fmt.Sprintf("failed to rebase %s on %s while preparing %s due to conflict", sourceOID, firstParentRefOID, targetRef)), err) - - currentTargetRefOID := gittest.ResolveRevision(t, cfg, repoPath, targetRef) - require.Equal(t, targetRefOID, currentTargetRefOID, "target ref should not change when the rebase fails due to GitError") -} diff --git a/internal/gitaly/service/operations/rebase_to_ref.go b/internal/gitaly/service/operations/rebase_to_ref.go new file mode 100644 index 000000000..277d99fda --- /dev/null +++ b/internal/gitaly/service/operations/rebase_to_ref.go @@ -0,0 +1,116 @@ +package operations + +import ( + "context" + "errors" + "fmt" + "time" + + "gitlab.com/gitlab-org/gitaly/v16/internal/git" + "gitlab.com/gitlab-org/gitaly/v16/internal/git2go" + "gitlab.com/gitlab-org/gitaly/v16/internal/structerr" + "gitlab.com/gitlab-org/gitaly/v16/proto/go/gitalypb" +) + +// UserRebaseToRef overwrites the given TargetRef with the result of rebasing +// SourceSHA on top of FirstParentRef, and returns the SHA of the HEAD of +// TargetRef. +func (s *Server) UserRebaseToRef(ctx context.Context, request *gitalypb.UserRebaseToRefRequest) (*gitalypb.UserRebaseToRefResponse, error) { + if err := validateUserRebaseToRefRequest(s.locator, request); err != nil { + return nil, structerr.NewInvalidArgument("%w", err) + } + + quarantineDir, quarantineRepo, err := s.quarantinedRepo(ctx, request.Repository) + if err != nil { + return nil, structerr.NewInternal("creating repo quarantine: %w", err) + } + + repoPath, err := quarantineRepo.Path() + if err != nil { + return nil, err + } + + oid, err := quarantineRepo.ResolveRevision(ctx, git.Revision(request.FirstParentRef)) + if err != nil { + return nil, structerr.NewInvalidArgument("invalid FirstParentRef") + } + + sourceOID, err := quarantineRepo.ResolveRevision(ctx, git.Revision(request.SourceSha)) + if err != nil { + return nil, structerr.NewInvalidArgument("invalid SourceSha") + } + + // Resolve the current state of the target reference. We do not care whether it + // exists or not, but what we do want to assert is that the target reference doesn't + // change while we compute the merge commit as a small protection against races. + objectHash, err := quarantineRepo.ObjectHash(ctx) + if err != nil { + return nil, structerr.NewInternal("detecting object hash: %w", err) + } + + var oldTargetOID git.ObjectID + if expectedOldOID := request.GetExpectedOldOid(); expectedOldOID != "" { + oldTargetOID, err = objectHash.FromHex(expectedOldOID) + if err != nil { + return nil, structerr.NewInvalidArgument("invalid expected old object ID: %w", err).WithMetadata("old_object_id", expectedOldOID) + } + + oldTargetOID, err = quarantineRepo.ResolveRevision( + ctx, git.Revision(fmt.Sprintf("%s^{object}", oldTargetOID)), + ) + if err != nil { + return nil, structerr.NewInvalidArgument("cannot resolve expected old object ID: %w", err). + WithMetadata("old_object_id", expectedOldOID) + } + } else if targetRef, err := quarantineRepo.GetReference(ctx, git.ReferenceName(request.TargetRef)); err == nil { + if targetRef.IsSymbolic { + return nil, structerr.NewFailedPrecondition("target reference is symbolic: %q", request.TargetRef) + } + + oid, err := objectHash.FromHex(targetRef.Target) + if err != nil { + return nil, structerr.NewInternal("invalid target revision: %w", err) + } + + oldTargetOID = oid + } else if errors.Is(err, git.ErrReferenceNotFound) { + oldTargetOID = objectHash.ZeroOID + } else { + return nil, structerr.NewInternal("could not read target reference: %w", err) + } + + committer := git.NewSignature(string(request.User.Name), string(request.User.Email), time.Now()) + if request.Timestamp != nil { + committer.When = request.Timestamp.AsTime() + } + + rebasedOID, err := s.git2goExecutor.Rebase(ctx, quarantineRepo, git2go.RebaseCommand{ + Repository: repoPath, + Committer: committer, + CommitID: sourceOID, + UpstreamCommitID: oid, + SkipEmptyCommits: true, + }) + if err != nil { + var conflictErr git2go.ConflictingFilesError + if errors.As(err, &conflictErr) { + return nil, structerr.NewFailedPrecondition("failed to rebase %s on %s while preparing %s due to conflict", + sourceOID, oid, string(request.TargetRef)) + } + + return nil, structerr.NewInternal("rebasing commits: %w", err) + } + + if err := quarantineDir.Migrate(); err != nil { + return nil, structerr.NewInternal("migrating quarantined objects: %w", err) + } + + repo := s.localrepo(request.GetRepository()) + if err := repo.UpdateRef(ctx, git.ReferenceName(request.TargetRef), rebasedOID, oldTargetOID); err != nil { + return nil, structerr.NewFailedPrecondition("could not update %s. Please refresh and try again", string(request.TargetRef)) + } + + return &gitalypb.UserRebaseToRefResponse{ + CommitId: rebasedOID.String(), + }, nil +} diff --git a/internal/gitaly/service/operations/rebase_to_ref_test.go b/internal/gitaly/service/operations/rebase_to_ref_test.go new file mode 100644 index 000000000..49afe5f46 --- /dev/null +++ b/internal/gitaly/service/operations/rebase_to_ref_test.go @@ -0,0 +1,237 @@ +//go:build !gitaly_test_sha256 + +package operations + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "gitlab.com/gitlab-org/gitaly/v16/internal/git" + "gitlab.com/gitlab-org/gitaly/v16/internal/git/gittest" + "gitlab.com/gitlab-org/gitaly/v16/internal/git/localrepo" + "gitlab.com/gitlab-org/gitaly/v16/internal/gitaly/storage" + "gitlab.com/gitlab-org/gitaly/v16/internal/structerr" + "gitlab.com/gitlab-org/gitaly/v16/internal/testhelper" + "gitlab.com/gitlab-org/gitaly/v16/proto/go/gitalypb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestUserRebaseToRef_successful(t *testing.T) { + t.Parallel() + + ctx, cfg, client := setupOperationsServiceWithoutRepo(t, testhelper.Context(t)) + repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg) + repo := localrepo.NewTestRepo(t, cfg, repoProto) + + mergeBaseOID := gittest.WriteCommit(t, cfg, repoPath, gittest.WithMessage("first commit")) + + sourceOID := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("commit source SHA"), + gittest.WithParents(mergeBaseOID), + gittest.WithTree(gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ + {Path: "file", Mode: "100644", OID: gittest.WriteBlob(t, cfg, repoPath, []byte("source blob"))}, + })), + ) + + firstParentRef := "refs/heads/main" + firstParentRefBlobID := gittest.WriteBlob(t, cfg, repoPath, []byte("first parent ref blob")) + firstParentRefTreeID := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ + {Path: "other-file", Mode: "100644", OID: firstParentRefBlobID}, + }) + firstParentRefOID := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithReference(firstParentRef), + gittest.WithMessage("first parent ref commit"), + gittest.WithParents(mergeBaseOID), + gittest.WithTree(firstParentRefTreeID), + ) + + targetRef := "refs/merge-requests/1234/train" + + request := &gitalypb.UserRebaseToRefRequest{ + Repository: repoProto, + User: gittest.TestUser, + SourceSha: sourceOID.String(), + FirstParentRef: []byte(firstParentRef), + TargetRef: []byte(targetRef), + Timestamp: ×tamppb.Timestamp{Seconds: 100000000}, + } + + response, err := client.UserRebaseToRef(ctx, request) + require.NoError(t, err, "rebase error") + + rebasedCommitID := gittest.ResolveRevision(t, cfg, repoPath, response.CommitId) + require.NotEqual(t, rebasedCommitID, firstParentRefOID.String(), "no rebase occurred") + + currentTargetRefOID := gittest.ResolveRevision(t, cfg, repoPath, targetRef) + require.Equal(t, currentTargetRefOID.String(), response.CommitId, "target ref does not point to rebased commit") + + _, err = repo.ReadCommit(ctx, git.Revision(response.CommitId)) + require.NoError(t, err, "rebased commit is unreadable") + + currentParentRefOID := gittest.ResolveRevision(t, cfg, repoPath, firstParentRef) + require.Equal(t, currentParentRefOID, firstParentRefOID, "first parent ref got mutated") +} + +func TestUserRebaseToRef_failure(t *testing.T) { + t.Parallel() + + ctx, cfg, client := setupOperationsServiceWithoutRepo(t, testhelper.Context(t)) + repo, repoPath := gittest.CreateRepository(t, ctx, cfg) + + mergeBaseOID := gittest.WriteCommit(t, cfg, repoPath, gittest.WithMessage("first commit")) + + validTargetRef := []byte("refs/merge-requests/x/merge") + validTargetRefOID := gittest.WriteCommit(t, cfg, repoPath, gittest.WithMessage("commit to target ref")) + validSourceOID := gittest.WriteCommit(t, cfg, repoPath, gittest.WithMessage("commit source SHA"), gittest.WithParents(mergeBaseOID)) + validSourceSha := validSourceOID.String() + validFirstParentRef := []byte("refs/heads/main") + gittest.WriteCommit(t, cfg, repoPath, gittest.WithMessage("first parent ref commit"), gittest.WithReference(string(validFirstParentRef)), gittest.WithParents(mergeBaseOID)) + + testCases := []struct { + desc string + repo *gitalypb.Repository + user *gitalypb.User + targetRef []byte + sourceSha string + firstParentRef []byte + expectedOldOID string + expectedError error + }{ + { + desc: "empty repository", + user: gittest.TestUser, + targetRef: validTargetRef, + sourceSha: validSourceSha, + firstParentRef: validFirstParentRef, + expectedError: structerr.NewInvalidArgument("%w", storage.ErrRepositoryNotSet), + }, + { + desc: "empty user", + repo: repo, + sourceSha: validSourceSha, + targetRef: validTargetRef, + firstParentRef: validFirstParentRef, + expectedError: structerr.NewInvalidArgument("%w", errors.New("empty User")), + }, + { + desc: "empty source SHA", + repo: repo, + user: gittest.TestUser, + firstParentRef: validFirstParentRef, + targetRef: validTargetRef, + expectedError: structerr.NewInvalidArgument("%w", errors.New("empty SourceSha")), + }, + { + desc: "non-existing source SHA commit", + repo: repo, + user: gittest.TestUser, + targetRef: validTargetRef, + sourceSha: "f001", + firstParentRef: validFirstParentRef, + expectedError: structerr.NewInvalidArgument("%w", errors.New("invalid SourceSha")), + }, + { + desc: "empty first parent ref", + repo: repo, + user: gittest.TestUser, + targetRef: validTargetRef, + sourceSha: validSourceSha, + expectedError: structerr.NewInvalidArgument("%w", errors.New("empty FirstParentRef")), + }, + { + desc: "invalid target ref", + repo: repo, + user: gittest.TestUser, + targetRef: []byte("refs/heads/branch"), + sourceSha: validSourceSha, + firstParentRef: validFirstParentRef, + expectedError: structerr.NewInvalidArgument("%w", errors.New("invalid TargetRef")), + }, + { + desc: "non-existing first parent ref", + repo: repo, + user: gittest.TestUser, + targetRef: validTargetRef, + sourceSha: validSourceSha, + firstParentRef: []byte("refs/heads/branch"), + expectedError: structerr.NewInvalidArgument("invalid FirstParentRef"), + }, + { + desc: "target_ref not at expected_old_oid", + repo: repo, + user: gittest.TestUser, + targetRef: validTargetRef, + sourceSha: validSourceSha, + firstParentRef: validFirstParentRef, + expectedOldOID: validSourceSha, // arbitrary valid SHA + expectedError: structerr.NewFailedPrecondition("could not update %s. Please refresh and try again", validTargetRef), + }, + } + + for _, tc := range testCases { + tc := tc + + t.Run(tc.desc, func(t *testing.T) { + t.Parallel() + // reset target ref between tests + gittest.WriteRef(t, cfg, repoPath, git.ReferenceName(validTargetRef), validTargetRefOID) + + request := &gitalypb.UserRebaseToRefRequest{ + Repository: tc.repo, + User: tc.user, + TargetRef: tc.targetRef, + SourceSha: tc.sourceSha, + FirstParentRef: tc.firstParentRef, + ExpectedOldOid: tc.expectedOldOID, + } + _, err := client.UserRebaseToRef(ctx, request) + testhelper.RequireGrpcError(t, err, tc.expectedError) + }) + } +} + +func TestUserRebaseToRef_conflict(t *testing.T) { + t.Parallel() + + ctx, cfg, client := setupOperationsServiceWithoutRepo(t, testhelper.Context(t)) + repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg) + + mergeBaseOID := gittest.WriteCommit(t, cfg, repoPath, gittest.WithMessage("first commit")) + + firstParentRef := "refs/heads/main" + firstParentRefBlobID := gittest.WriteBlob(t, cfg, repoPath, []byte("first parent ref blob")) + firstParentRefTreeID := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ + {Path: "file", Mode: "100644", OID: firstParentRefBlobID}, + }) + firstParentRefOID := gittest.WriteCommit(t, cfg, repoPath, gittest.WithMessage("first parent ref commit"), gittest.WithTree(firstParentRefTreeID), gittest.WithReference(firstParentRef), gittest.WithParents(mergeBaseOID)) + + sourceBlobID := gittest.WriteBlob(t, cfg, repoPath, []byte("source blob")) + + sourceOID := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithMessage("source commit"), + gittest.WithParents(mergeBaseOID), + gittest.WithTree(gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ + {Path: "file", Mode: "100644", OID: sourceBlobID}, + })), + ) + + targetRef := "refs/merge-requests/9999/merge" + targetRefOID := gittest.WriteCommit(t, cfg, repoPath, gittest.WithReference(targetRef)) + + request := &gitalypb.UserRebaseToRefRequest{ + Repository: repoProto, + User: gittest.TestUser, + TargetRef: []byte(targetRef), + SourceSha: sourceOID.String(), + FirstParentRef: []byte(firstParentRef), + } + response, err := client.UserRebaseToRef(ctx, request) + + require.Nil(t, response) + testhelper.RequireGrpcError(t, structerr.NewFailedPrecondition(fmt.Sprintf("failed to rebase %s on %s while preparing %s due to conflict", sourceOID, firstParentRefOID, targetRef)), err) + + currentTargetRefOID := gittest.ResolveRevision(t, cfg, repoPath, targetRef) + require.Equal(t, targetRefOID, currentTargetRefOID, "target ref should not change when the rebase fails due to GitError") +} |