fix(pull): keep the merged state in sync with git (#39062)

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
bircni
2026-08-26 01:04:49 +08:00
committed by GitHub
co-authored by wxiaoguang silverwind
parent 1680ac24e6
commit 38747d48fe
7 changed files with 66 additions and 60 deletions
+2 -3
View File
@@ -22,7 +22,6 @@ import (
"gitea.dev/modules/base" "gitea.dev/modules/base"
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd" "gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/optional" "gitea.dev/modules/optional"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
@@ -1041,7 +1040,7 @@ func MergePullRequest(ctx *context.APIContext) {
} }
} }
if err := pull_service.Merge(ctx, pr, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil { if err := pull_service.Merge(pr, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
if pull_service.IsErrInvalidMergeStyle(err) { if pull_service.IsErrInvalidMergeStyle(err) {
ctx.APIError(http.StatusMethodNotAllowed, fmt.Sprintf("%s is not allowed an allowed merge style for this repository", repo_model.MergeStyle(form.Do))) ctx.APIError(http.StatusMethodNotAllowed, fmt.Sprintf("%s is not allowed an allowed merge style for this repository", repo_model.MergeStyle(form.Do)))
} else if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok { } else if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok {
@@ -1275,7 +1274,7 @@ func UpdatePullRequest(ctx *context.APIContext) {
// default merge commit message // default merge commit message
message := fmt.Sprintf("Merge branch '%s' into %s", pr.BaseBranch, pr.HeadBranch) message := fmt.Sprintf("Merge branch '%s' into %s", pr.BaseBranch, pr.HeadBranch)
if err = pull_service.Update(graceful.GetManager().ShutdownContext(), pr, ctx.Doer, message, rebase); err != nil { if err = pull_service.Update(pr, ctx.Doer, message, rebase); err != nil {
if pull_service.IsErrMergeConflicts(err) { if pull_service.IsErrMergeConflicts(err) {
ctx.APIError(http.StatusConflict, "merge failed because of conflict") ctx.APIError(http.StatusConflict, "merge failed because of conflict")
return return
+5 -7
View File
@@ -29,7 +29,6 @@ import (
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd" "gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/glob" "gitea.dev/modules/glob"
"gitea.dev/modules/graceful"
issue_template "gitea.dev/modules/issue/template" issue_template "gitea.dev/modules/issue/template"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/optional" "gitea.dev/modules/optional"
@@ -1002,9 +1001,7 @@ func UpdatePullRequest(ctx *context.Context) {
// default merge commit message // default merge commit message
message := fmt.Sprintf("Merge branch '%s' into %s", issue.PullRequest.BaseBranch, issue.PullRequest.HeadBranch) message := fmt.Sprintf("Merge branch '%s' into %s", issue.PullRequest.BaseBranch, issue.PullRequest.HeadBranch)
// The update process should not be canceled by the user if err = pull_service.Update(issue.PullRequest, ctx.Doer, message, rebase); err != nil {
// so we set the context to be a background context
if err = pull_service.Update(graceful.GetManager().ShutdownContext(), issue.PullRequest, ctx.Doer, message, rebase); err != nil {
if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok { if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok {
flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{
"Message": ctx.Tr("repo.pulls.merge_conflict"), "Message": ctx.Tr("repo.pulls.merge_conflict"),
@@ -1149,7 +1146,7 @@ func MergePullRequest(ctx *context.Context) {
} }
} }
if err := pull_service.Merge(ctx, pr, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil { if err := pull_service.Merge(pr, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
if pull_service.IsErrInvalidMergeStyle(err) { if pull_service.IsErrInvalidMergeStyle(err) {
ctx.JSONError(ctx.Tr("repo.pulls.invalid_merge_option")) ctx.JSONError(ctx.Tr("repo.pulls.invalid_merge_option"))
} else if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok { } else if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok {
@@ -1213,13 +1210,14 @@ func MergePullRequest(ctx *context.Context) {
} }
log.Trace("Pull request merged: %d", pr.ID) log.Trace("Pull request merged: %d", pr.ID)
// FIXME: calling it here is wrong.
// 1. the ctx might have been canceled ("Merge" might take a very long time and the user closes their browser)
// 2. it is inconsistent with API/AutoMerge which all miss the call
if err := stopTimerIfAvailable(ctx, ctx.Doer, issue); err != nil { if err := stopTimerIfAvailable(ctx, ctx.Doer, issue); err != nil {
ctx.ServerError("stopTimerIfAvailable", err) ctx.ServerError("stopTimerIfAvailable", err)
return return
} }
log.Trace("Pull request merged: %d", pr.ID)
if deleteBranchAfterMerge { if deleteBranchAfterMerge {
deleteBranchAfterMergeAndFlashMessage(ctx, pr.ID) deleteBranchAfterMergeAndFlashMessage(ctx, pr.ID)
if ctx.Written() { if ctx.Written() {
+1 -1
View File
@@ -259,7 +259,7 @@ func handlePullRequestAutoMerge(pullID int64, sha string) {
return return
} }
if err := pull_service.Merge(ctx, pr, doer, scheduledPRM.MergeStyle, "", scheduledPRM.Message, true); err != nil { if err := pull_service.Merge(pr, doer, scheduledPRM.MergeStyle, "", scheduledPRM.Message, true); err != nil {
log.Error("pull_service.Merge: %v", err) log.Error("pull_service.Merge: %v", err)
// FIXME: if merge failed, we should display some error message to the pull request page. // FIXME: if merge failed, we should display some error message to the pull request page.
// The resolution is add a new column on automerge table named `error_message` to store the error message and displayed // The resolution is add a new column on automerge table named `error_message` to store the error message and displayed
+37 -27
View File
@@ -27,6 +27,7 @@ import (
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd" "gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/globallock" "gitea.dev/modules/globallock"
"gitea.dev/modules/graceful"
"gitea.dev/modules/httplib" "gitea.dev/modules/httplib"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/references" "gitea.dev/modules/references"
@@ -234,9 +235,28 @@ func (err ErrInvalidMergeStyle) Unwrap() error {
return util.ErrInvalidArgument return util.ErrInvalidArgument
} }
func addTestPullRequestTaskAfterWebOperation(pr *issues_model.PullRequest, doer *user_model.User) {
// This is a duplicated call to AddTestPullRequestTask (it will also be called by the post-receive hook, via a push queue).
// This call will do some operations (push to base repo, sync commit divergence, add PR conflict check queue task, etc)
// immediately instead of waiting for the "push queue"'s task. The code is from https://github.com/go-gitea/gitea/pull/7082.
// But it's really questionable whether it's worth to do it ahead without waiting for the "push queue" task to run.
// TODO: DUPLICATE-PR-TASK: maybe can try to remove this in 1.26 to see if there is any issue.
go AddTestPullRequestTask(TestPullRequestOptions{
RepoID: pr.BaseRepo.ID,
Doer: doer,
Branch: pr.BaseBranch,
IsSync: false,
IsForcePush: false,
OldCommitID: "",
NewCommitID: "",
})
}
// Merge merges pull request to base repository. // Merge merges pull request to base repository.
// Caller should check PR is ready to be merged (review and status checks) // Caller should check PR is ready to be merged (review and status checks)
func Merge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string, wasAutoMerged bool) error { func Merge(pr *issues_model.PullRequest, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string, wasAutoMerged bool) error {
ctx := graceful.GetManager().HammerContext() // don't abort the git operation even if the user's request is canceled
if err := pr.LoadBaseRepo(ctx); err != nil { if err := pr.LoadBaseRepo(ctx); err != nil {
log.Error("Unable to load base repo: %v", err) log.Error("Unable to load base repo: %v", err)
return fmt.Errorf("unable to load base repo: %w", err) return fmt.Errorf("unable to load base repo: %w", err)
@@ -257,37 +277,27 @@ func Merge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.U
return ErrInvalidMergeStyle{ID: pr.BaseRepo.ID, Style: mergeStyle} return ErrInvalidMergeStyle{ID: pr.BaseRepo.ID, Style: mergeStyle}
} }
releaser, err := globallock.Lock(ctx, getPullWorkingLockKey(pr.ID)) err = globallock.LockAndDo(ctx, getPullWorkingLockKey(pr.ID), func(ctx context.Context) error {
if err != nil { _, err := doMergeAndPush(ctx, pr, doer, mergeStyle, expectedHeadCommitID, message, repo_module.PushTriggerPRMergeToBase)
log.Error("lock.Lock(): %v", err) return err
return fmt.Errorf("lock.Lock: %w", err) })
} defer addTestPullRequestTaskAfterWebOperation(pr, doer) // keep the same behavior as old code: always call AddTestPullRequestTask
defer releaser() // TODO: the "merge" operation has finished, there could still be some edge cases:
defer func() { // * if the post-process hook isn't executed correctly:
// This is a duplicated call to AddTestPullRequestTask (it will also be called by the post-receive hook, via a push queue). // * the commit has been merged into target branch
// This call will do some operations (push to base repo, sync commit divergence, add PR conflict check queue task, etc) // * the PR's status is still "open (unmerged)"
// immediately instead of waiting for the "push queue"'s task. The code is from https://github.com/go-gitea/gitea/pull/7082. // * something wrong happens (e.g.: out of sync?)
// But it's really questionable whether it's worth to do it ahead without waiting for the "push queue" task to run. // * maybe this is the reason that why the duplicate AddTestPullRequestTask is called in defer func above
// TODO: DUPLICATE-PR-TASK: maybe can try to remove this in 1.26 to see if there is any issue.
go AddTestPullRequestTask(TestPullRequestOptions{
RepoID: pr.BaseRepo.ID,
Doer: doer,
Branch: pr.BaseBranch,
IsSync: false,
IsForcePush: false,
OldCommitID: "",
NewCommitID: "",
})
}()
_, err = doMergeAndPush(ctx, pr, doer, mergeStyle, expectedHeadCommitID, message, repo_module.PushTriggerPRMergeToBase)
releaser()
if err != nil { if err != nil {
return err return err
} }
// TODO: it is questionable whether it should return error here, the "merge" operation has succeeded
return handleMergePostProcess(ctx, pr.ID, doer, wasAutoMerged)
}
func handleMergePostProcess(ctx context.Context, prID int64, doer *user_model.User, wasAutoMerged bool) error {
// reload pull request because it has been updated by post receive hook // reload pull request because it has been updated by post receive hook
pr, err = issues_model.GetPullRequestByID(ctx, pr.ID) pr, err := issues_model.GetPullRequestByID(ctx, prID)
if err != nil { if err != nil {
return err return err
} }
+14 -15
View File
@@ -16,12 +16,14 @@ import (
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/globallock" "gitea.dev/modules/globallock"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/repository" "gitea.dev/modules/repository"
) )
// Update updates pull request with base branch. // Update updates pull request with base branch.
func Update(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, message string, rebase bool) error { func Update(pr *issues_model.PullRequest, doer *user_model.User, message string, rebase bool) error {
ctx := graceful.GetManager().HammerContext() // don't abort the git operation even if the user's request is canceled
if pr.Flow == issues_model.PullRequestFlowAGit { if pr.Flow == issues_model.PullRequestFlowAGit {
// TODO: update of agit flow pull request's head branch is unsupported // TODO: update of agit flow pull request's head branch is unsupported
return errors.New("update of agit flow pull request's head branch is unsupported") return errors.New("update of agit flow pull request's head branch is unsupported")
@@ -62,20 +64,10 @@ func Update(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.
return fmt.Errorf("unable to load HeadRepo for PR[%d] during update-by-merge: %w", pr.ID, err) return fmt.Errorf("unable to load HeadRepo for PR[%d] during update-by-merge: %w", pr.ID, err)
} }
defer func() { // TODO: The code is from https://github.com/go-gitea/gitea/pull/9784,
// The code is from https://github.com/go-gitea/gitea/pull/9784, // it seems a simple copy-paste from https://github.com/go-gitea/gitea/pull/7082 without a real reason.
// it seems a simple copy-paste from https://github.com/go-gitea/gitea/pull/7082 without a real reason. // TODO: DUPLICATE-PR-TASK: search and see another TODO comment for more details
// TODO: DUPLICATE-PR-TASK: search and see another TODO comment for more details defer addTestPullRequestTaskAfterWebOperation(pr, doer)
go AddTestPullRequestTask(TestPullRequestOptions{
RepoID: pr.BaseRepo.ID,
Doer: doer,
Branch: pr.BaseBranch,
IsSync: false,
IsForcePush: false,
OldCommitID: "",
NewCommitID: "",
})
}()
if rebase { if rebase {
return updateHeadByRebaseOnToBase(ctx, pr, doer) return updateHeadByRebaseOnToBase(ctx, pr, doer)
@@ -97,6 +89,13 @@ func Update(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.
} }
_, err = doMergeAndPush(ctx, reversePR, doer, repo_model.MergeStyleMerge, "", message, repository.PushTriggerPRUpdateWithBase) _, err = doMergeAndPush(ctx, reversePR, doer, repo_model.MergeStyleMerge, "", message, repository.PushTriggerPRUpdateWithBase)
// TODO: the "update" (merge target branch to PR head branch) operation has finished, there could still be some edge cases:
// * the database was already out of sync: the target branch was already in head branch:
// * so no post-receive hook is really executed, no PR status update
// * then the PR status is stuck in "behind the target branch" (a new push can be used as a workaround)
// * "merge" operation does finish, but the post-receive hook isn't correctly executed due to other reasons:
// * although the target branch is merged into head branch by this "update" (head branch receives new commits)
// * but database isn't updated, so the PR status is still "behind the target branch"
return err return err
} }
+1 -1
View File
@@ -87,7 +87,7 @@ func MergeUpstream(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_
BaseBranch: divergingInfo.BaseBranchName, BaseBranch: divergingInfo.BaseBranchName,
} }
fakeIssue.PullRequest = fakePR fakeIssue.PullRequest = fakePR
err = pull.Update(ctx, fakePR, doer, "merge upstream", false) err = pull.Update(fakePR, doer, "merge upstream", false)
if err != nil { if err != nil {
return "", err return "", err
} }
+6 -6
View File
@@ -360,11 +360,11 @@ func TestCantMergeConflict(t *testing.T) {
BaseBranch: "base", BaseBranch: "base",
}) })
err := pull_service.Merge(t.Context(), pr, user1, repo_model.MergeStyleMerge, "", "CONFLICT", false) err := pull_service.Merge(pr, user1, repo_model.MergeStyleMerge, "", "CONFLICT", false)
assert.Error(t, err, "Merge should return an error due to conflict") assert.Error(t, err, "Merge should return an error due to conflict")
assert.True(t, pull_service.IsErrMergeConflicts(err), "Merge error is not a conflict error") assert.True(t, pull_service.IsErrMergeConflicts(err), "Merge error is not a conflict error")
err = pull_service.Merge(t.Context(), pr, user1, repo_model.MergeStyleRebase, "", "CONFLICT", false) err = pull_service.Merge(pr, user1, repo_model.MergeStyleRebase, "", "CONFLICT", false)
assert.Error(t, err, "Merge should return an error due to conflict") assert.Error(t, err, "Merge should return an error due to conflict")
assert.True(t, pull_service.IsErrRebaseConflicts(err), "Merge error is not a conflict error") assert.True(t, pull_service.IsErrRebaseConflicts(err), "Merge error is not a conflict error")
}) })
@@ -455,7 +455,7 @@ func TestCantMergeUnrelated(t *testing.T) {
BaseBranch: "base", BaseBranch: "base",
}) })
err = pull_service.Merge(t.Context(), pr, user1, repo_model.MergeStyleMerge, "", "UNRELATED", false) err = pull_service.Merge(pr, user1, repo_model.MergeStyleMerge, "", "UNRELATED", false)
assert.Error(t, err, "Merge should return an error due to unrelated") assert.Error(t, err, "Merge should return an error due to unrelated")
assert.True(t, pull_service.IsErrMergeUnrelatedHistories(err), "Merge error is not a unrelated histories error") assert.True(t, pull_service.IsErrMergeUnrelatedHistories(err), "Merge error is not a unrelated histories error")
}) })
@@ -491,7 +491,7 @@ func TestFastForwardOnlyMerge(t *testing.T) {
BaseBranch: "master", BaseBranch: "master",
}) })
err := pull_service.Merge(t.Context(), pr, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false) err := pull_service.Merge(pr, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false)
assert.NoError(t, err) assert.NoError(t, err)
}) })
} }
@@ -578,7 +578,7 @@ func TestFastForwardOnlyMergeWithRequiredSignedCommits(t *testing.T) {
pb.RequireSignedCommits = false pb.RequireSignedCommits = false
require.NoError(t, git_model.UpdateProtectBranch(t.Context(), repo1, pb, git_model.WhitelistOptions{})) require.NoError(t, git_model.UpdateProtectBranch(t.Context(), repo1, pb, git_model.WhitelistOptions{}))
require.NoError(t, pull_service.Merge(t.Context(), pr, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false)) require.NoError(t, pull_service.Merge(pr, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false))
}) })
} }
@@ -613,7 +613,7 @@ func TestCantFastForwardOnlyMergeDiverging(t *testing.T) {
BaseBranch: "master", BaseBranch: "master",
}) })
err := pull_service.Merge(t.Context(), pr, user1, repo_model.MergeStyleFastForwardOnly, "", "DIVERGING", false) err := pull_service.Merge(pr, user1, repo_model.MergeStyleFastForwardOnly, "", "DIVERGING", false)
assert.Error(t, err, "Merge should return an error due to being for a diverging branch") assert.Error(t, err, "Merge should return an error due to being for a diverging branch")
assert.True(t, pull_service.IsErrMergeDivergingFastForwardOnly(err), "Merge error is not a diverging fast-forward-only error") assert.True(t, pull_service.IsErrMergeDivergingFastForwardOnly(err), "Merge error is not a diverging fast-forward-only error")
}) })