Add ff_only parameter to POST /repos/{owner}/{repo}/merge-upstream (#34770)

The merge-upstream route was so far performing any kind of merge, even
those that would create merge commits and thus make your branch diverge
from upstream, requiring manual intervention via the git cli to undo the
damage.

With the new optional parameter ff_only, we can instruct gitea to error
out, if a non-fast-forward merge would be performed.
This commit is contained in:
Dan Čermák 2025-06-19 21:29:10 +02:00 committed by GitHub
parent 7346ae7cd4
commit b8c9a0c323
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 45 additions and 3 deletions

View File

@ -136,6 +136,7 @@ type UpdateBranchProtectionPriories struct {
type MergeUpstreamRequest struct { type MergeUpstreamRequest struct {
Branch string `json:"branch"` Branch string `json:"branch"`
FfOnly bool `json:"ff_only"`
} }
type MergeUpstreamResponse struct { type MergeUpstreamResponse struct {

View File

@ -1181,7 +1181,7 @@ func MergeUpstream(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
form := web.GetForm(ctx).(*api.MergeUpstreamRequest) form := web.GetForm(ctx).(*api.MergeUpstreamRequest)
mergeStyle, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, form.Branch) mergeStyle, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, form.Branch, form.FfOnly)
if err != nil { if err != nil {
if errors.Is(err, util.ErrInvalidArgument) { if errors.Is(err, util.ErrInvalidArgument) {
ctx.APIError(http.StatusBadRequest, err) ctx.APIError(http.StatusBadRequest, err)

View File

@ -258,7 +258,7 @@ func CreateBranch(ctx *context.Context) {
func MergeUpstream(ctx *context.Context) { func MergeUpstream(ctx *context.Context) {
branchName := ctx.FormString("branch") branchName := ctx.FormString("branch")
_, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName) _, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName, false)
if err != nil { if err != nil {
if errors.Is(err, util.ErrNotExist) { if errors.Is(err, util.ErrNotExist) {
ctx.JSONErrorNotFound() ctx.JSONErrorNotFound()

View File

@ -18,7 +18,7 @@ import (
) )
// MergeUpstream merges the base repository's default branch into the fork repository's current branch. // MergeUpstream merges the base repository's default branch into the fork repository's current branch.
func MergeUpstream(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, branch string) (mergeStyle string, err error) { func MergeUpstream(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, branch string, ffOnly bool) (mergeStyle string, err error) {
if err = repo.MustNotBeArchived(); err != nil { if err = repo.MustNotBeArchived(); err != nil {
return "", err return "", err
} }
@ -45,6 +45,11 @@ func MergeUpstream(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_
return "", err return "", err
} }
// If ff_only is requested and fast-forward failed, return error
if ffOnly {
return "", util.NewInvalidArgumentErrorf("fast-forward merge not possible: branch has diverged")
}
// TODO: FakePR: it is somewhat hacky, but it is the only way to "merge" at the moment // TODO: FakePR: it is somewhat hacky, but it is the only way to "merge" at the moment
// ideally in the future the "merge" functions should be refactored to decouple from the PullRequest // ideally in the future the "merge" functions should be refactored to decouple from the PullRequest
fakeIssue := &issue_model.Issue{ fakeIssue := &issue_model.Issue{

View File

@ -24839,6 +24839,10 @@
"branch": { "branch": {
"type": "string", "type": "string",
"x-go-name": "Branch" "x-go-name": "Branch"
},
"ff_only": {
"type": "boolean",
"x-go-name": "FfOnly"
} }
}, },
"x-go-package": "code.gitea.io/gitea/modules/structs" "x-go-package": "code.gitea.io/gitea/modules/structs"

View File

@ -147,5 +147,37 @@ func TestRepoMergeUpstream(t *testing.T) {
return queryMergeUpstreamButtonLink(htmlDoc) == "" return queryMergeUpstreamButtonLink(htmlDoc) == ""
}, 5*time.Second, 100*time.Millisecond) }, 5*time.Second, 100*time.Millisecond)
}) })
t.Run("FastForwardOnly", func(t *testing.T) {
// Create a clean branch for fast-forward testing
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/test-repo-fork/branches/_new/branch/master", forkUser.Name), map[string]string{
"_csrf": GetUserCSRFToken(t, session),
"new_branch_name": "ff-test-branch",
})
session.MakeRequest(t, req, http.StatusSeeOther)
// Add content to base repository that can be fast-forwarded
require.NoError(t, createOrReplaceFileInBranch(baseUser, baseRepo, "ff-test.txt", "master", "ff-content-1"))
// ff_only=true with fast-forward possible (should succeed)
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/test-repo-fork/merge-upstream", forkUser.Name), &api.MergeUpstreamRequest{
Branch: "ff-test-branch",
FfOnly: true,
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var mergeResp api.MergeUpstreamResponse
DecodeJSON(t, resp, &mergeResp)
assert.Equal(t, "fast-forward", mergeResp.MergeStyle)
// ff_only=true when fast-forward is not possible (should fail)
require.NoError(t, createOrReplaceFileInBranch(baseUser, baseRepo, "another-file.txt", "master", "more-content"))
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/test-repo-fork/merge-upstream", forkUser.Name), &api.MergeUpstreamRequest{
Branch: "fork-branch",
FfOnly: true,
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusBadRequest)
})
}) })
} }