gitea/tests/integration/repo_merge_upstream_test.go
Dan Čermák b8c9a0c323
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.
2025-06-19 12:29:10 -07:00

184 lines
8.2 KiB
Go

// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"net/url"
"strings"
"testing"
"time"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRepoMergeUpstream(t *testing.T) {
onGiteaRun(t, func(*testing.T, *url.URL) {
forkUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
baseUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: baseRepo.OwnerID})
checkFileContent := func(branch, exp string) {
req := NewRequest(t, "GET", fmt.Sprintf("/%s/test-repo-fork/raw/branch/%s/new-file.txt", forkUser.Name, branch))
resp := MakeRequest(t, req, http.StatusOK)
require.Equal(t, exp, resp.Body.String())
}
session := loginUser(t, forkUser.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
// create a fork
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseUser.Name, baseRepo.Name), &api.CreateForkOption{
Name: util.ToPointer("test-repo-fork"),
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusAccepted)
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: forkUser.ID, Name: "test-repo-fork"})
// create fork-branch
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": "fork-branch",
})
session.MakeRequest(t, req, http.StatusSeeOther)
queryMergeUpstreamButtonLink := func(htmlDoc *HTMLDoc) string {
return htmlDoc.Find(`button[data-url*="merge-upstream"]`).AttrOr("data-url", "")
}
t.Run("HeadBeforeBase", func(t *testing.T) {
// add a file in base repo
sessionBaseUser := loginUser(t, baseUser.Name)
require.NoError(t, createOrReplaceFileInBranch(baseUser, baseRepo, "new-file.txt", "master", "test-content-1"))
var mergeUpstreamLink string
t.Run("DetectDefaultBranch", func(t *testing.T) {
// the repo shows a prompt to "sync fork" (defaults to the default branch)
require.Eventually(t, func() bool {
resp := session.MakeRequest(t, NewRequestf(t, "GET", "/%s/test-repo-fork/src/branch/fork-branch", forkUser.Name), http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
mergeUpstreamLink = queryMergeUpstreamButtonLink(htmlDoc)
if mergeUpstreamLink == "" {
return false
}
respMsg, _ := htmlDoc.Find(".ui.message:not(.positive)").Html()
return strings.Contains(respMsg, `This branch is 1 commit behind <a href="/user2/repo1/src/branch/master">user2/repo1:master</a>`)
}, 5*time.Second, 100*time.Millisecond)
})
t.Run("DetectSameBranch", func(t *testing.T) {
// if the fork-branch name also exists in the base repo, then use that branch instead
req = NewRequestWithValues(t, "POST", "/user2/repo1/branches/_new/branch/master", map[string]string{
"_csrf": GetUserCSRFToken(t, sessionBaseUser),
"new_branch_name": "fork-branch",
})
sessionBaseUser.MakeRequest(t, req, http.StatusSeeOther)
require.Eventually(t, func() bool {
resp := session.MakeRequest(t, NewRequestf(t, "GET", "/%s/test-repo-fork/src/branch/fork-branch", forkUser.Name), http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
mergeUpstreamLink = queryMergeUpstreamButtonLink(htmlDoc)
if mergeUpstreamLink == "" {
return false
}
respMsg, _ := htmlDoc.Find(".ui.message:not(.positive)").Html()
return strings.Contains(respMsg, `This branch is 1 commit behind <a href="/user2/repo1/src/branch/fork-branch">user2/repo1:fork-branch</a>`)
}, 5*time.Second, 100*time.Millisecond)
})
// click the "sync fork" button
req = NewRequestWithValues(t, "POST", mergeUpstreamLink, map[string]string{"_csrf": GetUserCSRFToken(t, session)})
session.MakeRequest(t, req, http.StatusOK)
checkFileContent("fork-branch", "test-content-1")
// delete the "fork-branch" from the base repo
req = NewRequestWithValues(t, "POST", "/user2/repo1/branches/delete?name=fork-branch", map[string]string{
"_csrf": GetUserCSRFToken(t, sessionBaseUser),
})
sessionBaseUser.MakeRequest(t, req, http.StatusOK)
})
t.Run("BaseChangeAfterHeadChange", func(t *testing.T) {
// update the files: base first, head later, and check the prompt
require.NoError(t, createOrReplaceFileInBranch(baseUser, baseRepo, "new-file.txt", "master", "test-content-2"))
require.NoError(t, createOrReplaceFileInBranch(forkUser, forkRepo, "new-file-other.txt", "fork-branch", "test-content-other"))
// make sure the base branch's update time is before the fork, to make it test the complete logic
baseBranch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: baseRepo.ID, Name: "master"})
forkBranch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: forkRepo.ID, Name: "fork-branch"})
_, err := db.GetEngine(db.DefaultContext).ID(forkBranch.ID).Update(&git_model.Branch{UpdatedUnix: baseBranch.UpdatedUnix + 1})
require.NoError(t, err)
// the repo shows a prompt to "sync fork"
require.Eventually(t, func() bool {
resp := session.MakeRequest(t, NewRequestf(t, "GET", "/%s/test-repo-fork/src/branch/fork-branch", forkUser.Name), http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
respMsg, _ := htmlDoc.Find(".ui.message:not(.positive)").Html()
return strings.Contains(respMsg, `The base branch <a href="/user2/repo1/src/branch/master">user2/repo1:master</a> has new changes`)
}, 5*time.Second, 100*time.Millisecond)
// and do the merge-upstream by API
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/test-repo-fork/merge-upstream", forkUser.Name), &api.MergeUpstreamRequest{
Branch: "fork-branch",
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
checkFileContent("fork-branch", "test-content-2")
var mergeResp api.MergeUpstreamResponse
DecodeJSON(t, resp, &mergeResp)
assert.Equal(t, "merge", mergeResp.MergeStyle)
// after merge, there should be no "sync fork" button anymore
require.Eventually(t, func() bool {
resp := session.MakeRequest(t, NewRequestf(t, "GET", "/%s/test-repo-fork/src/branch/fork-branch", forkUser.Name), http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
return queryMergeUpstreamButtonLink(htmlDoc) == ""
}, 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)
})
})
}