Skip to content

Commit dd78d87

Browse files
GiteaBotjasonlearstclaudewxiaoguang
authored
fix: merge autodetect can't close other PRs but only the last one when multiple PRs are pushed at once (#37512) (#37516)
Backport #37512 Fixes #37510. Co-authored-by: Jason Learst <jason@jasonlearst.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
1 parent e2b211f commit dd78d87

4 files changed

Lines changed: 74 additions & 22 deletions

File tree

modules/git/catfile_batch_command.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import (
77
"context"
88
"os"
99
"path/filepath"
10+
"strings"
1011

1112
"code.gitea.io/gitea/modules/git/gitcmd"
13+
"code.gitea.io/gitea/modules/setting"
1214
"code.gitea.io/gitea/modules/util"
1315
)
1416

@@ -39,6 +41,9 @@ func (b *catFileBatchCommand) getBatch() *catFileBatchCommunicator {
3941
}
4042

4143
func (b *catFileBatchCommand) QueryContent(obj string) (*CatFileObject, BufferedReader, error) {
44+
if strings.Contains(obj, "\n") {
45+
setting.PanicInDevOrTesting("invalid object name with newline: %q", obj)
46+
}
4247
_, err := b.getBatch().reqWriter.Write([]byte("contents " + obj + "\n"))
4348
if err != nil {
4449
return nil, nil, err
@@ -51,6 +56,9 @@ func (b *catFileBatchCommand) QueryContent(obj string) (*CatFileObject, Buffered
5156
}
5257

5358
func (b *catFileBatchCommand) QueryInfo(obj string) (*CatFileObject, error) {
59+
if strings.Contains(obj, "\n") {
60+
setting.PanicInDevOrTesting("invalid object name with newline: %q", obj)
61+
}
5462
_, err := b.getBatch().reqWriter.Write([]byte("info " + obj + "\n"))
5563
if err != nil {
5664
return nil, err

modules/git/catfile_batch_legacy.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import (
88
"io"
99
"os"
1010
"path/filepath"
11+
"strings"
1112

1213
"code.gitea.io/gitea/modules/git/gitcmd"
14+
"code.gitea.io/gitea/modules/setting"
1315
"code.gitea.io/gitea/modules/util"
1416
)
1517

@@ -50,6 +52,9 @@ func (b *catFileBatchLegacy) getBatchCheck() *catFileBatchCommunicator {
5052
}
5153

5254
func (b *catFileBatchLegacy) QueryContent(obj string) (*CatFileObject, BufferedReader, error) {
55+
if strings.Contains(obj, "\n") {
56+
setting.PanicInDevOrTesting("invalid object name with newline: %q", obj)
57+
}
5358
_, err := io.WriteString(b.getBatchContent().reqWriter, obj+"\n")
5459
if err != nil {
5560
return nil, nil, err
@@ -62,6 +67,9 @@ func (b *catFileBatchLegacy) QueryContent(obj string) (*CatFileObject, BufferedR
6267
}
6368

6469
func (b *catFileBatchLegacy) QueryInfo(obj string) (*CatFileObject, error) {
70+
if strings.Contains(obj, "\n") {
71+
setting.PanicInDevOrTesting("invalid object name with newline: %q", obj)
72+
}
6573
_, err := io.WriteString(b.getBatchCheck().reqWriter, obj+"\n")
6674
if err != nil {
6775
return nil, err

services/pull/check.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -313,18 +313,25 @@ func getMergeCommit(ctx context.Context, pr *issues_model.PullRequest) (*git.Com
313313

314314
objectFormat := git.ObjectFormatFromName(pr.BaseRepo.ObjectFormatName)
315315

316-
// Get the commit from BaseBranch where the pull request got merged
316+
// Get the commit from BaseBranch where the pull request got merged.
317+
// When several PRs targeting the same base are merged in a single push,
318+
// rev-list returns one line per merge commit on the ancestry path; we
319+
// only want the first one (the oldest, with --reverse, i.e. the merge
320+
// commit that actually introduced this PR).
317321
mergeCommit, _, err := gitrepo.RunCmdString(ctx, pr.BaseRepo,
318322
gitcmd.NewCommand("rev-list", "--ancestry-path", "--merges", "--reverse").
319323
AddDynamicArguments(prHeadCommitID+".."+pr.BaseBranch))
320324
if err != nil {
321325
return nil, fmt.Errorf("git rev-list --ancestry-path --merges --reverse: %w", err)
322-
} else if len(mergeCommit) < objectFormat.FullLength() {
326+
}
327+
328+
// only use the latest commit as merge commit if the output contains multiple commits
329+
mergeCommit = strings.TrimSpace(mergeCommit)
330+
mergeCommit, _, _ = strings.Cut(mergeCommit, "\n")
331+
if len(mergeCommit) < objectFormat.FullLength() {
323332
// PR was maybe fast-forwarded, so just use last commit of PR
324333
mergeCommit = prHeadCommitID
325334
}
326-
mergeCommit = strings.TrimSpace(mergeCommit)
327-
328335
commit, err := gitRepo.GetCommit(mergeCommit)
329336
if err != nil {
330337
return nil, fmt.Errorf("GetMergeCommit[%s]: %w", mergeCommit, err)

tests/integration/manual_merge_test.go

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@ package integration
66
import (
77
"fmt"
88
"net/url"
9+
"strings"
910
"testing"
1011
"time"
1112

1213
auth_model "code.gitea.io/gitea/models/auth"
1314
issues_model "code.gitea.io/gitea/models/issues"
15+
repo_model "code.gitea.io/gitea/models/repo"
1416
"code.gitea.io/gitea/models/unittest"
1517
user_model "code.gitea.io/gitea/models/user"
18+
"code.gitea.io/gitea/modules/git/gitcmd"
1619
"code.gitea.io/gitea/modules/setting"
1720
api "code.gitea.io/gitea/modules/structs"
1821

@@ -26,7 +29,6 @@ func TestManualMergeAutodetect(t *testing.T) {
2629
// user1 is the pusher/merger
2730
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
2831
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
29-
session2 := loginUser(t, user2.Name)
3032

3133
// Create a repo owned by user2
3234
repoName := "manual-merge-autodetect"
@@ -41,35 +43,62 @@ func TestManualMergeAutodetect(t *testing.T) {
4143
AutodetectManualMerge: new(true),
4244
})(t)
4345

44-
// Create a PR from a branch
45-
branchName := "feature"
46-
testEditFileToNewBranch(t, session2, user2.Name, repoName, defaultBranch, branchName, "README.md", "Manual Merge Test")
46+
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: repoName})
47+
user1Ctx := NewAPITestContext(t, user1.Name, repoName, auth_model.AccessTokenScopeWriteRepository)
4748

48-
apiPull, err := doAPICreatePullRequest(NewAPITestContext(t, user1.Name, repoName, auth_model.AccessTokenScopeWriteRepository), user2.Name, repoName, defaultBranch, branchName)(t)
49-
assert.NoError(t, err)
49+
// multiple PRs should be able to be closed together if a push contains their branch commits.
50+
branchNames := []string{"fix-1", "fix-2"}
51+
apiPulls := make([]api.PullRequest, len(branchNames))
52+
for i, branchName := range branchNames {
53+
_, err := createFileInBranch(user2, repo,
54+
createFileInBranchOptions{OldBranch: defaultBranch, NewBranch: branchName},
55+
map[string]string{"test-file-" + branchName: "dummy"},
56+
)
57+
require.NoError(t, err)
58+
apiPulls[i], err = doAPICreatePullRequest(user1Ctx, user2.Name, repoName, defaultBranch, branchName)(t)
59+
require.NoError(t, err)
60+
}
5061

51-
// user1 clones and pushes the branch to master (fast-forward)
62+
// user1 clones, then merges every branch sequentially, then pushes once.
63+
// The first merge fast-forwards; the rest produce real merge commits,
64+
// which generates multiple commits for "git rev-list --ancestry-path --merges ...".
5265
dstPath := t.TempDir()
5366
u, _ := url.Parse(giteaURL.String())
5467
u.Path = fmt.Sprintf("%s/%s.git", user2.Name, repoName)
5568
u.User = url.UserPassword(user1.Name, userPassword)
5669

5770
doGitClone(dstPath, u)(t)
58-
doGitMerge(dstPath, "origin/"+branchName)(t)
71+
72+
// Capture each branch's expected merge commit hash from the local clone,
73+
// so we can assert that Gitea recorded the correct merge commit per PR
74+
// (and not just "some merge commit" — see the regression where every PR
75+
// was attributed to the last merge in the push).
76+
expectedMergeCommits := make([]string, len(branchNames))
77+
for i, branchName := range branchNames {
78+
doGitMerge(dstPath, "--no-ff", "-m", "merge "+branchName, "origin/"+branchName)(t)
79+
head, _, cmdErr := gitcmd.NewCommand("rev-parse", "HEAD").WithDir(dstPath).RunStdString(t.Context())
80+
require.NoError(t, cmdErr)
81+
expectedMergeCommits[i] = strings.TrimSpace(head)
82+
}
5983
doGitPushTestRepository(dstPath, "origin", defaultBranch)(t)
6084

61-
// Wait for the PR to be marked as merged by the background task
62-
var pr *issues_model.PullRequest
85+
// Every PR should be detected as manually merged by the background task, not just the last one.
6386
require.Eventually(t, func() bool {
64-
pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: apiPull.ID})
65-
return pr.HasMerged
87+
for _, apiPull := range apiPulls {
88+
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: apiPull.ID})
89+
if !pr.HasMerged {
90+
return false
91+
}
92+
}
93+
return true
6694
}, 10*time.Second, 100*time.Millisecond)
6795

68-
// Check if the PR is merged and who is the merger
69-
pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: apiPull.ID})
70-
assert.True(t, pr.HasMerged)
71-
assert.Equal(t, issues_model.PullRequestStatusManuallyMerged, pr.Status)
72-
// Merger should be user1 (the pusher), not the commit author (user2) or repo owner (user2)
73-
assert.Equal(t, user1.ID, pr.MergerID)
96+
for i, apiPull := range apiPulls {
97+
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: apiPull.ID})
98+
assert.Truef(t, pr.HasMerged, "PR %d (%s) should be marked as merged", i+1, branchNames[i])
99+
assert.Equalf(t, issues_model.PullRequestStatusManuallyMerged, pr.Status, "PR %d (%s) should have ManuallyMerged status", i+1, branchNames[i])
100+
assert.Equalf(t, user1.ID, pr.MergerID, "PR %d (%s) merger should be the pusher", i+1, branchNames[i])
101+
assert.Equalf(t, expectedMergeCommits[i], pr.MergedCommitID, "PR %d (%s) should be attributed to its own merge commit, not another PR's", i+1, branchNames[i])
102+
}
74103
})
75104
}

0 commit comments

Comments
 (0)