Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitaly.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPatrick Steinhardt <psteinhardt@gitlab.com>2023-08-16 10:01:19 +0300
committerPatrick Steinhardt <psteinhardt@gitlab.com>2023-08-16 10:01:19 +0300
commitbdf67f3a5687a6be53eb33ce42f78df3ea011cc9 (patch)
treece70487f19f3ee3c4e0dc88269939d1550a40c1b
parent0f25c6473acdcbefa4b668096108c07e5bb9626f (diff)
parenta7f9db080b1eee2e5a986acec9d1228243144359 (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.go10
-rw-r--r--internal/git/localrepo/rebase.go249
-rw-r--r--internal/git/localrepo/rebase_test.go1019
-rw-r--r--internal/git/signature.go27
-rw-r--r--internal/git/signature_test.go57
-rw-r--r--internal/git2go/signature.go28
-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.go116
-rw-r--r--internal/gitaly/service/operations/rebase_to_ref_test.go237
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: &timestamppb.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: &timestamppb.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: &timestamppb.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")
+}