diff --git a/routers/web/repo/cherry_pick.go b/routers/web/repo/cherry_pick.go index 33d941c9d88..616bb8c6cbb 100644 --- a/routers/web/repo/cherry_pick.go +++ b/routers/web/repo/cherry_pick.go @@ -114,11 +114,19 @@ func CherryPickPost(ctx *context.Context) { message += "\n\n" + form.CommitMessage } + gitCommitter, valid := WebGitOperationGetCommitChosenEmailIdentity(ctx, form.CommitEmail) + if !valid { + ctx.Data["Err_CommitEmail"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.invalid_commit_email"), tplCherryPick, &form) + return + } opts := &files.ApplyDiffPatchOptions{ LastCommitID: form.LastCommit, OldBranch: ctx.Repo.BranchName, NewBranch: branchName, Message: message, + Author: gitCommitter, + Committer: gitCommitter, } // First lets try the simple plain read-tree -m approach diff --git a/routers/web/repo/editor.go b/routers/web/repo/editor.go index 48e041fb1dc..cc4ffc698dd 100644 --- a/routers/web/repo/editor.go +++ b/routers/web/repo/editor.go @@ -13,7 +13,6 @@ import ( git_model "code.gitea.io/gitea/models/git" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" - user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/json" @@ -103,18 +102,6 @@ func getParentTreeFields(treePath string) (treeNames, treePaths []string) { return treeNames, treePaths } -func getCandidateEmailAddresses(ctx *context.Context) []string { - emails, err := user_model.GetActivatedEmailAddresses(ctx, ctx.Doer.ID) - if err != nil { - log.Error("getCandidateEmailAddresses: GetActivatedEmailAddresses: %v", err) - } - - if ctx.Doer.KeepEmailPrivate { - emails = append([]string{ctx.Doer.GetPlaceholderEmail()}, emails...) - } - return emails -} - func editFileCommon(ctx *context.Context, isNewFile bool) { ctx.Data["PageIsEdit"] = true ctx.Data["IsNewFile"] = isNewFile @@ -123,8 +110,6 @@ func editFileCommon(ctx *context.Context, isNewFile bool) { ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") ctx.Data["IsEditingFileOnly"] = ctx.FormString("return_uri") != "" ctx.Data["ReturnURI"] = ctx.FormString("return_uri") - ctx.Data["CommitCandidateEmails"] = getCandidateEmailAddresses(ctx) - ctx.Data["CommitDefaultEmail"] = ctx.Doer.GetEmail() } func editFile(ctx *context.Context, isNewFile bool) { @@ -287,15 +272,11 @@ func editFilePost(ctx *context.Context, form forms.EditRepoFileForm, isNewFile b message += "\n\n" + form.CommitMessage } - gitCommitter := &files_service.IdentityOptions{} - if form.CommitEmail != "" { - if util.SliceContainsString(getCandidateEmailAddresses(ctx), form.CommitEmail, true) { - gitCommitter.GitUserEmail = form.CommitEmail - } else { - ctx.Data["Err_CommitEmail"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.invalid_commit_email"), tplEditFile, &form) - return - } + gitCommitter, valid := WebGitOperationGetCommitChosenEmailIdentity(ctx, form.CommitEmail) + if !valid { + ctx.Data["Err_CommitEmail"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.invalid_commit_email"), tplEditFile, &form) + return } operation := "update" @@ -515,6 +496,13 @@ func DeleteFilePost(ctx *context.Context) { message += "\n\n" + form.CommitMessage } + gitCommitter, valid := WebGitOperationGetCommitChosenEmailIdentity(ctx, form.CommitEmail) + if !valid { + ctx.Data["Err_CommitEmail"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.invalid_commit_email"), tplDeleteFile, &form) + return + } + if _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{ LastCommitID: form.LastCommit, OldBranch: ctx.Repo.BranchName, @@ -525,8 +513,10 @@ func DeleteFilePost(ctx *context.Context) { TreePath: ctx.Repo.TreePath, }, }, - Message: message, - Signoff: form.Signoff, + Message: message, + Signoff: form.Signoff, + Author: gitCommitter, + Committer: gitCommitter, }); err != nil { // This is where we handle all the errors thrown by repofiles.DeleteRepoFile if git.IsErrNotExist(err) || files_service.IsErrRepoFileDoesNotExist(err) { @@ -726,6 +716,13 @@ func UploadFilePost(ctx *context.Context) { message += "\n\n" + form.CommitMessage } + gitCommitter, valid := WebGitOperationGetCommitChosenEmailIdentity(ctx, form.CommitEmail) + if !valid { + ctx.Data["Err_CommitEmail"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.invalid_commit_email"), tplUploadFile, &form) + return + } + if err := files_service.UploadRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.UploadRepoFileOptions{ LastCommitID: ctx.Repo.CommitID, OldBranch: oldBranchName, @@ -734,6 +731,8 @@ func UploadFilePost(ctx *context.Context) { Message: message, Files: form.Files, Signoff: form.Signoff, + Author: gitCommitter, + Committer: gitCommitter, }); err != nil { if git_model.IsErrLFSFileLocked(err) { ctx.Data["Err_TreePath"] = true diff --git a/routers/web/repo/patch.go b/routers/web/repo/patch.go index 4d47a705d6e..120b3469f68 100644 --- a/routers/web/repo/patch.go +++ b/routers/web/repo/patch.go @@ -66,7 +66,7 @@ func NewDiffPatchPost(ctx *context.Context) { return } - // Cannot commit to a an existing branch if user doesn't have rights + // Cannot commit to an existing branch if user doesn't have rights if branchName == ctx.Repo.BranchName && !canCommit { ctx.Data["Err_NewBranchName"] = true ctx.Data["commit_choice"] = frmCommitChoiceNewBranch @@ -86,12 +86,21 @@ func NewDiffPatchPost(ctx *context.Context) { message += "\n\n" + form.CommitMessage } + gitCommitter, valid := WebGitOperationGetCommitChosenEmailIdentity(ctx, form.CommitEmail) + if !valid { + ctx.Data["Err_CommitEmail"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.invalid_commit_email"), tplPatchFile, &form) + return + } + fileResponse, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, &files.ApplyDiffPatchOptions{ LastCommitID: form.LastCommit, OldBranch: ctx.Repo.BranchName, NewBranch: branchName, Message: message, Content: strings.ReplaceAll(form.Content, "\r", ""), + Author: gitCommitter, + Committer: gitCommitter, }) if err != nil { if git_model.IsErrBranchAlreadyExists(err) { diff --git a/routers/web/repo/webgit.go b/routers/web/repo/webgit.go new file mode 100644 index 00000000000..5f390197e72 --- /dev/null +++ b/routers/web/repo/webgit.go @@ -0,0 +1,40 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + files_service "code.gitea.io/gitea/services/repository/files" +) + +func WebGitOperationCommonData(ctx *context.Context) { + // TODO: more places like "wiki page" and "merging a pull request or creating an auto merge merging task" + emails, err := user_model.GetActivatedEmailAddresses(ctx, ctx.Doer.ID) + if err != nil { + log.Error("WebGitOperationCommonData: GetActivatedEmailAddresses: %v", err) + } + if ctx.Doer.KeepEmailPrivate { + emails = append([]string{ctx.Doer.GetPlaceholderEmail()}, emails...) + } + ctx.Data["CommitCandidateEmails"] = emails + ctx.Data["CommitDefaultEmail"] = ctx.Doer.GetEmail() +} + +func WebGitOperationGetCommitChosenEmailIdentity(ctx *context.Context, email string) (_ *files_service.IdentityOptions, valid bool) { + if ctx.Data["CommitCandidateEmails"] == nil { + setting.PanicInDevOrTesting("no CommitCandidateEmails in context data") + } + emails, _ := ctx.Data["CommitCandidateEmails"].([]string) + if email == "" { + return nil, true + } + if util.SliceContainsString(emails, email, true) { + return &files_service.IdentityOptions{GitUserEmail: email}, true + } + return nil, false +} diff --git a/routers/web/web.go b/routers/web/web.go index 096f1e6bbeb..e85ff9d3311 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1294,21 +1294,20 @@ func registerRoutes(m *web.Router) { m.Group("/{username}/{reponame}", func() { // repo code m.Group("", func() { m.Group("", func() { + m.Post("/_preview/*", web.Bind(forms.EditPreviewDiffForm{}), repo.DiffPreviewPost) m.Combo("/_edit/*").Get(repo.EditFile). Post(web.Bind(forms.EditRepoFileForm{}), repo.EditFilePost) m.Combo("/_new/*").Get(repo.NewFile). Post(web.Bind(forms.EditRepoFileForm{}), repo.NewFilePost) - m.Post("/_preview/*", web.Bind(forms.EditPreviewDiffForm{}), repo.DiffPreviewPost) m.Combo("/_delete/*").Get(repo.DeleteFile). Post(web.Bind(forms.DeleteRepoFileForm{}), repo.DeleteFilePost) - m.Combo("/_upload/*", repo.MustBeAbleToUpload). - Get(repo.UploadFile). + m.Combo("/_upload/*", repo.MustBeAbleToUpload).Get(repo.UploadFile). Post(web.Bind(forms.UploadRepoFileForm{}), repo.UploadFilePost) m.Combo("/_diffpatch/*").Get(repo.NewDiffPatch). Post(web.Bind(forms.EditRepoFileForm{}), repo.NewDiffPatchPost) m.Combo("/_cherrypick/{sha:([a-f0-9]{7,64})}/*").Get(repo.CherryPick). Post(web.Bind(forms.CherryPickForm{}), repo.CherryPickPost) - }, context.RepoRefByType(git.RefTypeBranch), context.CanWriteToBranch()) + }, context.RepoRefByType(git.RefTypeBranch), context.CanWriteToBranch(), repo.WebGitOperationCommonData) m.Group("", func() { m.Post("/upload-file", repo.UploadFileToServer) m.Post("/upload-remove", web.Bind(forms.RemoveUploadFileForm{}), repo.RemoveUploadFileFromServer) diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 40e15f2d5c5..2c6373e03cf 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -756,6 +756,7 @@ type CherryPickForm struct { LastCommit string Revert bool Signoff bool + CommitEmail string } // Validate validates the fields @@ -781,6 +782,7 @@ type UploadRepoFileForm struct { NewBranchName string `binding:"GitRefName;MaxSize(100)"` Files []string Signoff bool + CommitEmail string } // Validate validates the fields @@ -815,6 +817,7 @@ type DeleteRepoFileForm struct { NewBranchName string `binding:"GitRefName;MaxSize(100)"` LastCommit string Signoff bool + CommitEmail string } // Validate validates the fields diff --git a/services/repository/files/upload.go b/services/repository/files/upload.go index af32bc4c85b..3c58598427e 100644 --- a/services/repository/files/upload.go +++ b/services/repository/files/upload.go @@ -27,6 +27,8 @@ type UploadRepoFileOptions struct { Message string Files []string // In UUID format. Signoff bool + Author *IdentityOptions + Committer *IdentityOptions } type uploadInfo struct { @@ -130,11 +132,13 @@ func UploadRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use // Now commit the tree commitOpts := &CommitTreeUserOptions{ - ParentCommitID: opts.LastCommitID, - TreeHash: treeHash, - CommitMessage: opts.Message, - SignOff: opts.Signoff, - DoerUser: doer, + ParentCommitID: opts.LastCommitID, + TreeHash: treeHash, + CommitMessage: opts.Message, + SignOff: opts.Signoff, + DoerUser: doer, + AuthorIdentity: opts.Author, + CommitterIdentity: opts.Committer, } commitHash, err := t.CommitTree(commitOpts) if err != nil { diff --git a/tests/integration/editor_test.go b/tests/integration/editor_test.go index 08798c10283..03f09ee1ea7 100644 --- a/tests/integration/editor_test.go +++ b/tests/integration/editor_test.go @@ -4,7 +4,10 @@ package integration import ( + "bytes" "fmt" + "io" + "mime/multipart" "net/http" "net/http/httptest" "net/url" @@ -181,101 +184,150 @@ func TestEditFileToNewBranch(t *testing.T) { }) } -func TestEditFileCommitEmail(t *testing.T) { +func TestWebGitCommitEmail(t *testing.T) { onGiteaRun(t, func(t *testing.T, _ *url.URL) { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) - assert.True(t, user.KeepEmailPrivate) + require.True(t, user.KeepEmailPrivate) + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + gitRepo, _ := git.OpenRepository(git.DefaultContext, repo1.RepoPath()) + defer gitRepo.Close() + getLastCommit := func(t *testing.T) *git.Commit { + c, err := gitRepo.GetBranchCommit("master") + require.NoError(t, err) + return c + } session := loginUser(t, user.Name) - link := "/user2/repo1/_edit/master/README.md" - getLastCommitID := func(t *testing.T) string { - req := NewRequest(t, "GET", link) - resp := session.MakeRequest(t, req, http.StatusOK) - htmlDoc := NewHTMLParser(t, resp.Body) - lastCommit := htmlDoc.GetInputValueByName("last_commit") - require.NotEmpty(t, lastCommit) - return lastCommit + makeReq := func(t *testing.T, link string, params map[string]string, expectedUserName, expectedEmail string) { + lastCommit := getLastCommit(t) + params["_csrf"] = GetUserCSRFToken(t, session) + params["last_commit"] = lastCommit.ID.String() + params["commit_choice"] = "direct" + req := NewRequestWithValues(t, "POST", link, params) + resp := session.MakeRequest(t, req, NoExpectedStatus) + newCommit := getLastCommit(t) + if expectedUserName == "" { + require.Equal(t, lastCommit.ID.String(), newCommit.ID.String()) + htmlDoc := NewHTMLParser(t, resp.Body) + errMsg := htmlDoc.doc.Find(".ui.negative.message").Text() + assert.Contains(t, errMsg, translation.NewLocale("en-US").Tr("repo.editor.invalid_commit_email")) + } else { + require.NotEqual(t, lastCommit.ID.String(), newCommit.ID.String()) + assert.EqualValues(t, expectedUserName, newCommit.Author.Name) + assert.EqualValues(t, expectedEmail, newCommit.Author.Email) + assert.EqualValues(t, expectedUserName, newCommit.Committer.Name) + assert.EqualValues(t, expectedEmail, newCommit.Committer.Email) + } } - newReq := func(t *testing.T, session *TestSession, email, content string) *RequestWrapper { - req := NewRequestWithValues(t, "POST", link, map[string]string{ - "_csrf": GetUserCSRFToken(t, session), - "last_commit": getLastCommitID(t), - "tree_path": "README.md", - "content": content, - "commit_choice": "direct", - "commit_email": email, - }) - return req + uploadFile := func(t *testing.T, name, content string) string { + body := &bytes.Buffer{} + uploadForm := multipart.NewWriter(body) + file, _ := uploadForm.CreateFormFile("file", name) + _, _ = io.Copy(file, bytes.NewBufferString(content)) + _ = uploadForm.WriteField("_csrf", GetUserCSRFToken(t, session)) + _ = uploadForm.Close() + + req := NewRequestWithBody(t, "POST", "/user2/repo1/upload-file", body) + req.Header.Add("Content-Type", uploadForm.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusOK) + + respMap := map[string]string{} + DecodeJSON(t, resp, &respMap) + return respMap["uuid"] } t.Run("EmailInactive", func(t *testing.T) { defer tests.PrintCurrentTest(t)() email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 35, UID: user.ID}) - assert.False(t, email.IsActivated) - - req := newReq(t, session, email.Email, "test content") - resp := session.MakeRequest(t, req, http.StatusOK) - htmlDoc := NewHTMLParser(t, resp.Body) - assert.Contains(t, - htmlDoc.doc.Find(".ui.negative.message").Text(), - translation.NewLocale("en-US").Tr("repo.editor.invalid_commit_email"), - ) + require.False(t, email.IsActivated) + makeReq(t, "/user2/repo1/_edit/master/README.md", map[string]string{ + "tree_path": "README.md", + "content": "test content", + "commit_email": email.Email, + }, "", "") }) t.Run("EmailInvalid", func(t *testing.T) { defer tests.PrintCurrentTest(t)() email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 1, IsActivated: true}) - assert.NotEqualValues(t, email.UID, user.ID) - - req := newReq(t, session, email.Email, "test content") - resp := session.MakeRequest(t, req, http.StatusOK) - htmlDoc := NewHTMLParser(t, resp.Body) - assert.Contains(t, - htmlDoc.doc.Find(".ui.negative.message").Text(), - translation.NewLocale("en-US").Tr("repo.editor.invalid_commit_email"), - ) + require.NotEqualValues(t, email.UID, user.ID) + makeReq(t, "/user2/repo1/_edit/master/README.md", map[string]string{ + "tree_path": "README.md", + "content": "test content", + "commit_email": email.Email, + }, "", "") }) - repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - gitRepo, _ := git.OpenRepository(git.DefaultContext, repo1.RepoPath()) - defer gitRepo.Close() + testWebGit := func(t *testing.T, linkForKeepPrivate string, paramsForKeepPrivate map[string]string, linkForChosenEmail string, paramsForChosenEmail map[string]string) { + t.Run("DefaultEmailKeepPrivate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + paramsForKeepPrivate["commit_email"] = "" + makeReq(t, linkForKeepPrivate, paramsForKeepPrivate, "User Two", "user2@noreply.example.org") + }) + t.Run("ChooseEmail", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + paramsForChosenEmail["commit_email"] = "user2@example.com" + makeReq(t, linkForChosenEmail, paramsForChosenEmail, "User Two", "user2@example.com") + }) + } - t.Run("DefaultEmailKeepPrivate", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() - req := newReq(t, session, "", "privacy email") - session.MakeRequest(t, req, http.StatusSeeOther) - - commit, err := gitRepo.GetCommitByPath("README.md") - assert.NoError(t, err) - - fileContent, err := commit.GetFileContent("README.md", 64) - assert.NoError(t, err) - assert.EqualValues(t, "privacy email", fileContent) - assert.EqualValues(t, "User Two", commit.Author.Name) - assert.EqualValues(t, "user2@noreply.example.org", commit.Author.Email) - assert.EqualValues(t, "User Two", commit.Committer.Name) - assert.EqualValues(t, "user2@noreply.example.org", commit.Committer.Email) + t.Run("Edit", func(t *testing.T) { + testWebGit(t, + "/user2/repo1/_edit/master/README.md", map[string]string{"tree_path": "README.md", "content": "for keep private"}, + "/user2/repo1/_edit/master/README.md", map[string]string{"tree_path": "README.md", "content": "for chosen email"}, + ) }) - t.Run("ChooseEmail", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() - - email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 3, UID: user.ID, IsActivated: true}) - req := newReq(t, session, email.Email, "chosen email") - session.MakeRequest(t, req, http.StatusSeeOther) + t.Run("UploadDelete", func(t *testing.T) { + file1UUID := uploadFile(t, "file1", "File 1") + file2UUID := uploadFile(t, "file2", "File 2") + testWebGit(t, + "/user2/repo1/_upload/master", map[string]string{"files": file1UUID}, + "/user2/repo1/_upload/master", map[string]string{"files": file2UUID}, + ) + testWebGit(t, + "/user2/repo1/_delete/master/file1", map[string]string{}, + "/user2/repo1/_delete/master/file2", map[string]string{}, + ) + }) - commit, err := gitRepo.GetCommitByPath("README.md") - assert.NoError(t, err) + t.Run("ApplyPatchCherryPick", func(t *testing.T) { + testWebGit(t, + "/user2/repo1/_diffpatch/master", map[string]string{ + "tree_path": "__dummy__", + "content": `diff --git a/patch-file-1.txt b/patch-file-1.txt +new file mode 100644 +index 0000000000..aaaaaaaaaa +--- /dev/null ++++ b/patch-file-1.txt +@@ -0,0 +1 @@ ++File 1 +`, + }, + "/user2/repo1/_diffpatch/master", map[string]string{ + "tree_path": "__dummy__", + "content": `diff --git a/patch-file-2.txt b/patch-file-2.txt +new file mode 100644 +index 0000000000..bbbbbbbbbb +--- /dev/null ++++ b/patch-file-2.txt +@@ -0,0 +1 @@ ++File 2 +`, + }, + ) - fileContent, err := commit.GetFileContent("README.md", 64) - assert.NoError(t, err) - assert.EqualValues(t, "chosen email", fileContent) - assert.EqualValues(t, "User Two", commit.Author.Name) - assert.EqualValues(t, email.Email, commit.Author.Email) - assert.EqualValues(t, "User Two", commit.Committer.Name) - assert.EqualValues(t, email.Email, commit.Committer.Email) + commit1, err := gitRepo.GetCommitByPath("patch-file-1.txt") + require.NoError(t, err) + commit2, err := gitRepo.GetCommitByPath("patch-file-2.txt") + require.NoError(t, err) + testWebGit(t, + "/user2/repo1/_cherrypick/"+commit1.ID.String()+"/master", map[string]string{"revert": "true"}, + "/user2/repo1/_cherrypick/"+commit2.ID.String()+"/master", map[string]string{"revert": "true"}, + ) }) }) }