0
0
mirror of https://github.com/go-gitea/gitea.git synced 2025-11-25 18:52:54 +01:00

Use GitHub-style commit message for squash merge (#35987)

This commit is contained in:
Zettat123 2025-11-22 10:20:45 -07:00 committed by GitHub
parent 62d750eadb
commit ed977d9702
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 198 additions and 47 deletions

View File

@ -13,6 +13,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"time" "time"
"unicode/utf8"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git" git_model "code.gitea.io/gitea/models/git"
@ -838,51 +839,53 @@ func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequ
stringBuilder := strings.Builder{} stringBuilder := strings.Builder{}
if !setting.Repository.PullRequest.PopulateSquashCommentWithCommitMessages { if !setting.Repository.PullRequest.PopulateSquashCommentWithCommitMessages {
// use PR's title and description as squash commit message
message := strings.TrimSpace(pr.Issue.Content) message := strings.TrimSpace(pr.Issue.Content)
stringBuilder.WriteString(message) stringBuilder.WriteString(message)
if stringBuilder.Len() > 0 { if stringBuilder.Len() > 0 {
stringBuilder.WriteRune('\n') stringBuilder.WriteRune('\n')
if !commitMessageTrailersPattern.MatchString(message) { if !commitMessageTrailersPattern.MatchString(message) {
// TODO: this trailer check doesn't work with the separator line added below for the co-authors
stringBuilder.WriteRune('\n') stringBuilder.WriteRune('\n')
} }
} }
} else {
// use PR's commit messages as squash commit message
// commits list is in reverse chronological order
maxMsgSize := setting.Repository.PullRequest.DefaultMergeMessageSize
for i := len(commits) - 1; i >= 0; i-- {
commit := commits[i]
msg := strings.TrimSpace(commit.CommitMessage)
if msg == "" {
continue
}
// This format follows GitHub's squash commit message style,
// even if there are other "* " in the commit message body, they are written as-is.
// Maybe, ideally, we should indent those lines too.
_, _ = fmt.Fprintf(&stringBuilder, "* %s\n\n", msg)
if maxMsgSize > 0 && stringBuilder.Len() >= maxMsgSize {
tmp := stringBuilder.String()
wasValidUtf8 := utf8.ValidString(tmp)
tmp = tmp[:maxMsgSize] + "..."
if wasValidUtf8 {
// If the message was valid UTF-8 before truncation, ensure it remains valid after truncation
// For non-utf8 messages, we can't do much about it, end users should use utf-8 as much as possible
tmp = strings.ToValidUTF8(tmp, "")
}
stringBuilder.Reset()
stringBuilder.WriteString(tmp)
break
}
}
} }
// commits list is in reverse chronological order // collect co-authors
first := true for _, commit := range commits {
for i := len(commits) - 1; i >= 0; i-- {
commit := commits[i]
if setting.Repository.PullRequest.PopulateSquashCommentWithCommitMessages {
maxSize := setting.Repository.PullRequest.DefaultMergeMessageSize
if maxSize < 0 || stringBuilder.Len() < maxSize {
var toWrite []byte
if first {
first = false
toWrite = []byte(strings.TrimPrefix(commit.CommitMessage, pr.Issue.Title))
} else {
toWrite = []byte(commit.CommitMessage)
}
if len(toWrite) > maxSize-stringBuilder.Len() && maxSize > -1 {
toWrite = append(toWrite[:maxSize-stringBuilder.Len()], "..."...)
}
if _, err := stringBuilder.Write(toWrite); err != nil {
log.Error("Unable to write commit message Error: %v", err)
return ""
}
if _, err := stringBuilder.WriteRune('\n'); err != nil {
log.Error("Unable to write commit message Error: %v", err)
return ""
}
}
}
authorString := commit.Author.String() authorString := commit.Author.String()
if uniqueAuthors.Add(authorString) && authorString != posterSig { if uniqueAuthors.Add(authorString) && authorString != posterSig {
// Compare use account as well to avoid adding the same author multiple times // Compare use account as well to avoid adding the same author multiple times
// times when email addresses are private or multiple emails are used. // when email addresses are private or multiple emails are used.
commitUser, _ := user_model.GetUserByEmail(ctx, commit.Author.Email) commitUser, _ := user_model.GetUserByEmail(ctx, commit.Author.Email)
if commitUser == nil || commitUser.ID != pr.Issue.Poster.ID { if commitUser == nil || commitUser.ID != pr.Issue.Poster.ID {
authors = append(authors, authorString) authors = append(authors, authorString)
@ -890,12 +893,12 @@ func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequ
} }
} }
// Consider collecting the remaining authors // collect the remaining authors
if limit >= 0 && setting.Repository.PullRequest.DefaultMergeMessageAllAuthors { if limit >= 0 && setting.Repository.PullRequest.DefaultMergeMessageAllAuthors {
skip := limit skip := limit
limit = 30 limit = 30
for { for {
commits, err := gitRepo.CommitsBetweenLimit(headCommit, mergeBase, limit, skip) commits, err = gitRepo.CommitsBetweenLimit(headCommit, mergeBase, limit, skip)
if err != nil { if err != nil {
log.Error("Unable to get commits between: %s %s Error: %v", pr.HeadBranch, pr.MergeBase, err) log.Error("Unable to get commits between: %s %s Error: %v", pr.HeadBranch, pr.MergeBase, err)
return "" return ""
@ -916,19 +919,15 @@ func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequ
} }
} }
if stringBuilder.Len() > 0 && len(authors) > 0 {
// TODO: this separator line doesn't work with the trailer check (commitMessageTrailersPattern) above
stringBuilder.WriteString("---------\n\n")
}
for _, author := range authors { for _, author := range authors {
if _, err := stringBuilder.WriteString("Co-authored-by: "); err != nil { stringBuilder.WriteString("Co-authored-by: ")
log.Error("Unable to write to string builder Error: %v", err) stringBuilder.WriteString(author)
return "" stringBuilder.WriteRune('\n')
}
if _, err := stringBuilder.WriteString(author); err != nil {
log.Error("Unable to write to string builder Error: %v", err)
return ""
}
if _, err := stringBuilder.WriteRune('\n'); err != nil {
log.Error("Unable to write to string builder Error: %v", err)
return ""
}
} }
return stringBuilder.String() return stringBuilder.String()

View File

@ -19,6 +19,9 @@ import (
type createFileInBranchOptions struct { type createFileInBranchOptions struct {
OldBranch, NewBranch string OldBranch, NewBranch string
CommitMessage string
CommitterName string
CommitterEmail string
} }
func testCreateFileInBranch(t *testing.T, user *user_model.User, repo *repo_model.Repository, createOpts createFileInBranchOptions, files map[string]string) *api.FilesResponse { func testCreateFileInBranch(t *testing.T, user *user_model.User, repo *repo_model.Repository, createOpts createFileInBranchOptions, files map[string]string) *api.FilesResponse {
@ -29,7 +32,17 @@ func testCreateFileInBranch(t *testing.T, user *user_model.User, repo *repo_mode
func createFileInBranch(user *user_model.User, repo *repo_model.Repository, createOpts createFileInBranchOptions, files map[string]string) (*api.FilesResponse, error) { func createFileInBranch(user *user_model.User, repo *repo_model.Repository, createOpts createFileInBranchOptions, files map[string]string) (*api.FilesResponse, error) {
ctx := context.TODO() ctx := context.TODO()
opts := &files_service.ChangeRepoFilesOptions{OldBranch: createOpts.OldBranch, NewBranch: createOpts.NewBranch} opts := &files_service.ChangeRepoFilesOptions{
OldBranch: createOpts.OldBranch,
NewBranch: createOpts.NewBranch,
Message: createOpts.CommitMessage,
}
if createOpts.CommitterName != "" || createOpts.CommitterEmail != "" {
opts.Committer = &files_service.IdentityOptions{
GitUserName: createOpts.CommitterName,
GitUserEmail: createOpts.CommitterEmail,
}
}
for path, content := range files { for path, content := range files {
opts.Files = append(opts.Files, &files_service.ChangeRepoFile{ opts.Files = append(opts.Files, &files_service.ChangeRepoFile{
Operation: "create", Operation: "create",

View File

@ -33,6 +33,7 @@ import (
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/automerge" "code.gitea.io/gitea/services/automerge"
"code.gitea.io/gitea/services/automergequeue" "code.gitea.io/gitea/services/automergequeue"
pull_service "code.gitea.io/gitea/services/pull" pull_service "code.gitea.io/gitea/services/pull"
@ -41,6 +42,7 @@ import (
files_service "code.gitea.io/gitea/services/repository/files" files_service "code.gitea.io/gitea/services/repository/files"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
type MergeOptions struct { type MergeOptions struct {
@ -1212,3 +1214,140 @@ func TestPullSquashMergeEmpty(t *testing.T) {
}) })
}) })
} }
func TestPullSquashMessage(t *testing.T) {
onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
user2Session := loginUser(t, user2.Name)
defer test.MockVariableValue(&setting.Repository.PullRequest.PopulateSquashCommentWithCommitMessages, true)()
defer test.MockVariableValue(&setting.Repository.PullRequest.DefaultMergeMessageSize, 80)()
repo, err := repo_service.CreateRepository(t.Context(), user2, user2, repo_service.CreateRepoOptions{
Name: "squash-message-test",
Description: "Test squash message",
AutoInit: true,
Readme: "Default",
DefaultBranch: "main",
})
require.NoError(t, err)
type commitInfo struct {
userName string
commitMessage string
}
testCases := []struct {
name string
commitInfos []*commitInfo
expectedMessage string
}{
{
name: "Single-line messages",
commitInfos: []*commitInfo{
{
userName: user2.Name,
commitMessage: "commit msg 1",
},
{
userName: user2.Name,
commitMessage: "commit msg 2",
},
},
expectedMessage: `* commit msg 1
* commit msg 2
`,
},
{
name: "Multiple-line messages",
commitInfos: []*commitInfo{
{
userName: user2.Name,
commitMessage: `commit msg 1
Commit description.`,
},
{
userName: user2.Name,
commitMessage: `commit msg 2
- Detail 1
- Detail 2`,
},
},
expectedMessage: `* commit msg 1
Commit description.
* commit msg 2
- Detail 1
- Detail 2
`,
},
{
name: "Too long message",
commitInfos: []*commitInfo{
{
userName: user2.Name,
commitMessage: `loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong message`,
},
},
expectedMessage: `* looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo...`,
},
{
name: "Test Co-authored-by",
commitInfos: []*commitInfo{
{
userName: user2.Name,
commitMessage: "commit msg 1",
},
{
userName: "user4",
commitMessage: "commit msg 2",
},
},
expectedMessage: `* commit msg 1
* commit msg 2
---------
Co-authored-by: user4 <user4@example.com>
`,
},
}
for tcNum, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
branchName := "test-branch-" + strconv.Itoa(tcNum)
for infoIdx, info := range tc.commitInfos {
createFileOpts := createFileInBranchOptions{
CommitMessage: info.commitMessage,
CommitterName: info.userName,
CommitterEmail: util.Iif(info.userName != "", info.userName+"@example.com", ""),
OldBranch: util.Iif(infoIdx == 0, "main", branchName),
NewBranch: branchName,
}
testCreateFileInBranch(t, user2, repo, createFileOpts, map[string]string{"dummy-file-" + strconv.Itoa(infoIdx): "dummy content"})
}
resp := testPullCreateDirectly(t, user2Session, createPullRequestOptions{
BaseRepoOwner: user2.Name,
BaseRepoName: repo.Name,
BaseBranch: repo.DefaultBranch,
HeadBranch: branchName,
Title: "Pull for " + branchName,
})
elems := strings.Split(test.RedirectURL(resp), "/")
pullIndex, err := strconv.ParseInt(elems[4], 10, 64)
assert.NoError(t, err)
pullRequest := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, Index: pullIndex})
squashMergeCommitMessage := pull_service.GetSquashMergeCommitMessages(t.Context(), pullRequest)
assert.Equal(t, tc.expectedMessage, squashMergeCommitMessage)
})
}
})
}