diff --git a/modules/git/git.go b/modules/git/git.go index 82d4693bfc..8421a9f11c 100644 --- a/modules/git/git.go +++ b/modules/git/git.go @@ -40,13 +40,13 @@ var ( HasSSHExecutable bool - gitVersion *version.Version + GitVersion *version.Version ) // loadGitVersion returns current Git version from shell. Internal usage only. func loadGitVersion() error { // doesn't need RWMutex because it's executed by Init() - if gitVersion != nil { + if GitVersion != nil { return nil } stdout, _, runErr := NewCommand(DefaultContext, "version").RunStdString(nil) @@ -62,7 +62,7 @@ func loadGitVersion() error { versionString := fields[2] var err error - gitVersion, err = version.NewVersion(versionString) + GitVersion, err = version.NewVersion(versionString) return err } @@ -88,7 +88,7 @@ func SetExecutablePath(path string) error { return err } - if gitVersion.LessThan(versionRequired) { + if GitVersion.LessThan(versionRequired) { moreHint := "get git: https://git-scm.com/downloads" if runtime.GOOS == "linux" { // there are a lot of CentOS/RHEL users using old git, so we add a special hint for them @@ -97,7 +97,7 @@ func SetExecutablePath(path string) error { moreHint = "get git: https://git-scm.com/downloads/linux and https://ius.io" } } - return fmt.Errorf("installed git version %q is not supported, Gitea requires git version >= %q, %s", gitVersion.Original(), RequiredVersion, moreHint) + return fmt.Errorf("installed git version %q is not supported, Gitea requires git version >= %q, %s", GitVersion.Original(), RequiredVersion, moreHint) } return nil @@ -105,11 +105,11 @@ func SetExecutablePath(path string) error { // VersionInfo returns git version information func VersionInfo() string { - if gitVersion == nil { + if GitVersion == nil { return "(git not found)" } format := "%s" - args := []any{gitVersion.Original()} + args := []any{GitVersion.Original()} // Since git wire protocol has been released from git v2.18 if setting.Git.EnableAutoGitWireProtocol && CheckGitVersionAtLeast("2.18") == nil { format += ", Wire Protocol %s Enabled" @@ -346,8 +346,8 @@ func CheckGitVersionAtLeast(atLeast string) error { if err != nil { return err } - if gitVersion.Compare(atLeastVersion) < 0 { - return fmt.Errorf("installed git binary version %s is not at least %s", gitVersion.Original(), atLeast) + if GitVersion.Compare(atLeastVersion) < 0 { + return fmt.Errorf("installed git binary version %s is not at least %s", GitVersion.Original(), atLeast) } return nil } @@ -361,8 +361,8 @@ func CheckGitVersionEqual(equal string) error { if err != nil { return err } - if !gitVersion.Equal(atLeastVersion) { - return fmt.Errorf("installed git binary version %s is not equal to %s", gitVersion.Original(), equal) + if !GitVersion.Equal(atLeastVersion) { + return fmt.Errorf("installed git binary version %s is not equal to %s", GitVersion.Original(), equal) } return nil } diff --git a/modules/git/git_test.go b/modules/git/git_test.go index a3faf85f16..6ad5ce7b84 100644 --- a/modules/git/git_test.go +++ b/modules/git/git_test.go @@ -123,7 +123,7 @@ func TestSyncConfigGPGFormat(t *testing.T) { t.Run("Old version", func(t *testing.T) { oldVersion, err := version.NewVersion("2.33.0") require.NoError(t, err) - defer test.MockVariableValue(&gitVersion, oldVersion)() + defer test.MockVariableValue(&GitVersion, oldVersion)() require.ErrorContains(t, syncGitConfig(), "ssh signing requires Git >= 2.34.0") }) diff --git a/services/pull/merge_prepare.go b/services/pull/merge_prepare.go index fb09515dbd..fc70da10a4 100644 --- a/services/pull/merge_prepare.go +++ b/services/pull/merge_prepare.go @@ -236,10 +236,72 @@ func getDiffTree(ctx context.Context, repoPath, baseBranch, headBranch string, o // rebaseTrackingOnToBase checks out the tracking branch as staging and rebases it on to the base branch // if there is a conflict it will return a models.ErrRebaseConflicts func rebaseTrackingOnToBase(ctx *mergeContext, mergeStyle repo_model.MergeStyle) error { - // Checkout head branch - if err := git.NewCommand(ctx, "checkout", "-b").AddDynamicArguments(stagingBranch, trackingBranch). + // Create staging branch + if err := git.NewCommand(ctx, "branch").AddDynamicArguments(stagingBranch, trackingBranch). Run(ctx.RunOpts()); err != nil { - return fmt.Errorf("unable to git checkout tracking as staging in temp repo for %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), ctx.errbuf.String()) + return fmt.Errorf( + "unable to git branch tracking as staging in temp repo for %v: %w\n%s\n%s", + ctx.pr, err, + ctx.outbuf.String(), + ctx.errbuf.String(), + ) + } + ctx.outbuf.Reset() + ctx.errbuf.Reset() + + // Check git version for availability of git-replay. If it is available, we use + // it for performance and to preserve unknown commit headers like the + // "change-id" header used by Jujutsu and GitButler to track changes across + // rebase, amend etc. + if err := git.CheckGitVersionAtLeast("2.44"); err == nil { + // Use git-replay for performance and to preserve unknown headers, + // like the "change-id" header used by Jujutsu and GitButler. + if err := git.NewCommand(ctx, "replay", "--onto").AddDynamicArguments(baseBranch). + AddDynamicArguments(fmt.Sprintf("%s..%s", baseBranch, stagingBranch)). + Run(ctx.RunOpts()); err != nil { + // git-replay doesn't tell us which commit first created a merge conflict. + // In order to preserve the quality of our error messages, fall back to + // regular git-rebase. + goto regular_rebase + } + // git-replay worked, stdout contains the instructions for update-ref + updateRefInstructions := ctx.outbuf.String() + opts := ctx.RunOpts() + opts.Stdin = strings.NewReader(updateRefInstructions) + if err := git.NewCommand(ctx, "update-ref", "--stdin").Run(opts); err != nil { + return fmt.Errorf( + "Failed to update ref for %v: %w\n%s\n%s", + ctx.pr, + err, + ctx.outbuf.String(), + ctx.errbuf.String(), + ) + } + // Checkout staging branch + if err := git.NewCommand(ctx, "checkout").AddDynamicArguments(stagingBranch). + Run(ctx.RunOpts()); err != nil { + return fmt.Errorf( + "unable to git checkout staging in temp repo for %v: %w\n%s\n%s", + ctx.pr, + err, + ctx.outbuf.String(), + ctx.errbuf.String(), + ) + } + ctx.outbuf.Reset() + ctx.errbuf.Reset() + return nil + } + + // The available git version is too old to support git-replay, or git-replay + // failed and we want to determine the first commit that produced a + // merge-conflict. Fall back to regular rebase. +regular_rebase: + + // Checkout head branch + if err := git.NewCommand(ctx, "checkout").AddDynamicArguments(stagingBranch). + Run(ctx.RunOpts()); err != nil { + return fmt.Errorf("unable to git checkout staging in temp repo for %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), ctx.errbuf.String()) } ctx.outbuf.Reset() ctx.errbuf.Reset() diff --git a/tests/integration/pull_merge_test.go b/tests/integration/pull_merge_test.go index 774b96cb7a..7c4f84fe67 100644 --- a/tests/integration/pull_merge_test.go +++ b/tests/integration/pull_merge_test.go @@ -44,6 +44,7 @@ import ( webhook_service "forgejo.org/services/webhook" "forgejo.org/tests" + "github.com/hashicorp/go-version" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -306,9 +307,22 @@ func TestCantMergeConflict(t *testing.T) { require.Error(t, err, "Merge should return an error due to conflict") assert.True(t, models.IsErrMergeConflicts(err), "Merge error is not a conflict error") - err = pull.Merge(t.Context(), pr, user1, gitRepo, repo_model.MergeStyleRebase, "", "CONFLICT", false) - require.Error(t, err, "Merge should return an error due to conflict") - assert.True(t, models.IsErrRebaseConflicts(err), "Merge error is not a conflict error") + t.Run("Git version with replay", func(t *testing.T) { + require.NoError(t, git.CheckGitVersionAtLeast("2.44")) + + err = pull.Merge(t.Context(), pr, user1, gitRepo, repo_model.MergeStyleRebase, "", "CONFLICT", false) + require.Error(t, err, "Merge should return an error due to conflict") + assert.True(t, models.IsErrRebaseConflicts(err), "Merge error is not a conflict error") + }) + t.Run("Git version without replay", func(t *testing.T) { + oldVersion, err := version.NewVersion("2.43.0") + require.NoError(t, err) + defer test.MockVariableValue(&git.GitVersion, oldVersion)() + + err = pull.Merge(t.Context(), pr, user1, gitRepo, repo_model.MergeStyleRebase, "", "CONFLICT", false) + require.Error(t, err, "Merge should return an error due to conflict") + assert.True(t, models.IsErrRebaseConflicts(err), "Merge error is not a conflict error") + }) gitRepo.Close() }) }